From 40318aafdd974dfd71c61834e16308b8620a966f Mon Sep 17 00:00:00 2001 From: NetDesk Date: Sun, 18 Oct 2026 02:02:46 +0000 Subject: [PATCH] =?UTF-8?q?NetDesk=201.5.0=20=E2=80=94=20source=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 26 + LICENSE | 19 + NetDeskHost.spec | 45 + NetDeskServer.spec | 38 + README.md | 293 + docker/.env.example | 39 + docker/Dockerfile | 29 + docker/README.md | 41 + docker/docker-compose.yml | 44 + docker/docker-entrypoint.sh | 28 + host/icon.ico | Bin 0 -> 17581 bytes host/netdesk_gui.html | 971 +++ host/netdesk_gui.py | 825 +++ host/netdesk_host.py | 3318 ++++++++++ host/pty_session.py | 112 + native/.cargo/config.toml | 10 + native/.gitignore | 22 + native/Cargo.lock | 5758 +++++++++++++++++ native/Cargo.toml | 41 + native/README.md | 63 + native/assets/icon-512.png | Bin 0 -> 5204 bytes native/assets/icon.ico | Bin 0 -> 17077 bytes native/assets/icon.png | Bin 0 -> 2743 bytes native/assets/logo.svg | 1 + native/cli/Cargo.toml | 97 + native/cli/src/bin/direct_connect.rs | 80 + native/cli/src/bin/e2e.rs | 1475 +++++ native/cli/src/bin/fps_probe.rs | 73 + native/cli/src/bin/getdir.rs | 125 + native/cli/src/bin/lan_scan.rs | 33 + native/cli/src/bin/privacy_test.rs | 95 + native/cli/src/bin/tap-probe.rs | 64 + native/cli/src/bin/term.rs | 77 + native/cli/src/bin/tunnel.rs | 210 + native/cli/src/bin/voice_send.rs | 81 + native/cli/src/main.rs | 192 + native/core/Cargo.toml | 82 + native/core/src/brand.rs | 214 + native/core/src/cidr.rs | 88 + native/core/src/crypto.rs | 450 ++ native/core/src/decode.rs | 254 + native/core/src/decode_mediacodec.rs | 512 ++ native/core/src/decode_mf.rs | 646 ++ native/core/src/decode_vaapi.rs | 287 + native/core/src/decode_vt.rs | 539 ++ native/core/src/error.rs | 23 + native/core/src/host.rs | 1049 +++ native/core/src/lan.rs | 205 + native/core/src/lib.rs | 34 + native/core/src/netqos.rs | 223 + native/core/src/p2p.rs | 831 +++ native/core/src/proto.rs | 184 + native/core/src/recorder.rs | 182 + native/core/src/rtc.rs | 585 ++ native/core/src/signaling.rs | 1170 ++++ native/core/src/transport.rs | 471 ++ native/core/src/update.rs | 133 + native/core/src/webm.rs | 420 ++ native/core/tests/host_viewer.rs | 149 + native/core/tests/p2p_loopback.rs | 1723 +++++ native/core/tests/perms_enforce.rs | 739 +++ native/docs/build-android.md | 180 + native/docs/build-ios.md | 288 + native/docs/build-linux.md | 189 + native/docs/build-macos.md | 293 + native/docs/cross-platform-decode.md | 223 + native/frb/Cargo.toml | 32 + native/frb/flutter_rust_bridge.yaml | 6 + native/frb/src/api.rs | 632 ++ native/frb/src/frb_generated.rs | 1350 ++++ native/frb/src/lib.rs | 6 + native/host/Cargo.toml | 81 + native/host/src/annot.rs | 261 + native/host/src/audio.rs | 551 ++ native/host/src/bin/encbench.rs | 203 + native/host/src/bin/engine.rs | 76 + native/host/src/bin/privacy_probe.rs | 146 + native/host/src/bin/probe.rs | 75 + native/host/src/capture.rs | 489 ++ native/host/src/color.rs | 99 + native/host/src/cursor.rs | 282 + native/host/src/encode.rs | 74 + native/host/src/encode_mf.rs | 486 ++ native/host/src/encoder.rs | 218 + native/host/src/files.rs | 188 + native/host/src/input.rs | 521 ++ native/host/src/keyhook.rs | 216 + native/host/src/lib.rs | 58 + native/host/src/privacy.rs | 131 + native/host/src/qos.rs | 365 ++ native/host/src/run.rs | 1994 ++++++ native/host/src/service.rs | 289 + native/host/src/sys.rs | 319 + native/host/src/telemetry.rs | 118 + native/host/src/term.rs | 209 + native/host/src/wol.rs | 92 + native/opus/Cargo.toml | 15 + native/opus/build.rs | 48 + native/opus/src/lib.rs | 225 + native/opus/wrapper.h | 1 + native/packaging/README.md | 130 + native/packaging/build-android.ps1 | 402 ++ native/packaging/build-brand.ps1 | 58 + native/packaging/build-installer.ps1 | 145 + native/packaging/build-ios.sh | 241 + native/packaging/build-linux.sh | 341 + native/packaging/build-macos.sh | 364 ++ native/packaging/build-msi.ps1 | 73 + native/packaging/build-portable.ps1 | 129 + native/packaging/ios/Podfile | 60 + native/packaging/ios/netdesk_core.podspec | 65 + native/packaging/netdesk.ico | Bin 0 -> 17077 bytes native/packaging/netdesk.nsi | 218 + native/packaging/netdesk.wxs | 81 + native/third_party/ucrt64/include/opus/opus.h | 1175 ++++ .../ucrt64/include/opus/opus_custom.h | 381 ++ .../ucrt64/include/opus/opus_defines.h | 867 +++ .../ucrt64/include/opus/opus_multistream.h | 736 +++ .../ucrt64/include/opus/opus_projection.h | 643 ++ .../ucrt64/include/opus/opus_types.h | 166 + native/third_party/ucrt64/include/vpx/vp8.h | 136 + native/third_party/ucrt64/include/vpx/vp8cx.h | 1129 ++++ native/third_party/ucrt64/include/vpx/vp8dx.h | 228 + .../ucrt64/include/vpx/vpx_codec.h | 475 ++ .../ucrt64/include/vpx/vpx_decoder.h | 371 ++ .../ucrt64/include/vpx/vpx_encoder.h | 1163 ++++ .../ucrt64/include/vpx/vpx_ext_ratectrl.h | 605 ++ .../ucrt64/include/vpx/vpx_frame_buffer.h | 83 + .../ucrt64/include/vpx/vpx_image.h | 255 + .../ucrt64/include/vpx/vpx_integer.h | 40 + .../third_party/ucrt64/include/vpx/vpx_tpl.h | 69 + native/third_party/ucrt64/lib/libopus.a | Bin 0 -> 732568 bytes native/third_party/ucrt64/lib/libvpx.a | Bin 0 -> 5628452 bytes native/ui/.gitignore | 48 + native/ui/.metadata | 30 + native/ui/README.md | 89 + native/ui/analysis_options.yaml | 33 + native/ui/android/.gitignore | 18 + native/ui/android/app/build.gradle.kts | 83 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 103 + .../main/kotlin/space/netdesk/BootReceiver.kt | 42 + .../main/kotlin/space/netdesk/HostService.kt | 311 + .../main/kotlin/space/netdesk/MainActivity.kt | 212 + .../netdesk/NetDeskAccessibilityService.kt | 84 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/strings.xml | 5 + .../app/src/main/res/values/styles.xml | 18 + .../res/xml/accessibility_service_config.xml | 8 + .../app/src/profile/AndroidManifest.xml | 7 + native/ui/android/build.gradle.kts | 24 + native/ui/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + native/ui/android/key.properties.example | 14 + native/ui/android/settings.gradle.kts | 26 + native/ui/assets/icon-512.png | Bin 0 -> 5204 bytes native/ui/assets/icon.png | Bin 0 -> 2743 bytes native/ui/lib/connect_screen.dart | 613 ++ native/ui/lib/host_screen.dart | 539 ++ native/ui/lib/main.dart | 63 + native/ui/lib/rust/api.dart | 258 + native/ui/lib/rust/api.freezed.dart | 3083 +++++++++ native/ui/lib/rust/frb_generated.dart | 1190 ++++ native/ui/lib/rust/frb_generated.io.dart | 196 + native/ui/lib/rust/frb_generated.web.dart | 196 + native/ui/lib/session_screen.dart | 1447 +++++ native/ui/lib/theme.dart | 196 + native/ui/pubspec.lock | 605 ++ native/ui/pubspec.yaml | 43 + native/ui/test/widget_test.dart | 22 + native/viewer/Cargo.toml | 64 + native/viewer/build.rs | 74 + native/viewer/src/audio.rs | 94 + native/viewer/src/home.rs | 2905 +++++++++ native/viewer/src/host_ctl.rs | 1959 ++++++ native/viewer/src/main.rs | 4653 +++++++++++++ native/vpx/Cargo.toml | 15 + native/vpx/build.rs | 62 + native/vpx/src/lib.rs | 742 +++ native/vpx/wrapper.h | 9 + publishing/README.md | 52 + publishing/nginx-source.snippet | 16 + publishing/source-page.html | 133 + requirements.txt | 17 + scripts/build_mac.sh | 46 + scripts/build_windows.bat | 27 + scripts/deploy/com.netdesk.host.plist | 27 + scripts/deploy/deploy_vps.sh | 51 + scripts/deploy/netdesk.service | 23 + scripts/install_autostart.bat | 13 + scripts/run_local_demo.bat | 22 + scripts/start_host_hidden.vbs | 6 + scripts/uninstall_autostart.bat | 8 + server/netdesk_server.py | 4263 ++++++++++++ server/web/about.html | 42 + server/web/admin.html | 230 + server/web/admin.js | 447 ++ server/web/app.css | 1302 ++++ server/web/app.html | 351 + server/web/app.js | 2995 +++++++++ server/web/contact.html | 50 + server/web/download.html | 202 + server/web/features.html | 41 + server/web/host.js | 307 + server/web/index.html | 329 + server/web/manifest.webmanifest | 9 + server/web/ndicon.svg | 1 + server/web/portal.html | 290 + server/web/portal.js | 349 + server/web/pricing.html | 79 + server/web/privacy.html | 44 + server/web/refund.html | 32 + server/web/security.html | 48 + server/web/site.css | 289 + server/web/site.js | 103 + server/web/sitemap.xml | 13 + server/web/terms.html | 44 + tools/patch_accounts.py | 219 + tools/patch_plans.py | 252 + tools/smoke_test.py | 407 ++ 227 files changed, 80798 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NetDeskHost.spec create mode 100644 NetDeskServer.spec create mode 100644 README.md create mode 100644 docker/.env.example create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100644 docker/docker-entrypoint.sh create mode 100644 host/icon.ico create mode 100644 host/netdesk_gui.html create mode 100644 host/netdesk_gui.py create mode 100644 host/netdesk_host.py create mode 100644 host/pty_session.py create mode 100644 native/.cargo/config.toml create mode 100644 native/.gitignore create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/README.md create mode 100644 native/assets/icon-512.png create mode 100644 native/assets/icon.ico create mode 100644 native/assets/icon.png create mode 100644 native/assets/logo.svg create mode 100644 native/cli/Cargo.toml create mode 100644 native/cli/src/bin/direct_connect.rs create mode 100644 native/cli/src/bin/e2e.rs create mode 100644 native/cli/src/bin/fps_probe.rs create mode 100644 native/cli/src/bin/getdir.rs create mode 100644 native/cli/src/bin/lan_scan.rs create mode 100644 native/cli/src/bin/privacy_test.rs create mode 100644 native/cli/src/bin/tap-probe.rs create mode 100644 native/cli/src/bin/term.rs create mode 100644 native/cli/src/bin/tunnel.rs create mode 100644 native/cli/src/bin/voice_send.rs create mode 100644 native/cli/src/main.rs create mode 100644 native/core/Cargo.toml create mode 100644 native/core/src/brand.rs create mode 100644 native/core/src/cidr.rs create mode 100644 native/core/src/crypto.rs create mode 100644 native/core/src/decode.rs create mode 100644 native/core/src/decode_mediacodec.rs create mode 100644 native/core/src/decode_mf.rs create mode 100644 native/core/src/decode_vaapi.rs create mode 100644 native/core/src/decode_vt.rs create mode 100644 native/core/src/error.rs create mode 100644 native/core/src/host.rs create mode 100644 native/core/src/lan.rs create mode 100644 native/core/src/lib.rs create mode 100644 native/core/src/netqos.rs create mode 100644 native/core/src/p2p.rs create mode 100644 native/core/src/proto.rs create mode 100644 native/core/src/recorder.rs create mode 100644 native/core/src/rtc.rs create mode 100644 native/core/src/signaling.rs create mode 100644 native/core/src/transport.rs create mode 100644 native/core/src/update.rs create mode 100644 native/core/src/webm.rs create mode 100644 native/core/tests/host_viewer.rs create mode 100644 native/core/tests/p2p_loopback.rs create mode 100644 native/core/tests/perms_enforce.rs create mode 100644 native/docs/build-android.md create mode 100644 native/docs/build-ios.md create mode 100644 native/docs/build-linux.md create mode 100644 native/docs/build-macos.md create mode 100644 native/docs/cross-platform-decode.md create mode 100644 native/frb/Cargo.toml create mode 100644 native/frb/flutter_rust_bridge.yaml create mode 100644 native/frb/src/api.rs create mode 100644 native/frb/src/frb_generated.rs create mode 100644 native/frb/src/lib.rs create mode 100644 native/host/Cargo.toml create mode 100644 native/host/src/annot.rs create mode 100644 native/host/src/audio.rs create mode 100644 native/host/src/bin/encbench.rs create mode 100644 native/host/src/bin/engine.rs create mode 100644 native/host/src/bin/privacy_probe.rs create mode 100644 native/host/src/bin/probe.rs create mode 100644 native/host/src/capture.rs create mode 100644 native/host/src/color.rs create mode 100644 native/host/src/cursor.rs create mode 100644 native/host/src/encode.rs create mode 100644 native/host/src/encode_mf.rs create mode 100644 native/host/src/encoder.rs create mode 100644 native/host/src/files.rs create mode 100644 native/host/src/input.rs create mode 100644 native/host/src/keyhook.rs create mode 100644 native/host/src/lib.rs create mode 100644 native/host/src/privacy.rs create mode 100644 native/host/src/qos.rs create mode 100644 native/host/src/run.rs create mode 100644 native/host/src/service.rs create mode 100644 native/host/src/sys.rs create mode 100644 native/host/src/telemetry.rs create mode 100644 native/host/src/term.rs create mode 100644 native/host/src/wol.rs create mode 100644 native/opus/Cargo.toml create mode 100644 native/opus/build.rs create mode 100644 native/opus/src/lib.rs create mode 100644 native/opus/wrapper.h create mode 100644 native/packaging/README.md create mode 100644 native/packaging/build-android.ps1 create mode 100644 native/packaging/build-brand.ps1 create mode 100644 native/packaging/build-installer.ps1 create mode 100644 native/packaging/build-ios.sh create mode 100644 native/packaging/build-linux.sh create mode 100644 native/packaging/build-macos.sh create mode 100644 native/packaging/build-msi.ps1 create mode 100644 native/packaging/build-portable.ps1 create mode 100644 native/packaging/ios/Podfile create mode 100644 native/packaging/ios/netdesk_core.podspec create mode 100644 native/packaging/netdesk.ico create mode 100644 native/packaging/netdesk.nsi create mode 100644 native/packaging/netdesk.wxs create mode 100644 native/third_party/ucrt64/include/opus/opus.h create mode 100644 native/third_party/ucrt64/include/opus/opus_custom.h create mode 100644 native/third_party/ucrt64/include/opus/opus_defines.h create mode 100644 native/third_party/ucrt64/include/opus/opus_multistream.h create mode 100644 native/third_party/ucrt64/include/opus/opus_projection.h create mode 100644 native/third_party/ucrt64/include/opus/opus_types.h create mode 100644 native/third_party/ucrt64/include/vpx/vp8.h create mode 100644 native/third_party/ucrt64/include/vpx/vp8cx.h create mode 100644 native/third_party/ucrt64/include/vpx/vp8dx.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_codec.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_decoder.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_encoder.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_image.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_integer.h create mode 100644 native/third_party/ucrt64/include/vpx/vpx_tpl.h create mode 100644 native/third_party/ucrt64/lib/libopus.a create mode 100644 native/third_party/ucrt64/lib/libvpx.a create mode 100644 native/ui/.gitignore create mode 100644 native/ui/.metadata create mode 100644 native/ui/README.md create mode 100644 native/ui/analysis_options.yaml create mode 100644 native/ui/android/.gitignore create mode 100644 native/ui/android/app/build.gradle.kts create mode 100644 native/ui/android/app/src/debug/AndroidManifest.xml create mode 100644 native/ui/android/app/src/main/AndroidManifest.xml create mode 100644 native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt create mode 100644 native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt create mode 100644 native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt create mode 100644 native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt create mode 100644 native/ui/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 native/ui/android/app/src/main/res/drawable/launch_background.xml create mode 100644 native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 native/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 native/ui/android/app/src/main/res/values-night/styles.xml create mode 100644 native/ui/android/app/src/main/res/values/strings.xml create mode 100644 native/ui/android/app/src/main/res/values/styles.xml create mode 100644 native/ui/android/app/src/main/res/xml/accessibility_service_config.xml create mode 100644 native/ui/android/app/src/profile/AndroidManifest.xml create mode 100644 native/ui/android/build.gradle.kts create mode 100644 native/ui/android/gradle.properties create mode 100644 native/ui/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 native/ui/android/key.properties.example create mode 100644 native/ui/android/settings.gradle.kts create mode 100644 native/ui/assets/icon-512.png create mode 100644 native/ui/assets/icon.png create mode 100644 native/ui/lib/connect_screen.dart create mode 100644 native/ui/lib/host_screen.dart create mode 100644 native/ui/lib/main.dart create mode 100644 native/ui/lib/rust/api.dart create mode 100644 native/ui/lib/rust/api.freezed.dart create mode 100644 native/ui/lib/rust/frb_generated.dart create mode 100644 native/ui/lib/rust/frb_generated.io.dart create mode 100644 native/ui/lib/rust/frb_generated.web.dart create mode 100644 native/ui/lib/session_screen.dart create mode 100644 native/ui/lib/theme.dart create mode 100644 native/ui/pubspec.lock create mode 100644 native/ui/pubspec.yaml create mode 100644 native/ui/test/widget_test.dart create mode 100644 native/viewer/Cargo.toml create mode 100644 native/viewer/build.rs create mode 100644 native/viewer/src/audio.rs create mode 100644 native/viewer/src/home.rs create mode 100644 native/viewer/src/host_ctl.rs create mode 100644 native/viewer/src/main.rs create mode 100644 native/vpx/Cargo.toml create mode 100644 native/vpx/build.rs create mode 100644 native/vpx/src/lib.rs create mode 100644 native/vpx/wrapper.h create mode 100644 publishing/README.md create mode 100644 publishing/nginx-source.snippet create mode 100644 publishing/source-page.html create mode 100644 requirements.txt create mode 100644 scripts/build_mac.sh create mode 100755 scripts/build_windows.bat create mode 100644 scripts/deploy/com.netdesk.host.plist create mode 100644 scripts/deploy/deploy_vps.sh create mode 100644 scripts/deploy/netdesk.service create mode 100755 scripts/install_autostart.bat create mode 100755 scripts/run_local_demo.bat create mode 100644 scripts/start_host_hidden.vbs create mode 100755 scripts/uninstall_autostart.bat create mode 100644 server/netdesk_server.py create mode 100644 server/web/about.html create mode 100644 server/web/admin.html create mode 100644 server/web/admin.js create mode 100644 server/web/app.css create mode 100644 server/web/app.html create mode 100644 server/web/app.js create mode 100644 server/web/contact.html create mode 100644 server/web/download.html create mode 100644 server/web/features.html create mode 100644 server/web/host.js create mode 100644 server/web/index.html create mode 100644 server/web/manifest.webmanifest create mode 100644 server/web/ndicon.svg create mode 100644 server/web/portal.html create mode 100644 server/web/portal.js create mode 100644 server/web/pricing.html create mode 100644 server/web/privacy.html create mode 100644 server/web/refund.html create mode 100644 server/web/security.html create mode 100644 server/web/site.css create mode 100644 server/web/site.js create mode 100644 server/web/sitemap.xml create mode 100644 server/web/terms.html create mode 100644 tools/patch_accounts.py create mode 100644 tools/patch_plans.py create mode 100644 tools/smoke_test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ead9342 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# build artifacts +dist/ +build/ +*.spec.bak +*.log + +# runtime state / user data - NEVER commit (contains tokens & password hashes) +portable/ +portable_test/ +deploy_secrets.txt +server/*.db +server/files/ +*.db + +# python +__pycache__/ +*.pyc +.venv/ +venv/ + +# OS +Thumbs.db +.DS_Store + +# Docker: local secrets, never commit +docker/.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e1a93e --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +NetDesk — Source-Available License +Copyright (c) 2026 Akronlogic. All rights reserved. + +You are granted a free, non-exclusive right to: + • download, read, and study this source code; + • build it and run your OWN self-hosted instance (personal or internal + business use), for any number of your own machines; + • modify it for your own self-hosting needs. + +Conditions: + • This notice and the copyright above must be kept in all copies. + • You may NOT resell, re-brand, or offer this software (modified or not) + as a hosted/managed service to third parties without written permission + from Akronlogic. + • The software is provided "AS IS", without warranty of any kind. Akronlogic + is not liable for any damages arising from its use. + +For commercial redistribution, OEM, or managed-hosting licensing, contact the +project owner via https://netdesk.space . diff --git a/NetDeskHost.spec b/NetDeskHost.spec new file mode 100644 index 0000000..a12255b --- /dev/null +++ b/NetDeskHost.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_submodules + +_zc = collect_submodules('zeroconf') + collect_submodules('aioice') + collect_submodules('aiortc') +# dxcam (DXGI Desktop Duplication fast capture) + its comtypes dependency. +_dx = collect_submodules('dxcam') + collect_submodules('comtypes') + +a = Analysis( + ['host/netdesk_host.py'], + pathex=['host'], + binaries=[], + datas=[('host/netdesk_gui.html', '.')], + hiddenimports=['netdesk_gui', 'pyaudiowpatch', 'qrcode', 'ipaddress', + 'pylibsrtp', 'pyee', 'ifaddr', 'dxcam', 'comtypes', + 'google_crc32c', 'OpenSSL', 'dns'] + _zc + _dx, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='NetDeskHost', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['host/icon.ico'], +) diff --git a/NetDeskServer.spec b/NetDeskServer.spec new file mode 100644 index 0000000..aa18bc7 --- /dev/null +++ b/NetDeskServer.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['server/netdesk_server.py'], + pathex=[], + binaries=[], + datas=[('server/web', 'web')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='NetDeskServer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..08bac8e --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# NetDesk — your own self-hosted remote desktop + +

+ Website + Version + Platform + E2E +

+ +**Website & live demo:** [netdesk.space](https://netdesk.space) · **Download:** [Windows agent](https://netdesk.space/download) · **Pricing:** [free to self-host](https://netdesk.space/pricing) + +A TeamViewer/RustDesk-style remote desktop system you fully own: a signaling/relay +server for your VPS, a **Windows service** on your powerful PC (reachable at boot, +at the logon screen, and across UAC prompts), and a **zero-install web viewer** +(Chrome/Edge) you can use from any laptop or phone while traveling. + +``` + ┌──────────────────────────┐ WSS outbound only ┌──────────┐ WSS ┌──────────────┐ + │ HOME PC (Windows service)│ ──────────────────► │ VPS │ ◄────── │ TRAVEL laptop│ + │ broker (SYSTEM, boot) │ │ netdesk │ │ any browser │ + │ └─ desktop agent │ ◄──── relayed ────► │ server │ │ (phone works)│ + │ (console session) │ E2E-encrypted └──────────┘ └──────────────┘ + └──────────────────────────┘ + │ + └──── optional direct mode: ws://home-ip:8901 (LAN / port-forward) ────┘ +``` + +## Features + +**Access & unattended operation** +- Boot-time Windows service (SYSTEM): no auto-logon needed, survives reboots +- Reachable at the **Windows logon screen** — view and type credentials to log in +- Input works across desktop switches (user desktop, logon screen, UAC prompts) via desktop-following injection +- Auto-reconnect to server forever (works behind any NAT, no port forwarding) +- Wake displays automatically when a viewer connects + +**Video / audio** +- H.264 (x264 ultrafast/zerolatency), up to 1080p60 @ 20 Mbps, idle screens send nothing +- Hardware decode in the browser (WebCodecs) + HTML5 canvas +- Adaptive **Auto quality** mode (RTT/decoder-queue based preset stepping) +- System audio (WASAPI loopback → Opus) — bundled in the packaged exe; `pip install PyAudioWPatch` for source runs + +**Full control** +- Mouse (all buttons, wheel), keyboard via SendInput scancodes, keyboard-lock for Alt+Tab/Win/Ctrl+W in fullscreen +- **Privacy mode** — turn the physical monitor off while you keep working +- **Lock PC on disconnect** (per-viewer checkbox) +- Process manager — list & kill remote processes +- Remote power — lock, sign out, reboot, shut down, run command +- Clipboard sync (two-way), chat + +**Files** +- Browse/download/upload/rename/delete/mkdir, **folder upload** with drag & drop +- Atomic uploads (.part → final), folder creation on demand +- **Remote printing** — 🖨 any host PDF, image, or text/code file to your own + local printer from the file manager (driver-free, browser-native print dialog) + +**Security** +- **End-to-end encryption** (default): ephemeral ECDH P-256 → AES-256-GCM per frame — the VPS cannot see your screen +- Password never leaves the host un-hashed (PBKDF2 200k challenge/response, 0.4 s throttle per bad attempt) +- Optional **TOTP 2FA** (`--set-totp auto` → authenticator code required at connect) +- Optional server registration key so only your hosts can register; `/status` API behind a token +- The service broker relays opaque bytes only — agents hold all keys + +**Accounts (RustDesk-style)** +- Sign up / sign in **in the desktop app and on the website** — no email + verification wall (the account works immediately; the email only confirms it) +- **My computers**: every PC where you sign in with your account is listed on + the website for one-click connect (online/offline/in-session status shown) +- Server address is **pre-filled** (netdesk.space) and no registration key is + needed by default; point the app at your own server anytime +- Pre-configured builds: name the exe `NetDeskHost-host=your.server,key=SECRET.exe` + and it auto-configures — hand it out with zero setup + +**Viewer niceties** +- Address book (saved connections), remember-password, view-only mode +- **Session recording** to .webm (one click) +- **TCP tunneling** — reach any TCP service on the host (SSH, RDP, web UIs) through the server; portal-gated, with a panel in the viewer +- **Installable app (PWA)** — install the viewer like a native app from Chrome/Edge/Safari on laptop or phone +- **Host-side remote printing** — send a file to the host's printer from the file manager +- Multi-monitor switching, quality presets, live stats (fps/RTT/bitrate/E2E badge) + +## Quick start (try on one PC) + +```bat +cd path\to\netdesk +scripts\run_local_demo.bat +``` + +First run prints and saves a 9-digit ID and a random password +(`%APPDATA%\NetDesk\config.json`). Automated end-to-end test: +`.venv\Scripts\python tools\smoke_test.py` (23 checks: auth, 2FA, E2E video, +clipboard, processes, privacy, files, input, broker path…). + +## Portable exe — a regular desktop app, no Python needed + +`portable\` (and `..\NetDesk-portable-win64.zip`) ships standalone Windows x64 +binaries built with PyInstaller, **tested end-to-end** (full 20-check suite +passes against the packaged exes): + +| File | Purpose | +|---|---| +| `NetDeskHost.exe` (54 MB, windowed) | the agent **desktop app** — double-click it: a control panel opens with live status, your ID, server settings, password/2FA management, start/stop, and one-click service install | +| `NetDeskServer.exe` (14 MB, console) | server for a Windows VPS; serves the viewer **and the portal** | + +Double-clicking `NetDeskHost.exe` opens the control panel (config + logs are +created next to the exe — the folder is fully portable). Advanced users can +still drive everything from a terminal (`--server …`, `--install`, +`--set-password`, `--set-totp auto`, `--uninstall`); the app itself is +windowless, so CLI output goes to `host.log` next to the exe. `--install` +sets up the same boot-time SYSTEM service with full logon-screen/UAC access. + +## macOS (MacBook M4 / Apple Silicon and Intel) + +Mac binaries cannot be cross-compiled from Windows, so the repo builds them ON +the Mac — native, one command: + +```bash +git scp the netdesk folder to the Mac, then: +bash scripts/build_mac.sh # -> dist/NetDeskHost + dist/NetDeskServer (arm64) +``` + +- `NetDeskHost` on the Mac = control the Mac remotely (capture via mss, input + via CoreGraphics CGEvent, clipboard via pbpaste/pbcopy, process manager, + power via osascript). First run: grant **Screen Recording** and + **Accessibility** permissions (System Settings → Privacy & Security); if + Gatekeeper complains: `xattr -cr NetDeskHost`. +- Autostart on login: `scripts/deploy/com.netdesk.host.plist` → copy to + `~/Library/LaunchAgents/` (edit the path/server inside first), + then `launchctl load ~/Library/LaunchAgents/com.netdesk.host.plist`. +- `NetDeskServer` also builds on the Mac if you want a Mac VPS. +- macOS audio capture needs a loopback device (e.g. BlackHole) — off by default. +- Connecting TO a Mac or PC from the MacBook: just the browser — open the + server URL in Chrome/Edge/Safari; nothing to install. + +## Web management portal + +Start the server with `--portal-password "YourPortalPassword"` and open +`https://your-server:8443/portal` — a TeamViewer-console-style dashboard: + +- **Computers list** — every host that ever registered, online/offline status, + version, last-seen, and a **Connect** button that deep-links into the viewer + (`/?id=` — ID prefilled, just type the host password) +- **Live stats** — hosts online, sessions total, relayed traffic, uptime +- **Activity feed** — host online/offline, connect attempts, accepted sessions, + wrong-password/2FA failures (auth failures are visible server-side; passwords + never are) +- **Downloads** — anything you drop into the server's `files/` folder + (e.g. `NetDeskHost.exe`) is offered to logged-in users, so your travel laptop + can always grab the current agent from your own server + +Access control: single portal password (cookie session, HttpOnly, 7 days). +The portal runs over the same TLS as everything else — keep TLS on for +internet use. By design the portal **cannot** see or store host passwords +(end-to-end encryption); connecting always needs the host's own password. + +## Production setup + +### 1. Server on your VPS (Debian/Ubuntu) + +```bash +cd netdesk/scripts/deploy && sudo bash deploy_vps.sh # installs systemd service, prints REG_KEY +# add --portal-password "..." to the unit for the web portal at /portal +sudo ufw allow 8443/tcp +``` + +TLS (recommended): Let's Encrypt via certbot, then add `--cert/--key` to the unit +and restart. Quick alternative: `--gen-cert` (self-signed, accept browser warning once). + +### 2. Host on your powerful PC (twice, then forget it) + +```bat +:: once, as your user: pick the password and server +.venv\Scripts\python host\netdesk_host.py --set-password "YourStrongPassword" +.venv\Scripts\python host\netdesk_host.py --server wss://desk.yourdomain.com:8443/ws --reg-key REG_KEY --listen 8901 + +:: once, as Administrator: install the boot-time service +scripts\install_autostart.bat (runs: netdesk_host.py --install) +``` + +The installer copies credentials to `C:\ProgramData\NetDesk` and creates a +SYSTEM scheduled task (ONSTART) running the **broker**. The broker spawns a +**desktop agent** into your console session and re-spawns it on user switches. +Optional: `--set-totp auto` for a 2FA secret (print → add to your authenticator). +Optional: `pip install PyAudioWPatch` for remote sound. + +### 3. Travel + +Open `https://desk.yourdomain.com:8443/` — enter ID (+ 2FA code if enabled) and +password. Save it in the address book. Works on phones too. At the office logon +screen after a reboot? Connect and type your Windows password — you're in. + +## Security model + +TLS to the server + end-to-end AES-GCM media + PBKDF2 password proof + optional +TOTP + registration key. Single session per host; second viewer gets "host busy". +Everything is stateless on the VPS. + +## Silent operation & what "undetected" means here + +Like RustDesk 1.2, NetDesk is invisible on the host while you work: + +- The engine is **headless** — no window, no tray icon, nothing pops up when a + session starts. In service mode it runs as a hidden SYSTEM task from boot. +- **Privacy mode** turns the physical monitor off while you keep working. + Enable **"Blank the monitor while controlled"** in the app (or per-session + via the viewer's privacy button) and the screen goes dark the moment a + viewer connects; it comes back when the session ends. +- Sessions inject input only; a person sitting at the PC would see the mouse + move (true for RustDesk/TeamViewer too — blocking local input needs a driver). + +Be honest about the detection surface, because "undetected" does not mean +"forensically invisible": the process shows in Task Manager, Windows sees the +scheduled task, the host keeps logs (`host.log`), and the machine makes an +outbound connection to your VPS. RustDesk has the same surfaces. That is the +correct trade-off for administering **your own or explicitly authorized +computers**; covertly controlling a machine you do not own or are not +authorized to manage is illegal in most jurisdictions and this tool is not +designed for it. + +## Honest limitations + +- No UDP P2P hole-punching: browsers can't send raw UDP, so traffic relays + through your VPS (~1 extra RTT; same as RustDesk when its punch fails). Direct + mode (`--listen` + port-forward) bypasses the VPS on your LAN. +- Remote printing: you can print any host file (PDF, image, text/code) straight + to **your** local printer from the web viewer's file manager — the 🖨 button + streams the file and opens your browser's native print dialog, no driver on + either side. Seamless redirection of an *arbitrary* remote app's print job + (pressing Ctrl+P inside a program on the host and having it pop out on your + printer) still needs a virtual printer driver and isn't wired up yet. +- Privacy mode blanks the operator's own screen — the Windows host powers the + physical monitor down, the native client paints a capture-excluded black + overlay — so nobody beside the machine can watch the session. A true + multi-session "fake desktop" (a separate virtual display for the remote + operator while a local user keeps working on the real screen at the same time) + needs a virtual display driver and is still on the roadmap. +- Session-0 notes: input at the logon screen/UAC requires the service install + (agent as SYSTEM). Ctrl+Alt+Del itself is never injectable without a driver. +- Mobile: there **is** a native Android app — install the APK to use your phone + or tablet as a viewer (control your PCs), and the Android build also includes + host support (control the phone from a PC). There's no native iOS app yet; + iPhone/iPad use the web viewer, which runs in any mobile browser. + +## Files + +``` +netdesk/ +├── server/netdesk_server.py # signaling + relay + web host (stateless) +├── server/web/ # viewer UI +├── host/netdesk_host.py # broker + desktop agent + service installer +├── tools/smoke_test.py # 23-check end-to-end suite +├── scripts/ # local demo, service install, VPS deploy +└── requirements.txt +``` + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| "host offline" | Broker not running: `schtasks /Run /TN NetDesk`, or start `--broker` manually; logs in the config folder's `host.log` | +| "bad 2FA code" | Host clock drift — resync Windows time, or re-set `--set-totp` | +| Black screen + "logon screen" banner | Nobody logged in — type credentials via keyboard, desktop switches automatically | +| Input dead on UAC prompt | Only in simple (non-service) mode — install the service for SYSTEM desktop access | +| Choppy | Auto quality preset, or direct mode on LAN | +| E2E badge missing | Old browser — needs WebCodecs + WebCrypto (Chrome/Edge 102+) | + +## Get the source code + +The full source is published on the project website (no GitHub account needed): + +```bash +# Clone directly from netdesk.space +git clone https://netdesk.space/git/netdesk.git + +# …or download a snapshot +# https://netdesk.space/source → .tar.gz / .zip +``` + +## Repository layout + +``` +netdesk/ +├── server/ Python aiohttp signaling + relay + web client +├── host/ Python Windows host/agent + desktop GUI (pywebview) +├── native/ from-scratch native client (Rust core + Flutter UI) +├── docker/ Dockerfile + compose for the server +├── scripts/ tools/ helper + maintenance scripts +└── requirements.txt Python deps for the server/host +``` + +## License + +Source-available — free to self-host and modify for your own use. See `LICENSE`. diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..4284f58 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,39 @@ +# NetDesk server config — copy to `.env` and fill in. `.env` is gitignored. +# Every value is optional; the server runs with sensible defaults, but you want +# at least a status key + portal password + TURN secret for a real deployment. + +# Public port to publish the server on (maps to container :8443). +ND_PORT=8443 + +# Secret required to read /status details (pick a long random string). +ND_STATUS_KEY= + +# Secret hosts must present to register (recommended; blocks stranger hosts). +ND_REG_KEY= + +# Password for the web management portal (/portal). +ND_PORTAL_PASSWORD= + +# Email granted unrestricted access + the admin panel. +ND_OWNER_EMAIL= + +# Public base URL used in verification emails, e.g. https://netdesk.example.com +ND_PUBLIC_URL= + +# Email accounts (optional): sender address + SMTP relay for verification mail. +ND_MAIL_FROM= +ND_SMTP_HOST=127.0.0.1 + +# Self-hosted coturn (optional): the server mints 12h HMAC TURN creds against +# this secret; the coturn sidecar shares it. TURN_REALM is the coturn realm. +ND_TURN_HOST= +ND_TURN_SECRET= +TURN_REALM=netdesk.local + +# In-process TLS (optional): paths INSIDE the container (mount certs in compose). +# Usually you terminate TLS at a reverse proxy instead and leave these blank. +ND_CERT= +ND_KEY= + +# Set to 1 for debug logging. +ND_VERBOSE=0 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..4d89fb9 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,29 @@ +# NetDesk signaling/relay server — container image. +# Build context is the repo root (so it can copy server/): +# docker build -f docker/Dockerfile -t netdesk-server . +# or just `docker compose up -d --build` from docker/. +FROM python:3.12-slim + +# aiohttp + cryptography (the server's only third-party deps). Pinned to the +# versions the production host runs. +RUN pip install --no-cache-dir aiohttp==3.9.1 cryptography==41.0.7 + +WORKDIR /app/server +# Server code + the web assets it serves (web/ is resolved relative to the +# script, so it must sit next to netdesk_server.py). +COPY server/netdesk_server.py /app/server/netdesk_server.py +COPY server/web /app/server/web + +# Persistent state (accounts.db, audit.log, files/) lives on a mounted volume, +# kept OUT of the image + the code dir via ND_DATA_DIR (see _nd_data_root()). +ENV ND_DATA_DIR=/data +RUN mkdir -p /data +VOLUME ["/data"] + +COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# The server listens on 8443 (plain HTTP/WS; terminate TLS at a reverse proxy, +# or pass ND_CERT/ND_KEY to enable TLS in-process). +EXPOSE 8443 +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..6367005 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,41 @@ +# NetDesk server — Docker + +Run a full self-hosted NetDesk (signaling + relay + web viewer/host + accounts + +portal + audit) with one command, plus an optional coturn TURN sidecar. + +```sh +cd docker +cp .env.example .env # then edit .env — at least ND_STATUS_KEY + ND_PORTAL_PASSWORD +docker compose up -d --build +``` + +The server listens on `:8443` (plain HTTP/WS). For the public internet, terminate +TLS at a reverse proxy (nginx/Caddy/Traefik) in front of it, or set `ND_CERT` / +`ND_KEY` in `.env` (and mount the certs in `docker-compose.yml`) to run TLS +in-process. + +Quick check once it's up: + +```sh +curl "http://localhost:8443/status?token=$ND_STATUS_KEY" +``` + +## Persistence + +All state — `accounts.db`, `audit.log`, and uploaded `files/` — lives in the +named volume `netdesk-data` (mounted at `/data` via `ND_DATA_DIR`), so it +survives `docker compose down` / image rebuilds. Back it up with +`docker run --rm -v netdesk-data:/data -v "$PWD":/backup busybox tar czf /backup/netdesk-data.tgz -C /data .`. + +## Config + +Everything is set through `.env` (see `.env.example` for the full list); the +entrypoint maps `ND_*` vars to the server's CLI flags. Only non-empty vars are +passed, so the defaults apply otherwise. + +## TURN + +The `coturn` sidecar runs with `--use-auth-secret` sharing `ND_TURN_SECRET`; the +server mints 12-hour HMAC credentials against the same secret. It uses host +networking because TURN needs the real relay port range. Leave `ND_TURN_HOST` / +`ND_TURN_SECRET` blank to disable TURN (direct P2P + the WSS relay still work). diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..7be8dc3 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,44 @@ +# NetDesk self-hosted stack: the signaling/relay/web server + a coturn TURN +# sidecar. One command to stand up a full self-hosted NetDesk: +# +# cd docker && cp .env.example .env # then edit .env (secrets) +# docker compose up -d --build +# +# Terminate TLS at your reverse proxy (nginx/Caddy/Traefik) in front of :8443, +# or set ND_CERT/ND_KEY + mount the certs to run TLS in-process. +services: + netdesk: + build: + context: .. # repo root, so the image can COPY server/ + dockerfile: docker/Dockerfile + image: netdesk-server:latest + restart: unless-stopped + ports: + - "${ND_PORT:-8443}:8443" # HTTP/WS (put a TLS proxy in front for the internet) + env_file: .env + volumes: + - netdesk-data:/data # accounts.db, audit.log, files/ (ND_DATA_DIR) + # If you enable in-process TLS, also mount your certs and set ND_CERT/ND_KEY: + # - ./certs:/certs:ro + + # Self-hosted TURN (use-auth-secret): the server mints 12h HMAC creds against + # ND_TURN_SECRET, and peers relay through here when direct P2P can't punch NAT. + # Set ND_TURN_HOST + ND_TURN_SECRET in .env (and TURN_REALM) to activate it. + coturn: + image: coturn/coturn:4.6.2 + restart: unless-stopped + network_mode: host # TURN needs the real host ports / relay range + command: + - -n + - --use-auth-secret + - --static-auth-secret=${ND_TURN_SECRET:-changeme} + - --realm=${TURN_REALM:-netdesk.local} + - --listening-port=3478 + - --tls-listening-port=5349 + - --min-port=49152 + - --max-port=65535 + - --no-cli + - --fingerprint + +volumes: + netdesk-data: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..f725912 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env sh +# Map ND_* environment variables to netdesk_server.py CLI flags, so secrets stay +# out of the image and the compose file (they come from the .env file / the +# environment). Only non-empty vars are passed, so every flag is optional. +set -eu + +set -- --port "${ND_PORT:-8443}" --bind "${ND_BIND:-0.0.0.0}" + +[ -n "${ND_STATUS_KEY:-}" ] && set -- "$@" --status-key "$ND_STATUS_KEY" +[ -n "${ND_REG_KEY:-}" ] && set -- "$@" --reg-key "$ND_REG_KEY" +[ -n "${ND_PORTAL_PASSWORD:-}" ] && set -- "$@" --portal-password "$ND_PORTAL_PASSWORD" +[ -n "${ND_ACCESS_PATH:-}" ] && set -- "$@" --access-path "$ND_ACCESS_PATH" +[ -n "${ND_OWNER_EMAIL:-}" ] && set -- "$@" --owner-email "$ND_OWNER_EMAIL" +[ -n "${ND_PUBLIC_URL:-}" ] && set -- "$@" --public-url "$ND_PUBLIC_URL" +[ -n "${ND_MAIL_FROM:-}" ] && set -- "$@" --mail-from "$ND_MAIL_FROM" +[ -n "${ND_SMTP_HOST:-}" ] && set -- "$@" --smtp-host "$ND_SMTP_HOST" +[ -n "${ND_TURN_HOST:-}" ] && set -- "$@" --turn-host "$ND_TURN_HOST" +[ -n "${ND_TURN_SECRET:-}" ] && set -- "$@" --turn-secret "$ND_TURN_SECRET" +[ "${ND_ALWAYS_USE_RELAY:-0}" = "1" ] && set -- "$@" --always-use-relay +[ -n "${ND_CERT:-}" ] && set -- "$@" --cert "$ND_CERT" +[ -n "${ND_KEY:-}" ] && set -- "$@" --key "$ND_KEY" +[ "${ND_VERBOSE:-0}" = "1" ] && set -- "$@" --verbose + +# ND_TURN_* for Cloudflare TURN are read from the environment by the server +# itself (argparse defaults to os.environ), so they need no mapping here. + +echo "[entrypoint] exec python netdesk_server.py $*" +exec python /app/server/netdesk_server.py "$@" diff --git a/host/icon.ico b/host/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..de36d446fe661a12f1f013d2b72d37ee4ded7c6e GIT binary patch literal 17581 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 literal 0 HcmV?d00001 diff --git a/host/netdesk_gui.html b/host/netdesk_gui.html new file mode 100644 index 0000000..c25f851 --- /dev/null +++ b/host/netdesk_gui.html @@ -0,0 +1,971 @@ + + + + + +NetDesk v1.5.0 — Enterprise Remote Desktop + + + +
+ + +
+
+ + NetDesk + — Enterprise Remote Desktop & Unattended Access +
+
+ Relay: … + |— ms + |TLS 1.3 • AES-256-GCM +
+
+ + + +
+
+ +
+ + + + +
+
+
+ Connect to Partner: + + +
+
+ + settings next to the app +
+
+ + +
+
+
+ +
+
+
+

This Computer's ID

+
+
…
+
+ +
+
+ Stopped + E2EE Ready +
+
+ +
+

Unattended Password

+ PBKDF2 · 200k
+
+ + + + + +
+

Anyone who knows this ID and password can control this PC. Set a strong + password; turn on 2FA for an extra authenticator code at connect.

+
+ Brute-force throttle: 0.4s / attempt + +
+
+
+
+ +
+

Connection

+
+
+
+
+
+
+
+
+ +
+

NetDesk Account

+
+
+
+
+
+ + +
+
+ +
+
+
+ +
+ + +
+ +
+
+
Service
Installed
+
Start with
Windows
+
Privacy
Mode
+
Lock on
disconnect
+
Close to
tray
+
+
+ +
+ + +
+
+ + +
+
+

My Computers

+ + + +
+
+ + + + + + +
+

Sign in to your NetDesk account to see the computers linked to it.

+
+
+ + +
+
+

Two-Factor Authentication (TOTP)

2FA: off
+

Adds a 6-digit authenticator code (Google Authenticator, Authy, 1Password…) + required at every connect — even with the correct password.

+
+ + +
+
+
+

Viewer IP Allowlist

off — everyone with the password
+

Only these IPs / CIDR ranges may connect (e.g. 203.0.113.7, 10.0.0.0/8). + Leave empty to allow any IP that knows the password. Applies to the next connection.

+
+ + +
+

+
+
+

Boot-Time Service

not installed
+

Installs a SYSTEM service so this PC is reachable at the logon screen, + across reboots and UAC prompts — no one needs to be logged in. Requires one UAC approval.

+
+ + +
+
+
+ + +
+
+

Session Audit Log

+ + + + + + + + +
+
Loading…
+
+
+
+
+ + + +
+
+ + + + diff --git a/host/netdesk_gui.py b/host/netdesk_gui.py new file mode 100644 index 0000000..346568e --- /dev/null +++ b/host/netdesk_gui.py @@ -0,0 +1,825 @@ +#!/usr/bin/env python3 +"""NetDesk desktop GUI - renders the approved HTML design in a native +WebView2 window (pywebview), wired to the real engine backend.""" + +import base64 +import json +import os +import secrets +import string +import subprocess +import sys +import threading +import time +import urllib.request +import urllib.error +import webbrowser +from pathlib import Path + +IS_WIN = sys.platform == "win32" +IS_MAC = sys.platform == "darwin" +if IS_WIN: + import ctypes +HERE = Path(__file__).resolve().parent +if getattr(sys, "frozen", False): + HERE = Path(sys.executable).resolve().parent + sys.path.insert(0, HERE) +else: + sys.path.insert(0, str(HERE)) + +import netdesk_host as nd # engine module + +CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0 +NL = chr(10) + + +def _html_path(): + if getattr(sys, "frozen", False): + me = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable)) + cand = os.path.join(me, "netdesk_gui.html") + if os.path.exists(cand): + return cand + return os.path.join(os.path.dirname(sys.executable), "netdesk_gui.html") + return str(HERE / "netdesk_gui.html") + + +def engine_exe(): + if getattr(sys, "frozen", False): + return [sys.executable, "--engine"] + return [sys.executable, str(HERE / "netdesk_host.py"), "--engine"] + + +class Api: + """Exposed to the HTML UI as window.pywebview.api.*""" + + def __init__(self): + self.proc = None + self.cfg = nd.Config(nd.appdata_dir() / "config.json") + self.portable = getattr(sys, "frozen", False) and \ + self.cfg.path.parent.resolve() == HERE + + _svc_cache = {"v": None, "t": 0.0} + _stu_cache = {"v": None, "t": 0.0} + + def _service_installed(self): + # NOTE: `schtasks /Query /TN NetDesk` returns "Access is denied" for a + # NON-elevated process when the task was created elevated - which made + # the toggle grey on every fresh app start. The COM API distinguishes + # not-found (0x80070002) from access-denied (0x80070005 = task EXISTS) + # by HRESULT, which is locale-independent. + if not IS_WIN: + return False + if time.time() - type(self)._svc_cache["t"] < 10: + return bool(type(self)._svc_cache["v"]) + ps = ("$s=New-Object -ComObject Schedule.Service;$s.Connect();" + "try{$null=$s.GetFolder('\\').GetTask('NetDesk');Write-Output 1}" + "catch{if($_.Exception.HResult -eq -2147024891){Write-Output 1}" + "else{Write-Output 0}}") + try: + r = subprocess.run(["powershell", "-NoProfile", "-Command", ps], + capture_output=True, text=True, timeout=20, + creationflags=CREATE_NO_WINDOW) + v = (r.stdout or "").strip().endswith("1") + except Exception: + v = bool(type(self)._svc_cache["v"]) # keep last known on error + type(self)._svc_cache = {"v": v, "t": time.time()} + return v + + def _startup_present(self): + if not IS_WIN: + return False + if time.time() - type(self)._stu_cache["t"] < 10: + return bool(type(self)._stu_cache["v"]) + r = subprocess.run(["reg", "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", "NetDesk"], capture_output=True, + creationflags=CREATE_NO_WINDOW).returncode + type(self)._stu_cache = {"v": r == 0, "t": time.time()} + return r == 0 + + def _engine_alive(self): + sf = self.cfg.path.parent / "gui_status.json" + try: + d = json.loads(sf.read_text("utf-8")) + return time.time() - d.get("ts", 0) < 12 + except Exception: + return False + + def log_err(self, msg): + try: + with open(self.cfg.path.parent / "gui_err.log", "a", encoding="utf-8") as f: + f.write(time.strftime("%H:%M:%S ") + str(msg) + NL) + except Exception: + pass + return {"ok": True} + + # ---------------- account (sign in / sign up, binds this PC to the web app) + def _api_base(self): + url = self.cfg.data.get("server") or nd.DEFAULT_SERVER + url = url.replace("wss://", "https://").replace("ws://", "http://") + url = url.rsplit("/ws", 1)[0] + return url.rstrip("/") + + def _api_post(self, path, payload): + data = json.dumps(payload).encode() + headers = {"Content-Type": "application/json"} + tok = self.cfg.data.get("account_token", "") + if tok: + headers["Authorization"] = "Bearer " + tok + req = urllib.request.Request(self._api_base() + path, data=data, + headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read().decode() or "{}") + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode() or "{}") + except Exception: + return {"error": f"server error {e.code}"} + except Exception as e: + return {"error": f"cannot reach server: {e}"} + + def _account_result(self, r): + if r.get("ok") and r.get("token"): + self.cfg.data["account_token"] = r["token"] + self.cfg.data["account_email"] = r.get("email", "") + self.cfg.save() # engine picks it up at the next (re)register via reload() + return {"ok": True, "email": r.get("email", ""), "msg": r.get("msg", "")} + return {"ok": False, "error": r.get("error", "sign-in failed")} + + def account_login(self, email, password): + email = (email or "").strip().lower() + if "@" not in email or len(password or "") < 1: + return {"ok": False, "error": "Enter your email and password."} + return self._account_result(self._api_post( + "/api/auth/login", {"email": email, "password": password, "app": True})) + + def account_signup(self, email, password): + email = (email or "").strip().lower() + if "@" not in email: + return {"ok": False, "error": "Enter a valid email."} + if len(password or "") < 8: + return {"ok": False, "error": "Password needs 8+ characters."} + return self._account_result(self._api_post( + "/api/auth/signup", {"email": email, "password": password, "app": True})) + + def account_logout(self): + tok = self.cfg.data.get("account_token", "") + if tok: + try: + self._api_post("/api/auth/logout", {}) + except Exception: + pass + self.cfg.data.pop("account_token", None) + self.cfg.data.pop("account_email", None) + self.cfg.save() + return {"ok": True} + + def get_state(self): + try: + return self._get_state_inner() + except Exception as e: + try: + self.log_err("get_state: " + repr(e)) + except Exception: + pass + return {"id": self.cfg.id, "state": "error", "error": repr(e), + "portable": self.portable} + + _last_online = 0.0 + + def _get_state_inner(self): + state, in_sess = "stopped", False + sf = self.cfg.path.parent / "gui_status.json" + try: + d = json.loads(sf.read_text("utf-8")) + if time.time() - d.get("ts", 0) < 12: + state, in_sess = d.get("state"), bool(d.get("in_session")) + except Exception: + pass + # Anti-flicker: a stale duplicate engine writes "error: already + # connected elsewhere" between the real engine's online heartbeats. + # That error literally means the ID IS being served by this machine, + # and any brief error/connecting blip within 12s of a confirmed + # online heartbeat is noise, not a real outage. + now = time.time() + cls = type(self) + alive = self._engine_alive() # some local engine heartbeated < 12s ago + # A stale duplicate engine writes "already connected elsewhere" between + # the real engine's online heartbeats. When an engine is genuinely + # heartbeating locally, that means the ID IS served here -> online. + if isinstance(state, str) and "already connected" in state and alive: + state = "online" + if state in ("online", "session"): + cls._last_online = now + elif (state != "stopped" or alive) and now - cls._last_online < 12: + # brief connecting/error blip within 12s of a confirmed online + # (e.g. slow-network reconnect) is noise, not a real outage + state = "online" + # Truly stopped only when OUR spawned engine exited AND no engine at all + # is heartbeating - never override a live/reconnecting engine's status. + if self.proc is not None and self.proc.poll() is not None \ + and not alive and now - cls._last_online >= 12: + state = "stopped" + elif state is None: + state = "running" if alive else "stopped" + return { + "id": self.cfg.id, + "server": self.cfg.data.get("server") or nd.DEFAULT_SERVER, + "regKey": self.cfg.data.get("reg_key", ""), + "name": self.cfg.data.get("name", ""), + "password": self.cfg.data.get("pw_plain", ""), + "account": self.cfg.data.get("account_email", ""), + "state": state, + "inSession": in_sess, + "totp": bool(self.cfg.data.get("totp")), + "allowIps": self.cfg.data.get("allow_ips", ""), + "portable": self.portable, + "configPath": str(self.cfg.path), + "serviceInstalled": self._service_installed(), + "startup": self._startup_present(), + "privacy": bool(self.cfg.data.get("privacy_on_session") + and self.cfg.data.get("allow_display_power")), + "lockOnEnd": bool(self.cfg.data.get("lock_on_end")), + "closeToTray": bool(self.cfg.data.get("close_to_tray", True)), + "inTray": self._tray_running(), + "isMac": IS_MAC, + } + + def copy_id(self): + if IS_WIN: + subprocess.run(["powershell", "-Command", + f"Set-Clipboard -Value '{self.cfg.id}'"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + return {"ok": True} + + def start_engine(self, server, regkey, name): + if self._engine_alive(): + return {"ok": False, "error": "Engine is already running."} + server = nd.normalize_server(server) or (self.cfg.data.get("server") or nd.DEFAULT_SERVER) + self.cfg.data["server"] = server + self.cfg.data["server_locked_by_user"] = True + if regkey.strip(): + self.cfg.data["reg_key"] = regkey.strip() + if name.strip(): + self.cfg.data["name"] = name.strip() + self.cfg.save() + cmd = engine_exe() + ["--server", server] + regkey = regkey.strip() or self.cfg.data.get("reg_key", "") + if regkey: + cmd += ["--reg-key", regkey] + if name.strip(): + cmd += ["--name", name.strip()] + flags = CREATE_NO_WINDOW if IS_WIN else 0 + self.proc = subprocess.Popen(cmd, creationflags=flags, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return {"ok": True} + + def ensure_engine(self): + """Auto-bring the host ONLINE on app launch (TeamViewer-style) unless + the user chose to stay offline. Safe: a fresh config always carries a + random password, so 'online' never means 'open to anyone'. No-op when + an engine (user or SYSTEM broker) is already serving this ID.""" + try: + if self.cfg.data.get("start_offline"): + return {"ok": False, "offline": True} + if self._engine_alive(): + return {"ok": True, "already": True} + server = self.cfg.data.get("server") or nd.DEFAULT_SERVER + cmd = engine_exe() + ["--server", server] + rk = self.cfg.data.get("reg_key", "") + if rk: + cmd += ["--reg-key", rk] + nm = self.cfg.data.get("name", "") + if nm: + cmd += ["--name", nm] + flags = CREATE_NO_WINDOW if IS_WIN else 0 + self.proc = subprocess.Popen(cmd, creationflags=flags, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return {"ok": True, "started": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def set_start_offline(self, on): + """User preference: skip auto-going-online when the app launches.""" + self.cfg.data["start_offline"] = bool(on) + self.cfg.save() + return {"ok": True} + + def stop_engine(self): + if self.proc is not None and self.proc.poll() is None: + self.proc.terminate() + self.proc = None + return {"ok": True} + if IS_WIN and getattr(sys, "frozen", False): + subprocess.run(["powershell", "-Command", + "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | " + "Where-Object { $_.CommandLine -like '*--engine*' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + return {"ok": True} + return {"ok": False, "error": "not running"} + + def set_password(self, pw): + pw = (pw or "").strip() + if len(pw) < 8: + return {"ok": False, "error": "Use at least 8 characters."} + self.cfg.set_password(pw) + return {"ok": True, "msg": "Password updated - it applies to the next connection."} + + def generate_password(self): + """One-click random password, TeamViewer/RustDesk style. Saved and + shown; the engine picks it up at the next incoming connection.""" + pw = nd.gen_password(10) + self.cfg.set_password(pw) + return {"ok": True, "password": pw} + + def toggle_2fa(self): + if self.cfg.data.get("totp"): + self.cfg.data.pop("totp", None) + self.cfg.save() + return {"ok": True, "on": False} + secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32)) + self.cfg.set_totp(secret) + f = self.cfg.path.parent / "totp_secret.txt" + f.write_text("NetDesk 2FA secret (base32)\n\n" + secret + "\n", "utf-8") + return {"ok": True, "on": True, "secret": secret, "path": str(f)} + + def totp_qr(self): + """QR code (base64 PNG) for the current TOTP secret - scan it with + Google Authenticator / Authy / 1Password.""" + secret = self.cfg.data.get("totp", "") + if not secret: + return {"ok": False, "error": "2FA is not enabled"} + label = f"NetDesk:{self.cfg.id}" + uri = (f"otpauth://totp/{urllib.parse.quote(label)}?secret={secret}" + f"&issuer=NetDesk&digits=6&period=30") + try: + import io + import qrcode + img = qrcode.make(uri, box_size=5, border=2) + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return {"ok": True, "png": "data:image/png;base64," + b64, + "secret": secret} + except Exception as e: + return {"ok": False, "error": str(e)[:120], "secret": secret} + + def set_allow_ips(self, text): + """Save the viewer IP/CIDR allowlist (empty = allow everyone).""" + import ipaddress + parts = str(text or "").replace(",", " ").split() + bad = [] + for p in parts: + try: + ipaddress.ip_network(p, strict=False) + except ValueError: + bad.append(p) + if bad: + return {"ok": False, "error": "Not a valid IP/CIDR: " + ", ".join(bad[:3])} + self.cfg.data["allow_ips"] = " ".join(parts) + self.cfg.save() + return {"ok": True, "count": len(parts)} + + def wake_device(self, dev_id): + """Wake-on-LAN an offline fleet computer (relayed via the server).""" + tok = self.cfg.data.get("account_token", "") + if not tok: + return {"ok": False, "error": "not signed in"} + r = self._api_post("/api/wake", {"id": str(dev_id)}) + if r.get("ok"): + return {"ok": True, "already_online": bool(r.get("already_online")), + "relayed": r.get("relayed", 0)} + return {"ok": False, "error": r.get("error", "wake failed")} + + def export_logs(self): + """Copy the session audit log to the Desktop for support/compliance.""" + try: + src = self.cfg.path.parent / "host.log" + if not src.is_file(): + return {"ok": False, "error": "no log file yet"} + dest_dir = Path.home() / "Desktop" + if not dest_dir.is_dir(): + dest_dir = Path.home() + dest = dest_dir / time.strftime("netdesk-log-%Y%m%d-%H%M%S.txt") + dest.write_bytes(src.read_bytes()) + return {"ok": True, "path": str(dest)} + except Exception as e: + return {"ok": False, "error": str(e)[:120]} + + # ---------------- enterprise UI data feeds ---------------- + def ping_relay(self): + """Round-trip time to the configured relay's /health endpoint. + Retries once so a single dropped packet on a slow line never + flashes 'offline'.""" + err = "" + for _ in range(2): + try: + t0 = time.time() + with urllib.request.urlopen(self._api_base() + "/health", timeout=10) as r: + r.read() + return {"ok": True, "ms": int((time.time() - t0) * 1000)} + except Exception as e: + err = str(e)[:120] + return {"ok": False, "error": err} + + def list_devices(self): + """This account's computers from the relay (for My Computers).""" + tok = self.cfg.data.get("account_token", "") + if not tok: + return {"ok": False, "error": "not signed in", "devices": []} + req = urllib.request.Request(self._api_base() + "/api/devices", + headers={"Authorization": "Bearer " + tok}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + d = json.loads(r.read().decode()) + return {"ok": True, "devices": d.get("devices", [])} + except Exception as e: + return {"ok": False, "error": str(e)[:120], "devices": []} + + def get_logs(self, n=150): + """Tail of the local agent log for the Session Logs view.""" + try: + p = self.cfg.path.parent / "host.log" + lines = p.read_text("utf-8", errors="replace").splitlines()[-int(n):] + return {"ok": True, "lines": lines} + except Exception as e: + return {"ok": False, "lines": [], "error": str(e)[:120]} + + def open_partner(self, pid): + """Outgoing connection: open the remote-control viewer in its own app + window (WebView2) - app-to-app like RustDesk/TeamViewer, one window per + partner. Falls back to the system browser only if the window can't open.""" + digits = "".join(ch for ch in str(pid or "") if ch.isdigit()) + if len(digits) != 9: + return {"ok": False, "error": "Enter the partner's 9-digit ID."} + url = self._api_base() + "/app?id=" + digits + "&embed=1" + try: + import webview + import ctypes + sw, sh = 1536, 864 + try: + u = ctypes.windll.user32 + try: + scale = u.GetDpiForSystem() / 96.0 + except Exception: + scale = 1.0 + sw = u.GetSystemMetrics(0) / (scale or 1.0) + sh = u.GetSystemMetrics(1) / (scale or 1.0) + except Exception: + pass + w, h = int(sw * 0.8), int(sh * 0.82) + title = "NetDesk – " + " ".join(digits[i:i + 3] for i in range(0, 9, 3)) + # the viewer window has the normal OS frame (resizable, maximize) + webview.create_window(title, url, width=w, height=h, min_size=(720, 460), + x=max(0, int((sw - w) / 2)), y=max(0, int((sh - h) / 2)), + background_color="#05070a", text_select=False) + return {"ok": True, "app": True} + except Exception as e: + self.log_err("viewer window: %s" % e) + webbrowser.open(url) + return {"ok": True} + + _maximized = False + + def maximize_window(self): + """Toggle maximize/restore (never fullscreen - that traps a frameless + window with no way out).""" + try: + import webview + for w in webview.windows: + try: + if type(self)._maximized: + w.restore() + type(self)._maximized = False + else: + w.maximize() + type(self)._maximized = True + except Exception: + pass + except Exception: + pass + return {"ok": True, "maximized": type(self)._maximized} + + def fit_window(self, height): + """Resize the window height to fit its content (width unchanged).""" + try: + import webview + h = max(560, min(int(height), 1400)) + for w in webview.windows: + try: + w.resize(getattr(w, "width", 1024) or 1024, h) + except Exception: + pass + except Exception: + pass + return {"ok": True} + + def set_switches(self, startup=None, privacy=None, lock=None): + if privacy is not None: + self.cfg.data["privacy_on_session"] = bool(privacy) + self.cfg.data["allow_display_power"] = bool(privacy) + if lock is not None: + self.cfg.data["lock_on_end"] = bool(lock) + self.cfg.save() + return {"ok": True} + + def install_service(self): + if not IS_WIN: + return {"ok": False, "error": "Windows only"} + # hand over: stop the GUI-spawned engine; the service owns the connection + self.stop_engine() + server = self.cfg.data.get("server") or nd.DEFAULT_SERVER + # clear any stale result from a previous attempt so we read a fresh one + result_path = nd.programdata_dir() / "install_result.json" + try: + result_path.unlink() + except Exception: + pass + exe = sys.executable + params = f'--install --server "{server}"' + reg = self.cfg.data.get("reg_key", "") + if reg: + params += f' --reg-key "{reg}"' + if not getattr(sys, "frozen", False): + params = f'"{HERE / "netdesk_host.py"}" ' + params + try: + r = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, None, 1) + if r <= 32: + return {"ok": False, "error": "UAC was cancelled - accept the prompt to install."} + except Exception as e: + return {"ok": False, "error": str(e)} + # Wait up to 60 s: the elevated one-file exe must first self-extract + # (a few seconds) before it can create the task. Trust install_result.json + # (written by the elevated installer) as the definitive answer, and fall + # back to querying the scheduled task directly. + for _ in range(120): + try: + res = json.loads(result_path.read_text("utf-8")) + if res.get("ok"): + # force the status cache so the toggle turns green immediately + type(self)._svc_cache = {"v": True, "t": time.time()} + return {"ok": True, + "msg": "Service installed - reachable at boot, the logon screen " + "and UAC prompts. The engine handed over to the service."} + return {"ok": False, "error": res.get("error", "install failed")} + except Exception: + pass + if self._service_installed(): + type(self)._svc_cache = {"v": True, "t": time.time()} + return {"ok": True, + "msg": "Service installed - reachable at boot, the logon screen " + "and UAC prompts."} + time.sleep(0.5) + return {"ok": False, + "error": "Install is taking longer than expected. If you accepted the UAC " + "prompt, the toggle should turn on shortly - reopen the app to check."} + + def remove_service_full(self): + r = self.remove_service() + # also stop broker/agent processes from the removed service + if IS_WIN and getattr(sys, "frozen", False): + subprocess.run(["powershell", "-Command", + "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | " + "Where-Object { $_.CommandLine -like '*--broker*' -or " + "$_.CommandLine -like '*--engine*' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + return r + + def remove_service(self): + if not IS_WIN: + return {"ok": False} + subprocess.run(["schtasks", "/Delete", "/TN", "NetDesk", "/F"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + return {"ok": True, "msg": "Service removed (if it existed)."} + + def add_startup(self): + if getattr(sys, "frozen", False): + exe = Path(sys.executable).resolve() + else: + exe = HERE / "NetDeskHost.exe" + val = f'"{exe}" --engine --server "{self.cfg.data.get("server") or "wss://netdesk.space/ws"}"' + subprocess.run(["reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", "NetDesk", "/t", "REG_SZ", "/d", val, "/f"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + return {"ok": True} + + def remove_startup(self): + if not IS_WIN: + return {"ok": False} + subprocess.run(["reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", "NetDesk", "/f"], capture_output=True, + creationflags=CREATE_NO_WINDOW) + return {"ok": True, "msg": "Startup entry removed."} + + def open_viewer(self): + url = self.cfg.data.get("server") or "wss://netdesk.space/ws" + url = url.replace("wss://", "https://").replace("ws://", "http://") + url = url.rsplit("/ws", 1)[0] or "http://127.0.0.1:8443" + webbrowser.open(url) + return {"ok": True} + + def close_request(self): + """✕ button: hide to tray by default, or really quit if disabled.""" + if self.cfg.data.get("close_to_tray", True): + self.hide_window() + return {"ok": True, "hidden": True} + return {"ok": True, "hidden": False, "quit": True} + + def hide_window(self): + try: + import webview + for w in webview.windows: + w.hide() + except Exception: + pass + + def show_window(self): + try: + import webview + for w in webview.windows: + w.show() + except Exception: + pass + + def minimize(self): + try: + import webview + for w in webview.windows: + w.minimize() + except Exception: + pass + return {"ok": True} + + def set_close_to_tray(self, on): + self.cfg.data["close_to_tray"] = bool(on) + self.cfg.save() + return {"ok": True} + + def quit_all(self): + try: + self.stop_engine() + except Exception: + pass + try: + import webview + for w in webview.windows: + w.destroy() + except Exception: + pass + os._exit(0) + + def _tray_running(self): + return getattr(self, "_tray", None) is not None and self._tray.visible + + def open_logs(self): + p = str(self.cfg.path.parent) + if IS_WIN: + os.startfile(p) # noqa + elif IS_MAC: + subprocess.Popen(["open", p]) + else: + subprocess.Popen(["xdg-open", p]) + return {"ok": True} + + +def _tray_image(): + from PIL import Image as _I + return _I.open(_io.BytesIO(base64.b64decode(ICON_B64))) if ICON_B64 else None + + +def _start_tray(api): + """System tray icon with the NetDesk logo and control menu.""" + try: + import pystray + img = _tray_image() + if img is None: + return + import webview + + def show_w(api_ref, icon_ref, item): + api.show_window() + + def quit_all(icon_ref, item): + api.quit_all() + + def noop(icon_ref, item): + pass + + menu = pystray.Menu( + pystray.MenuItem("Show NetDesk", lambda ic, it: (api.show_window()), + default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Start engine", + lambda ic, it: api.start_engine( + api.cfg.data.get("server", ""), + "", api.cfg.data.get("name", ""))), + pystray.MenuItem("Stop engine", lambda ic, it: api.stop_engine()), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Open Web Viewer", lambda ic, it: api.open_viewer()), + pystray.MenuItem("Exit NetDesk", lambda ic, it: api.quit_all()), + ) + icon = pystray.Icon("NetDesk", img, "NetDesk", menu) + api._tray = icon + icon.run_detached() + except Exception as e: + print("tray unavailable:", e) + + +def run(): + import webview + api = Api() + # standard client size like RustDesk/TeamViewer - never starts maximized. + # Sized to ~62% x 75% of the desktop so it always feels like a windowed + # app; the page auto-zooms so the full layout fits (see fitZoom in HTML). + sw, sh = 1536, 864 + try: + import ctypes + u = ctypes.windll.user32 + try: + scale = u.GetDpiForSystem() / 96.0 + except Exception: + scale = 1.0 + sw = u.GetSystemMetrics(0) / (scale or 1.0) + sh = u.GetSystemMetrics(1) / (scale or 1.0) + except Exception: + pass + w = int(min(1060, max(880, sw * 0.62))) + h = int(min(760, max(600, sh * 0.75))) + win = webview.create_window("NetDesk", _html_path(), js_api=api, + width=w, height=h, min_size=(860, 590), + x=max(20, int((sw - w) / 2)), y=max(20, int((sh - h) / 2.4)), + maximized=False, + frameless=True, easy_drag=False, + background_color="#0b0f15") + + def _on_closing(): + # native ✕ hides to tray by default (RustDesk/TeamViewer behavior), + # or really quits when "Close to tray" is off + if api.cfg.data.get("close_to_tray", True): + api.hide_window() + return False # cancel the close - just hide + api.quit_all() + return True + try: + win.events.closing += _on_closing + except Exception: + pass + + threading.Thread(target=_start_tray, args=(api,), daemon=True).start() + + # Auto bring the host ONLINE on launch (TeamViewer-style), and on the very + # first run default "Start with Windows" ON so it reconnects at every login. + # Both are safe (a fresh config always carries a random password) and fully + # reversible from the toggles. Runs off-thread so the UI never blocks on it. + def _autostart(): + try: + api.ensure_engine() + except Exception: + pass + try: + if IS_WIN and not api.cfg.data.get("autostart_initialized"): + api.add_startup() + api.cfg.data["autostart_initialized"] = True + api.cfg.save() + except Exception: + pass + threading.Thread(target=_autostart, daemon=True).start() + + webview.start() + + +def selftest(): + import webview + api = Api() + result = {"ok": False} + + def _check(): + time.sleep(2.2) + try: + st = api.get_state() + result["ok"] = isinstance(st, dict) and "id" in st + except Exception as e: + result["err"] = str(e) + for w in webview.windows: + try: + w.destroy() + except Exception: + pass + + threading.Thread(target=_check, daemon=True).start() + webview.create_window("NetDesk selftest", _html_path(), js_api=api, + width=580, height=760, background_color="#0b0e13") + webview.start() + if result["ok"]: + print("GUI_SELFTEST_OK") + else: + print("GUI_SELFTEST_FAIL", result) + sys.exit(1) + + +if __name__ == "__main__": + run() diff --git a/host/netdesk_host.py b/host/netdesk_host.py new file mode 100644 index 0000000..0c7ec54 --- /dev/null +++ b/host/netdesk_host.py @@ -0,0 +1,3318 @@ +#!/usr/bin/env python3 +""" +NetDesk Host - unattended remote desktop agent for Windows (full access). + +Modes: + python netdesk_host.py --server wss://VPS/ws [--listen 8901] # simple in-process mode + python netdesk_host.py --install # install+start boot-time service broker (run as admin once) + python netdesk_host.py --uninstall + python netdesk_host.py --broker # service broker (normally started by the scheduled task) + python netdesk_host.py --session-agent --broker ws://127.0.0.1:PORT/agent --token TOK + # per-desktop worker, spawned by the broker + +Service architecture (like TeamViewer/RustDesk): + broker (SYSTEM, session 0, starts at boot) + - owns the outbound connection to the NetDesk server + - spawns a desktop agent into the active console session as SYSTEM + -> agent can capture and inject on the user desktop, the logon screen + and UAC prompts (full access, before/without any user being logged on) + - relays traffic server <-> agent (agent holds all E2E keys, broker is a + dumb pipe) + +Features: H.264 capture/encode, SendInput keyboard+mouse (desktop-following), +clipboard sync, file manager with folder upload, chat, remote power, process +manager, privacy mode (monitor off), lock-on-disconnect, optional system audio +(PyAudioWPatch), optional TOTP 2FA, wake display on connect. +""" + +import argparse +import asyncio +import base64 +import hashlib +import hmac +import json +import logging +import mss +import numpy as np +import os +import queue +import secrets +import shutil +import string +import subprocess +import sys +import threading +import time +import uuid +from fractions import Fraction +from pathlib import Path + +import av +import aiohttp +from aiohttp import web, WSMsgType +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +# WebRTC (aiortc): optional P2P/UDP fast path. When it's missing or the NAT +# won't allow it, everything keeps working over the WSS relay exactly as before. +try: + from aiortc import (RTCPeerConnection, RTCSessionDescription, + RTCConfiguration, RTCIceServer) + RTC_AVAILABLE = True +except Exception: + RTC_AVAILABLE = False +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes + +# DXGI Desktop Duplication (dxcam): optional, Windows-only fast-capture path. +# ~10x faster than mss GDI readback (4ms vs ~45ms/frame at 1080p) with built-in +# damage detection (grab() returns None when the screen is unchanged). When it's +# missing or fails (secure desktop, RDP, odd GPU), capture falls back to mss. +try: + import dxcam as _dxcam + _DX_AVAILABLE = (sys.platform == "win32") +except Exception: + _dxcam = None + _DX_AVAILABLE = False + +log = logging.getLogger("netdesk.host") +VERSION = "1.5.0-netdesk" +BUILTIN_SERVER = "wss://netdesk.space/ws" +IS_WIN = sys.platform == "win32" +CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0 + + +def normalize_server(addr: str) -> str: + """Accept what users type - 'netdesk.space', 'https://x', 'x:8443', + 'wss://x/ws' - and return the websocket URL of the server.""" + a = (addr or "").strip().rstrip("/") + if not a: + return "" + if a.startswith("https://"): + a = "wss://" + a[8:] + elif a.startswith("http://"): + a = "ws://" + a[7:] + elif not a.startswith(("ws://", "wss://")): + host = a.split("/")[0] + is_local = (host.split(":")[0] in ("localhost", "127.0.0.1") + or host.startswith("192.168.") or host.startswith("10.")) + a = ("ws://" if is_local else "wss://") + a + _, rest = a.split("://", 1) + if "/" not in rest: + a += "/ws" + return a + + +def exe_name_config() -> dict: + """Configuration baked into the file name (RustDesk-style), e.g. + 'NetDeskHost-host=desk.example.com,key=SECRET.exe' -> preset server + key. + Lets you hand out a pre-configured exe with no manual setup.""" + if not getattr(sys, "frozen", False): + return {} + name = os.path.splitext(os.path.basename(sys.executable))[0] + out = {} + for part in name.replace("--", ",").split(","): + for key, field in (("host=", "server"), ("key=", "reg_key")): + idx = part.find(key) + if idx != -1: + out[field] = part[idx + len(key):].strip() + if out.get("server"): + out["server"] = normalize_server(out["server"]) + return out + + +DEFAULT_SERVER = exe_name_config().get("server") or BUILTIN_SERVER + +QUALITY_PRESETS = { + "low": dict(fps=15, bitrate=2000, scale=0.75), + "balanced": dict(fps=30, bitrate=6000, scale=1.0), + "high": dict(fps=30, bitrate=12000, scale=1.0), + "extreme": dict(fps=60, bitrate=20000, scale=1.0), +} + + +# ================================================================ config + +def default_config_dir(): + """Config location. Frozen (exe) builds are portable: settings and logs + live next to the executable, so the folder can be carried on a USB stick + and moved freely. If that folder is not writable (network share, read-only + mount, Program Files), fall back to the user profile so the app still + works. Source (non-frozen) runs always use the user profile folder.""" + if getattr(sys, "frozen", False): + exe_dir = Path(sys.executable).resolve().parent + try: + probe = exe_dir / ".nd_write_test" + probe.write_bytes(b"") + probe.unlink() + return exe_dir + except Exception: + pass + if IS_WIN: + d = Path(os.environ.get("APPDATA", Path.home())) / "NetDesk" + else: + d = Path.home() / ".netdesk" + d.mkdir(parents=True, exist_ok=True) + return d + + +def programdata_dir(): + d = Path(os.environ.get("ProgramData", r"C:\ProgramData")) / "NetDesk" + return d + + +_CONFIG_DIR = None + + +def appdata_dir(): + global _CONFIG_DIR + d = _CONFIG_DIR or default_config_dir() + d.mkdir(parents=True, exist_ok=True) + return d + + +def make_verifier(password: str, salt: bytes = None, iters: int = 200000): + salt = salt or secrets.token_bytes(16) + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iters) + verifier = kdf.derive(password.encode("utf-8")) + return salt, iters, verifier + + +# ---------------------------------------------------------------- TOTP (RFC 6238, stdlib only) + +def totp_now(secret: str, t: float = None, step: int = 30, digits: int = 6) -> str: + if t is None: + t = time.time() + key = secret.upper().rstrip("=") + key += "=" * ((8 - len(key) % 8) % 8) + key = base64.b32decode(key) + counter = int(t // step).to_bytes(8, "big") + digest = hmac.new(key, counter, hashlib.sha1).digest() + off = digest[-1] & 0x0F + code = ((digest[off] & 0x7F) << 24 | digest[off + 1] << 16 | + digest[off + 2] << 8 | digest[off + 3]) % (10 ** digits) + return str(code).zfill(digits) + + +def totp_verify(secret: str, code: str, window: int = 1) -> bool: + code = (code or "").strip() + if not code.isdigit(): + return False + now = time.time() + return any(hmac.compare_digest(totp_now(secret, now + i * 30), code) + for i in range(-window, window + 1)) + + +def gen_password(n: int = 8) -> str: + alphabet = "abcdefghijkmnpqrstuvwxyz23456789" # no look-alikes (l/1, o/0) + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +class Config: + def __init__(self, path: Path): + self.path = path + self.data = {} + if path.exists(): + try: + self.data = json.loads(path.read_text("utf-8")) + except Exception: + log.warning("config unreadable, regenerating") + if "id" not in self.data: + self.data["id"] = str(secrets.randbelow(900000000) + 100000000) + if "verifier" not in self.data: + pw = gen_password() + salt, iters, ver = make_verifier(pw) + self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) + self._new_password = pw + else: + self._new_password = None + baked = exe_name_config() + if baked.get("server") and not self.data.get("server_locked_by_user"): + self.data["server"] = baked["server"] + if baked.get("reg_key") and not self.data.get("reg_key"): + self.data["reg_key"] = baked["reg_key"] + self.data.setdefault("server", DEFAULT_SERVER) + self.data.setdefault("name", os.environ.get("COMPUTERNAME", "host")) + self.data.setdefault("perms", {"input": True, "clipboard": True, "files": True, "audio": True}) + self.data.setdefault("lock_on_end", False) + self.data.setdefault("privacy_on_session", False) + self.data.setdefault("allow_display_power", False) + # optional viewer IP allowlist: comma/space separated IPs or CIDRs + self.data.setdefault("allow_ips", "") + self.save() + + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.data, indent=2), "utf-8") + os.replace(tmp, self.path) + + def reload(self): + """Pick up changes made by the desktop app (password, account, server) + without restarting the engine or service.""" + try: + fresh = json.loads(self.path.read_text("utf-8")) + if isinstance(fresh, dict) and fresh.get("id") and fresh.get("verifier"): + self.data = fresh + except Exception: + pass + + @property + def id(self): return self.data["id"] + + def set_password(self, pw: str): + salt, iters, ver = make_verifier(pw) + self.data.update(salt=salt.hex(), iters=iters, verifier=ver.hex(), pw_plain=pw) + self.save() + + def set_totp(self, secret: str): + self.data["totp"] = secret + self.save() + + def check_proof(self, nonce: str, proof: str) -> bool: + want = hashlib.sha256((nonce + self.data["verifier"]).encode()).hexdigest() + return secrets.compare_digest(want, proof.lower()) + + def check_proof_bound(self, nonce: str, host_pub_b64: str, proof: str) -> bool: + """Channel-bound proof (MITM hardening): hex(SHA256(nonce+verifier+host_pub)). + Binds the host's ephemeral ECDH pubkey so a relay that swapped it is + detected (it can't produce a matching proof without the verifier).""" + want = hashlib.sha256( + (nonce + self.data["verifier"] + host_pub_b64).encode()).hexdigest() + return secrets.compare_digest(want, proof.lower()) + + +# ================================================================ Windows layer + +if IS_WIN: + import ctypes + from ctypes import wintypes + from ctypes.wintypes import DWORD, WORD, LONG + + ULONG_PTR = ctypes.c_size_t + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + advapi32 = ctypes.windll.advapi32 + + def _set_dpi_awareness(): + """Make the process PER-MONITOR-DPI-AWARE (v2) BEFORE mss/capture/SendInput + run. Without this, screen-capture geometry, monitor origins and injected + mouse coordinates disagree on scaled or mixed-DPI multi-monitor setups, + so the remote cursor lands in the wrong place. RustDesk does the same.""" + try: + user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2 + return + except Exception: + pass + try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE + return + except Exception: + pass + try: + user32.SetProcessDPIAware() # system-DPI-aware (last resort) + except Exception: + pass + + _set_dpi_awareness() + + class MOUSEINPUT(ctypes.Structure): + _fields_ = [("dx", LONG), ("dy", LONG), ("mouseData", DWORD), + ("dwFlags", DWORD), ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] + + class KEYBDINPUT(ctypes.Structure): + _fields_ = [("wVk", WORD), ("wScan", WORD), ("dwFlags", DWORD), + ("time", DWORD), ("dwExtraInfo", ULONG_PTR)] + + class _U(ctypes.Union): + # NOTE: do NOT add a c_byte padding member here. An oversized union makes + # sizeof(INPUT) 48 instead of the 40 Windows x64 requires, and SendInput + # then rejects EVERY event (returns 0) - no mouse, no keyboard at all. + _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] + + class INPUT(ctypes.Structure): + _fields_ = [("type", DWORD), ("u", _U)] + + class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), + ("dwProcessId", DWORD), ("dwThreadId", DWORD)] + + class STARTUPINFOW(ctypes.Structure): + _fields_ = [("cb", DWORD), ("lpReserved", wintypes.LPWSTR), ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), ("dwX", DWORD), ("dwY", DWORD), + ("dwXSize", DWORD), ("dwYSize", DWORD), ("dwXCountChars", DWORD), + ("dwYCountChars", DWORD), ("dwFillAttribute", DWORD), ("dwFlags", DWORD), + ("wShowWindow", WORD), ("cbReserved2", WORD), ("lpReserved2", wintypes.LPBYTE), + ("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE)] + + INPUT_MOUSE, INPUT_KEYBOARD = 0, 1 + KEYEVENTF_KEYUP, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_SCANCODE = 0x0002, 0x0001, 0x0008 + MOUSEEVENTF_MOVE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_VIRTUALDESKTOP = 0x0001, 0x8000, 0x4000 + MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004 + MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP = 0x0008, 0x0010 + MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040 + MOUSEEVENTF_WHEEL, MOUSEEVENTF_HWHEEL = 0x0800, 0x1000 + CF_UNICODETEXT, GMEM_MOVEABLE = 13, 0x0002 + + # 64-bit safety: handle-returning APIs must be declared, else ctypes + # truncates pointers to 32 bits and random access violations follow. + user32.GetClipboardData.restype = ctypes.c_void_p + user32.GetClipboardData.argtypes = [wintypes.UINT] + user32.SetClipboardData.restype = ctypes.c_void_p + user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HGLOBAL] + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] + user32.MapVirtualKeyW.restype = wintypes.UINT + user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int] + + # --- desktop following (logon screen / UAC secure desktop) --- + DESKTOP_ACCESS = 0x1 | 0x2 | 0x8 | 0x80 | 0x100 # read|createwin|hook|write|switch + user32.OpenInputDesktop.restype = wintypes.HANDLE + user32.OpenInputDesktop.argtypes = [DWORD, wintypes.BOOL, DWORD] + user32.CloseDesktop.argtypes = [wintypes.HANDLE] + user32.SetThreadDesktop.restype = wintypes.BOOL + user32.SetThreadDesktop.argtypes = [wintypes.HANDLE] + user32.GetUserObjectInformationW.argtypes = [wintypes.HANDLE, wintypes.INT, + ctypes.c_void_p, DWORD, ctypes.POINTER(DWORD)] + user32.GetThreadDesktop.restype = wintypes.HANDLE + user32.GetThreadDesktop.argtypes = [DWORD] + kernel32.GetCurrentThreadId.restype = DWORD + user32.LockWorkStation.restype = wintypes.BOOL + + _desktop_tls = threading.local() + + def _desktop_name(hdesk): + if not hdesk: + return None + buf = ctypes.create_unicode_buffer(64) + need = DWORD() + if user32.GetUserObjectInformationW(hdesk, 2, buf, 64, ctypes.byref(need)): # UOI_NAME + return buf.value + return None + + def attach_desktop(): + """Follow the active input desktop so injection reaches the logon screen + and UAC secure desktop (service/SYSTEM mode). CRITICAL: only call + SetThreadDesktop when the input desktop is DIFFERENT from the desktop this + thread is already on. Switching to a fresh handle of the *same* desktop + silently breaks SendInput (mouse/keyboard stop working entirely) - which + is exactly what happens in normal user mode, where the thread already owns + the visible 'Default' desktop.""" + if getattr(_desktop_tls, "name", None) is None: + # where this thread starts (usually 'Default' in a user session) + _desktop_tls.name = _desktop_name( + user32.GetThreadDesktop(kernel32.GetCurrentThreadId())) + h = user32.OpenInputDesktop(0, True, DESKTOP_ACCESS) + if not h: + return _desktop_tls.name + name = _desktop_name(h) + if not name or name == _desktop_tls.name: + user32.CloseDesktop(h) # already on the input desktop: do NOT switch + return _desktop_tls.name + # input desktop changed (user switch / logon screen / UAC): follow it + if user32.SetThreadDesktop(h): + old = getattr(_desktop_tls, "hdesk", None) + if old: + user32.CloseDesktop(old) + _desktop_tls.hdesk = h + _desktop_tls.name = name + else: + user32.CloseDesktop(h) + return _desktop_tls.name + + def desktop_name(): + return getattr(_desktop_tls, "name", None) + + # --- input --- + VK = { + **{f"Key{c}": 0x41 + i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, + **{f"Digit{c}": 0x30 + i for i, c in enumerate("0123456789")}, + **{f"Numpad{c}": 0x60 + i for i, c in enumerate("0123456789")}, + **{f"F{n}": 0x70 + n - 1 for n in range(1, 25)}, + "Backquote": 0xC0, "Minus": 0xBD, "Equal": 0xBB, "BracketLeft": 0xDB, + "BracketRight": 0xDD, "Backslash": 0xDC, "Semicolon": 0xBA, "Quote": 0xDE, + "Comma": 0xBC, "Period": 0xBE, "Slash": 0xBF, "IntlBackslash": 0xE2, + "CapsLock": 0x14, "Space": 0x20, "Enter": 0x0D, "NumpadEnter": 0x0D, + "Backspace": 0x08, "Tab": 0x09, "Escape": 0x1B, + "Delete": 0x2E, "Insert": 0x2D, "Home": 0x24, "End": 0x23, + "PageUp": 0x21, "PageDown": 0x22, + "ArrowLeft": 0x25, "ArrowUp": 0x26, "ArrowRight": 0x27, "ArrowDown": 0x28, + "ControlLeft": 0xA2, "ControlRight": 0xA3, "AltLeft": 0xA4, "AltRight": 0xA5, + "ShiftLeft": 0xA0, "ShiftRight": 0xA1, + "MetaLeft": 0x5B, "MetaRight": 0x5C, "ContextMenu": 0x5D, + "PrintScreen": 0x2C, "ScrollLock": 0x91, "Pause": 0x13, "NumLock": 0x90, + "NumpadMultiply": 0x6A, "NumpadAdd": 0x6B, "NumpadSubtract": 0x6D, + "NumpadDecimal": 0x6E, "NumpadDivide": 0x6F, + } + EXTENDED_CODES = { + "ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "PageUp", "PageDown", + "Home", "End", "Insert", "Delete", "NumLock", "PrintScreen", "Pause", + "ControlRight", "AltRight", "MetaLeft", "MetaRight", "ContextMenu", + "NumpadEnter", "NumpadDivide", "ScrollLock", + } + + user32.SendInput.restype = wintypes.UINT + + def send_inputs(*inputs): + arr = (INPUT * len(inputs))(*inputs) + n = user32.SendInput(len(inputs), arr, ctypes.sizeof(INPUT)) + if n != len(inputs): + # 0 here almost always means a bad cbSize/struct layout (see the _U + # union note) or input blocked by UIPI/secure desktop - make it loud + log.warning("SendInput injected %d/%d (sizeof(INPUT)=%d, err=%d)", + n, len(inputs), ctypes.sizeof(INPUT), ctypes.get_last_error()) + + def _mi(flags, data=0, dx=0, dy=0): + return INPUT(INPUT_MOUSE, _U(mi=MOUSEINPUT(dx, dy, data & 0xFFFFFFFF, flags, 0, 0))) + + # SM_*VIRTUALSCREEN indices - the whole multi-monitor bounding box, read + # cheaply (no mss allocation per mouse move) + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN = 76, 77 + SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 78, 79 + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP = 0x0080, 0x0100 + XBUTTON1, XBUTTON2 = 0x0001, 0x0002 + + def virtual_desktop_box(): + return {"left": user32.GetSystemMetrics(SM_XVIRTUALSCREEN), + "top": user32.GetSystemMetrics(SM_YVIRTUALSCREEN), + "w": user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), + "h": user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)} + + def inject_mouse_move(x, y, union_box=None): + u = union_box or virtual_desktop_box() + nx = int(round((x - u["left"]) * 65535 / max(1, u["w"] - 1))) + ny = int(round((y - u["top"]) * 65535 / max(1, u["h"] - 1))) + send_inputs(_mi(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESKTOP, dx=nx, dy=ny)) + + def inject_mouse_buttons(new_b, old_b): + evts = [] + for mask, down, up in ( + (1, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), + (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), + (4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), + ): + if new_b & mask and not old_b & mask: + evts.append(_mi(down)) + if not new_b & mask and old_b & mask: + evts.append(_mi(up)) + # side buttons (back/forward): bit 8 = X1, bit 16 = X2 + for mask, xb in ((8, XBUTTON1), (16, XBUTTON2)): + if new_b & mask and not old_b & mask: + evts.append(_mi(MOUSEEVENTF_XDOWN, data=xb)) + if not new_b & mask and old_b & mask: + evts.append(_mi(MOUSEEVENTF_XUP, data=xb)) + if evts: + send_inputs(*evts) + + def inject_wheel(v, h): + evts = [] + if v: + evts.append(_mi(MOUSEEVENTF_WHEEL, int(v * 120))) + if h: + evts.append(_mi(MOUSEEVENTF_HWHEEL, int(h * 120))) + if evts: + send_inputs(*evts) + + _pressed_keys = set() + + def inject_key(code, down): + vk = VK.get(code) + if not vk: + return + scan = user32.MapVirtualKeyW(vk, 0) + flags = KEYEVENTF_SCANCODE | (KEYEVENTF_EXTENDEDKEY if code in EXTENDED_CODES else 0) + if not down: + flags |= KEYEVENTF_KEYUP + if down: + _pressed_keys.add(code) + else: + _pressed_keys.discard(code) + if scan: + send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(0, scan, flags, 0, 0)))) + else: + send_inputs(INPUT(INPUT_KEYBOARD, _U(ki=KEYBDINPUT(vk, 0, flags, 0, 0)))) + + def release_all_keys(): + for code in list(_pressed_keys): + inject_key(code, False) + _pressed_keys.clear() + + class InputPump(threading.Thread): + """Dedicated input thread that follows the active desktop before each + injection (works on logon screen / UAC when running as SYSTEM).""" + + def __init__(self, host): + super().__init__(daemon=True) + self.host = host + self.q = queue.Queue(maxsize=2048) + self.buttons = 0 + self._last_attach = 0.0 + + def put(self, item): + try: + self.q.put_nowait(item) + except queue.Full: + # never drop a button/key/wheel event (a lost "up" = a stuck + # key or button). Only mouse *moves* are safe to drop under load; + # evict the oldest queued move to make room for this event. + if item[0] != "move": + try: + for _ in range(len(self.q.queue)): + old = self.q.get_nowait() + if old[0] == "move": + break + except Exception: + pass + try: + self.q.put_nowait(item) + except Exception: + pass + + def run(self): + while True: + item = self.q.get() + try: + # latest-wins for motion: if moves piled up (slow link burst, + # injector stalled), only the newest position matters - never + # skip past a button/key/wheel event though + if item[0] == "move": + while True: + try: + nxt = self.q.queue[0] + except IndexError: + break + if nxt[0] != "move": + break + item = self.q.get_nowait() + # follow the active input desktop (logon screen / UAC) but not + # on every single event - and INSIDE the try so a transient + # OpenInputDesktop failure can never kill the input thread + tnow = time.time() + if item[0] == "key" or tnow - self._last_attach > 0.2: + attach_desktop() + self._last_attach = tnow + if item[0] == "move": + # cheap virtual-desktop bounds (no per-move mss allocation) + inject_mouse_move(item[1], item[2], virtual_desktop_box()) + elif item[0] == "buttons": + inject_mouse_buttons(item[1], self.buttons) + self.buttons = item[1] + elif item[0] == "wheel": + inject_wheel(item[1], item[2]) + elif item[0] == "key": + if self.host.cfg.data["perms"].get("input", True): + inject_key(item[1], item[2]) + elif item[0] == "wake": + send_inputs(_mi(MOUSEEVENTF_MOVE, dx=1, dy=0), + _mi(MOUSEEVENTF_MOVE, dx=-1, dy=0)) + except Exception as e: + log.debug("input pump error: %s", e) + + # --- remote cursor shape (RustDesk-style overlay: sent separately from video + # so idle screens still update the cursor and no re-encode is forced) --- + gdi32 = ctypes.windll.gdi32 + + class CURSORINFO(ctypes.Structure): + _fields_ = [("cbSize", DWORD), ("flags", DWORD), ("hCursor", wintypes.HANDLE), + ("ptScreenPos", wintypes.POINT)] + + class ICONINFO(ctypes.Structure): + _fields_ = [("fIcon", wintypes.BOOL), ("xHotspot", DWORD), ("yHotspot", DWORD), + ("hbmMask", wintypes.HANDLE), ("hbmColor", wintypes.HANDLE)] + + class BITMAP(ctypes.Structure): + _fields_ = [("bmType", LONG), ("bmWidth", LONG), ("bmHeight", LONG), + ("bmWidthBytes", LONG), ("bmPlanes", WORD), ("bmBitsPixel", WORD), + ("bmBits", ctypes.c_void_p)] + + class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", DWORD), ("biWidth", LONG), ("biHeight", LONG), + ("biPlanes", WORD), ("biBitCount", WORD), ("biCompression", DWORD), + ("biSizeImage", DWORD), ("biXPelsPerMeter", LONG), ("biYPelsPerMeter", LONG), + ("biClrUsed", DWORD), ("biClrImportant", DWORD)] + + user32.GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] + user32.GetIconInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(ICONINFO)] + CURSOR_SHOWING = 0x0001 + _cursor_png_cache = {} + # shared system cursors -> CSS name. The viewer then uses the OS-native pointer + # (crisp at any DPI), and it still works when GetIconInfo refuses a handle that + # belongs to another session (engine running as SYSTEM in session 0). + _STD_CURSORS = {} + user32.LoadCursorW.restype = ctypes.c_void_p + for _idc, _css in ((32512, "default"), (32513, "text"), (32514, "wait"), + (32515, "crosshair"), (32516, "default"), (32642, "nwse-resize"), + (32643, "nesw-resize"), (32644, "ew-resize"), (32645, "ns-resize"), + (32646, "move"), (32648, "not-allowed"), (32649, "pointer"), + (32650, "progress"), (32651, "help")): + try: + _h = user32.LoadCursorW(None, ctypes.c_void_p(_idc)) # MAKEINTRESOURCE + if _h: + _STD_CURSORS[int(_h)] = _css + except Exception: + pass + + def cursor_state(): + """(hCursor, global_x, global_y) or None. hCursor==0 means hidden.""" + ci = CURSORINFO() + ci.cbSize = ctypes.sizeof(CURSORINFO) + if not user32.GetCursorInfo(ctypes.byref(ci)): + return None + showing = bool(ci.flags & CURSOR_SHOWING) and ci.hCursor + return (int(ci.hCursor) if showing else 0, ci.ptScreenPos.x, ci.ptScreenPos.y) + + def cursor_png(hcursor): + """RGBA PNG (base64) + hotspot for a cursor handle, cached by handle.""" + if hcursor in _cursor_png_cache: + return _cursor_png_cache[hcursor] + import numpy as _np + from PIL import Image as _Img + import io as _io + ii = ICONINFO() + if not user32.GetIconInfo(hcursor, ctypes.byref(ii)): + return None + try: + bm = BITMAP() + src = ii.hbmColor if ii.hbmColor else ii.hbmMask + gdi32.GetObjectW(src, ctypes.sizeof(BITMAP), ctypes.byref(bm)) + w, h = bm.bmWidth, bm.bmHeight + if not ii.hbmColor: + h //= 2 + hdc = user32.GetDC(0) + bi = BITMAPINFOHEADER() + bi.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bi.biWidth = w; bi.biPlanes = 1; bi.biBitCount = 32; bi.biCompression = 0 + + def dib(hbm, rows): + bi.biHeight = -abs(rows) + buf = (ctypes.c_char * (w * abs(rows) * 4))() + gdi32.GetDIBits(hdc, hbm, 0, abs(rows), buf, ctypes.byref(bi), 0) + return _np.frombuffer(bytes(buf), dtype=_np.uint8).reshape(abs(rows), w, 4) + + if ii.hbmColor: + col = dib(ii.hbmColor, h) + if col[:, :, 3].any(): + rgba = col[:, :, [2, 1, 0, 3]].copy() + else: + andm = dib(ii.hbmMask, h)[:, :, 0] + alpha = _np.where(andm == 0, 255, 0).astype(_np.uint8) + rgba = _np.dstack([col[:, :, 2], col[:, :, 1], col[:, :, 0], alpha]) + else: + both = dib(ii.hbmMask, h * 2)[:, :, 0] + AND, XOR = both[:h], both[h:] + alpha = _np.where(AND == 0, 255, 0).astype(_np.uint8) + lum = _np.where((AND == 0) & (XOR > 0), 255, 0).astype(_np.uint8) + rgba = _np.dstack([lum, lum, lum, alpha]) + user32.ReleaseDC(0, hdc) + b = _io.BytesIO() + _Img.fromarray(rgba, "RGBA").save(b, "PNG") + out = {"w": int(w), "h": int(h), "hx": int(ii.xHotspot), "hy": int(ii.yHotspot), + "png": base64.b64encode(b.getvalue()).decode()} + if len(_cursor_png_cache) < 64: + _cursor_png_cache[hcursor] = out + return out + except Exception: + return None + finally: + try: + if ii.hbmColor: + gdi32.DeleteObject(ii.hbmColor) + if ii.hbmMask: + gdi32.DeleteObject(ii.hbmMask) + except Exception: + pass + + # --- clipboard --- + def clip_get(): + if not user32.OpenClipboard(None): + return None + try: + h = user32.GetClipboardData(CF_UNICODETEXT) + if not h: + return None + ptr = kernel32.GlobalLock(h) + if not ptr: + return None + try: + return ctypes.wstring_at(ptr) + finally: + kernel32.GlobalUnlock(h) + finally: + user32.CloseClipboard() + + def clip_set(text): + data = text.encode("utf-16-le") + b"\x00\x00" + if not user32.OpenClipboard(None): + return False + try: + user32.EmptyClipboard() + h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data)) + if not h: + return False + ptr = kernel32.GlobalLock(h) + if not ptr: + return False + ctypes.memmove(ptr, data, len(data)) + kernel32.GlobalUnlock(h) + user32.SetClipboardData(CF_UNICODETEXT, h) + return True + finally: + user32.CloseClipboard() + + # --- display power (privacy mode) --- + HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER = 0xFFFF, 0x0112, 0xF170 + SMTO_ABORTIFHUNG = 0x0002 + + def monitor_power(state): # -1 on, 2 off + res = DWORD() + user32.SendMessageTimeoutW(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, + state, SMTO_ABORTIFHUNG, 500, ctypes.byref(res)) + + # --- process manager --- + def process_list(): + out = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + text=True, creationflags=0x08000000, timeout=15).stdout + items = [] + for line in out.splitlines(): + parts = [p.strip('"') for p in line.split('","')] + if len(parts) >= 5: + try: + mem = int(parts[4].replace("K", "").replace(",", "").strip()) + except Exception: + mem = 0 + items.append({"name": parts[0], "pid": int(parts[1]), "mem": mem}) + items.sort(key=lambda i: -i["mem"]) + return items[:300] + + def process_kill(pid): + r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, + text=True, creationflags=0x08000000, timeout=10) + return r.returncode == 0, (r.stdout or r.stderr).strip() + + def print_file(path): + """Print a document on this machine's default printer (print verb).""" + if not os.path.isfile(path): + return False, "file not found" + try: + r = ctypes.windll.shell32.ShellExecuteW(0, "print", path, None, None, 0) + if r > 32: + return True, "sent to default printer" + return False, f"no print association (code {r})" + except Exception as e: + return False, str(e) + + # --- power / commands --- + def sys_command(action, cmd=None): + CREATE_NO_WINDOW = 0x08000000 + try: + if action == "lock": + user32.LockWorkStation() + return True, "locked" + if action == "logoff": + subprocess.Popen("shutdown /l", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "logging off" + if action == "reboot": + subprocess.Popen("shutdown /r /t 3", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "rebooting" + if action == "shutdown": + subprocess.Popen("shutdown /s /t 3", shell=True, creationflags=CREATE_NO_WINDOW) + return True, "shutting down" + if action == "run" and cmd: + subprocess.Popen(cmd, shell=True, creationflags=CREATE_NO_WINDOW) + return True, "started" + return False, "unknown command" + except Exception as e: + return False, str(e) + + # --- session/process helpers for the service broker --- + kernel32.WTSGetActiveConsoleSessionId.restype = DWORD + kernel32.WTSGetActiveConsoleSessionId.argtypes = [] + kernel32.ProcessIdToSessionId.restype = wintypes.BOOL + kernel32.ProcessIdToSessionId.argtypes = [DWORD, ctypes.POINTER(DWORD)] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.GetCurrentProcess.argtypes = [] + kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + + TOKEN_ALL_ACCESS = 0xF01FF + TokenSessionId = 12 + SecurityImpersonation, TokenPrimary = 2, 1 + DETACHED_PROCESS, CREATE_NEW_PROCESS_GROUP = 0x00000008, 0x00000200 + CREATE_UNICODE_ENVIRONMENT, CREATE_BREAKAWAY_FROM_JOB = 0x00000400, 0x01000000 + + advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi32.DuplicateTokenEx.argtypes = [wintypes.HANDLE, DWORD, wintypes.LPVOID, + wintypes.INT, wintypes.INT, + ctypes.POINTER(wintypes.HANDLE)] + advapi32.SetTokenInformation.argtypes = [wintypes.HANDLE, wintypes.INT, + wintypes.LPVOID, DWORD] + advapi32.CreateProcessAsUserW.argtypes = [wintypes.HANDLE, wintypes.LPCWSTR, + wintypes.LPWSTR, wintypes.LPVOID, + wintypes.LPVOID, wintypes.BOOL, DWORD, + wintypes.LPVOID, wintypes.LPCWSTR, + ctypes.POINTER(STARTUPINFOW), + ctypes.POINTER(PROCESS_INFORMATION)] + userenv = ctypes.windll.userenv + userenv.CreateEnvironmentBlock.argtypes = [ctypes.POINTER(wintypes.LPVOID), + wintypes.HANDLE, wintypes.BOOL] + userenv.DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID] + + def console_session_id(): + return kernel32.WTSGetActiveConsoleSessionId() + + def my_session_id(): + sid = DWORD(0) + kernel32.ProcessIdToSessionId(kernel32.GetCurrentProcessId(), ctypes.byref(sid)) + return sid.value + + def spawn_in_console_session(cmdline, session_id, desktop="Winsta0\\Default"): + """Spawn a detached SYSTEM process inside the given console session + (needs SeTcbPrivilege, i.e. the caller runs as SYSTEM).""" + htok = wintypes.HANDLE() + if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), + TOKEN_ALL_ACCESS, ctypes.byref(htok)): + raise OSError("OpenProcessToken failed") + hdup = wintypes.HANDLE() + if not advapi32.DuplicateTokenEx(htok, TOKEN_ALL_ACCESS, None, + SecurityImpersonation, TokenPrimary, + ctypes.byref(hdup)): + raise OSError("DuplicateTokenEx failed") + sid = DWORD(session_id) + if not advapi32.SetTokenInformation(hdup, TokenSessionId, + ctypes.byref(sid), ctypes.sizeof(sid)): + raise OSError("SetTokenInformation(TokenSessionId) failed") + env = wintypes.LPVOID() + have_env = userenv.CreateEnvironmentBlock(ctypes.byref(env), hdup, False) + si = STARTUPINFOW() + si.cb = ctypes.sizeof(si) + si.lpDesktop = desktop + pi = PROCESS_INFORMATION() + flags = DETACHED_PROCESS | CREATE_UNICODE_ENVIRONMENT | CREATE_BREAKAWAY_FROM_JOB + cmd = ctypes.create_unicode_buffer(cmdline) + if cmdline.startswith('"'): + app = cmdline[1:cmdline.index('"', 1)] + else: + app = cmdline.split(" ", 1)[0] + ok = advapi32.CreateProcessAsUserW( + hdup, app, cmd, None, None, False, flags, + env.value if have_env else None, None, ctypes.byref(si), ctypes.byref(pi)) + if have_env: + userenv.DestroyEnvironmentBlock(env) + if not ok: + raise OSError(f"CreateProcessAsUser failed: {ctypes.GetLastError()}") + return pi.hProcess, pi.dwProcessId + + def is_elevated(): + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + return False + +elif sys.platform == "darwin": + # ---------------- macOS (Apple Silicon native, pure ctypes) ---------------- + import ctypes.util + import platform + + _cg = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreGraphics")) + _cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation")) + + class CGPoint(ctypes.Structure): + _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)] + + class _Size(ctypes.Structure): + _fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)] + + class CGRect2(ctypes.Structure): + _fields_ = [("origin", CGPoint), ("size", _Size)] + + _cg.CGEventCreateMouseEvent.restype = ctypes.c_void_p + _cg.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, CGPoint, ctypes.c_uint32] + _cg.CGEventCreateScrollWheelEvent.restype = ctypes.c_void_p + _cg.CGEventCreateScrollWheelEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_int32] + _cg.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p + _cg.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool] + _cg.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + _cg.CGEventSetLocation.argtypes = [ctypes.c_void_p, CGPoint] + _cg.CFRelease.argtypes = [ctypes.c_void_p] + _cg.CGMainDisplayID.restype = ctypes.c_void_p + _cg.CGDisplayBounds.restype = CGRect2 + _cg.CGDisplayBounds.argtypes = [ctypes.c_void_p] + _cg.CGDisplayPixelsWide.restype = ctypes.c_uint + _cg.CGDisplayPixelsWide.argtypes = [ctypes.c_void_p] + + kCGHIDEventTap = 0 + kCGEventMouseMoved, kCGEventLeftDown, kCGEventLeftUp = 5, 1, 2 + kCGEventRightDown, kCGEventRightUp = 3, 4 + kCGEventOtherDown, kCGEventOtherUp = 25, 26 + kCGScrollEventUnitLine = 1 + # US-ANSI virtual keycodes for our JS KeyboardEvent.code names + VK_MAC = { + **{f"Key{c}": v for c, v in zip( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + [0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04, 0x22, 0x26, + 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23, 0x0C, 0x0F, 0x01, 0x11, + 0x20, 0x09, 0x0D, 0x07, 0x10, 0x06])}, + **{f"Digit{c}": v for c, v in zip( + "1234567890", [0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19, 0x1D])}, + **{f"F{n}": v for n, v in zip(range(1, 13), + [0x7A, 0x78, 0x63, 0x76, 0x60, 0x61, 0x62, 0x64, 0x65, 0x6D, 0x67, 0x6F])}, + "Backquote": 0x32, "Minus": 0x1B, "Equal": 0x18, "BracketLeft": 0x21, + "BracketRight": 0x1E, "Backslash": 0x2A, "Semicolon": 0x29, "Quote": 0x27, + "Comma": 0x2B, "Period": 0x2F, "Slash": 0x2C, + "CapsLock": 0x39, "Space": 0x31, "Enter": 0x24, "NumpadEnter": 0x24, + "Backspace": 0x33, "Tab": 0x30, "Escape": 0x35, + "Delete": 0x75, "Insert": 0, "Home": 0x73, "End": 0x77, + "PageUp": 0x74, "PageDown": 0x79, + "ArrowLeft": 0x7B, "ArrowUp": 0x7E, "ArrowRight": 0x7C, "ArrowDown": 0x7D, + "ControlLeft": 0x3B, "ControlRight": 0x3E, "AltLeft": 0x3A, "AltRight": 0x3D, + "ShiftLeft": 0x38, "ShiftRight": 0x3C, + "MetaLeft": 0x37, "MetaRight": 0x37, "ContextMenu": 0, + "Numpad0": 0x52, "Numpad1": 0x53, "Numpad2": 0x54, "Numpad3": 0x55, + "Numpad4": 0x56, "Numpad5": 0x57, "Numpad6": 0x58, "Numpad7": 0x59, + "Numpad8": 0x5B, "Numpad9": 0x5C, + } + + _mac_tls = threading.local() + + def _display_info(): + """[(scale, origin_x, origin_y, point_w, point_h)] per active display.""" + if getattr(_mac_tls, "displays", None): + return _mac_tls.displays + infos = [] + count = 32 + arr = (ctypes.c_void_p * count)() + actual = ctypes.c_uint32() + if _cg.CGGetActiveDisplayList(count, arr, ctypes.byref(actual)) == 0: + for i in range(actual.value): + did = arr[i] + bounds = _cg.CGDisplayBounds(did) + pw = _cg.CGDisplayPixelsWide(did) + scale = (pw / bounds.size.width) if bounds.size.width else 1.0 + infos.append((scale, bounds.origin.x, bounds.origin.y, + bounds.size.width, bounds.size.height)) + _mac_tls.displays = infos or [(1.0, 0.0, 0.0, 1920.0, 1080.0)] + return _mac_tls.displays + + def pixel_to_global(x, y): + """Map a physical-pixel coordinate on the virtual desktop to global points.""" + for scale, ox, oy, pw, ph in _display_info(): + if x < pw * scale and y < ph * scale: + return CGPoint(ox + x / scale, oy + y / scale) + scale, ox, oy, pw, ph = _display_info()[0] + return CGPoint(ox + x / scale, oy + y / scale) + + def attach_desktop(): + return "Default" + + def desktop_name(): + return "Default" + + def _post_event(ev): + _cg.CGEventPost(kCGHIDEventTap, ev) + _cg.CFRelease(ev) + + def _mouse_event(etype, button, x, y): + return _cg.CGEventCreateMouseEvent(None, etype, pixel_to_global(x, y), button) + + def send_inputs(*inputs): # parity stub (mac uses discrete events) + pass + + class InputPump(threading.Thread): + def __init__(self, host): + super().__init__(daemon=True) + self.host = host + self.q = queue.Queue(maxsize=512) + self.buttons = 0 + + def put(self, item): + try: + self.q.put_nowait(item) + except Exception: + pass + + def run(self): + while True: + item = self.q.get() + try: + if item[0] == "move": + _post_event(_mouse_event(kCGEventMouseMoved, 0, item[1], item[2])) + elif item[0] == "buttons": + b = item[1] + x, y = self._pos() + for mask, (down_t, up_t, btn) in ( + (1, (kCGEventLeftDown, kCGEventLeftUp, 0)), + (2, (kCGEventRightDown, kCGEventRightUp, 1)), + (4, (kCGEventOtherDown, kCGEventOtherUp, 2))): + if b & mask and not self.buttons & mask: + _post_event(_mouse_event(down_t, btn, x, y)) + if not b & mask and self.buttons & mask: + _post_event(_mouse_event(up_t, btn, x, y)) + self.buttons = b + elif item[0] == "wheel": + v, h = item[1], item[2] + ev = _cg.CGEventCreateScrollWheelEvent(None, kCGScrollEventUnitLine, 2, int(v), int(h)) + _post_event(ev) + elif item[0] == "key": + if self.host.cfg.data["perms"].get("input", True): + kc = VK_MAC.get(item[1]) + if kc: + _post_event(_cg.CGEventCreateKeyboardEvent(None, kc, item[2])) + elif item[0] == "wake": + x, y = self._pos() + _post_event(_mouse_event(kCGEventMouseMoved, 0, x + 1, y)) + except Exception as e: + log.debug("mac input error: %s", e) + + def _pos(self): + return getattr(self.host, "_last_xy", (0, 0)) + + def clip_get(): + r = subprocess.run(["/usr/bin/pbpaste"], capture_output=True, text=True, timeout=5) + return r.stdout if r.returncode == 0 else None + + def clip_set(text): + r = subprocess.run(["/usr/bin/pbcopy"], input=text, text=True, timeout=5) + return r.returncode == 0 + + def monitor_power(state): # -1 ignored; 2 = display sleep + if state == 2: + subprocess.Popen(["/usr/bin/pmset", "displaysleepnow"]) + + def process_list(): + r = subprocess.run(["ps", "-axo", "pid=,rss=,comm="], capture_output=True, text=True, timeout=15) + items = [] + for line in r.stdout.splitlines(): + parts = line.strip().split(None, 2) + if len(parts) == 3 and parts[0].isdigit(): + items.append({"pid": int(parts[0]), "mem": int(parts[1]) * 1024, + "name": os.path.basename(parts[2])}) + items.sort(key=lambda i: -i["mem"]) + return items[:300] + + def process_kill(pid): + try: + os.kill(pid, 9) + return True, "killed" + except Exception as e: + return False, str(e) + + def print_file(path): + if not os.path.isfile(path): + return False, "file not found" + r = subprocess.run(["/usr/bin/lp", path], capture_output=True, text=True, timeout=10) + return r.returncode == 0, (r.stdout or r.stderr).strip() or "queued" + + def sys_command(action, cmd=None): + try: + if action == "lock": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to keystroke "q" using {command down, control down}'], + timeout=5) + return True, "locked" + if action == "logoff": + return False, "use lock or reboot" + if action == "reboot": + subprocess.Popen(["osascript", "-e", 'tell app "System Events" to restart']) + return True, "rebooting" + if action == "shutdown": + subprocess.Popen(["osascript", "-e", 'tell app "System Events" to shut down']) + return True, "shutting down" + if action == "run" and cmd: + subprocess.Popen(cmd, shell=True) + return True, "started" + return False, "unknown command" + except Exception as e: + return False, str(e) + + def console_session_id(): + return 1 + + def my_session_id(): + return 1 + + def spawn_in_console_session(cmdline, sid, desktop=None): + raise OSError("macOS uses LaunchAgent, not a session broker") + + def is_elevated(): + return os.geteuid() == 0 + +else: # ---- other non-Windows stubs ---- + def attach_desktop(): return "Default" + def desktop_name(): return "Default" + def clip_get(): return None + def clip_set(t): return False + def release_all_keys(): pass + def monitor_power(state): pass + def process_list(): return [] + def process_kill(pid): return False, "unsupported" + def print_file(path): return False, "unsupported" + def sys_command(a, c=None): return False, "Windows only" + def console_session_id(): return 0 + def my_session_id(): return 0 + def spawn_in_console_session(cmdline, sid, desktop=None): raise OSError("Windows only") + def is_elevated(): return os.geteuid() == 0 + + +# ================================================================ session / host + +class Session: + def __init__(self, host, sid): + self.host = host + self.sid = sid + self.active = False + self.e2e = None + self.worker = None + self.audio = None + self.stop_evt = threading.Event() + self.mon = 1 + self.fps = 30 + self.bitrate = 6000 + self.scale = 1.0 + self.apply_evt = threading.Event() + self.uploads = {} + self.monitors = [] + + +class Host: + """The desktop worker. Talks over exactly one of: + link='server' direct outbound websocket to the NetDesk server (simple mode) + link='broker' websocket to the local service broker (service mode) + link='direct' a direct viewer websocket (LAN / port-forward)""" + + def __init__(self, cfg: Config, args, link="server"): + self.cfg = cfg + self.args = args + self.link = link + self.loop = None + self.session: Session | None = None + self._current_sid = None + # WebRTC P2P fast path (video+input peer-to-peer; relay = fallback) + self.rtc_pc = None + self.rtc_media = None + self.rtc_ctl = None + self.rtc_active = False + self.ws_server = None # server link socket (simple mode) + self.ws_broker = None # broker socket (service mode) + self.ws_direct = None # direct viewer socket + self._pending = None + self._mouse_b = 0 + self.clip_last_sent = None + self.clip_thread = None + self.pump = None + self.privacy = False + self.reg_error = "" + self.account = "" + self._tunnels = {} # tid -> (reader, writer) + self.audio_available = False + try: + import pyaudiowpatch # noqa: F401 + self.audio_available = (not getattr(args, "no_audio", False) + and cfg.data["perms"].get("audio", True)) + except Exception: + log.info("PyAudioWPatch not installed - audio disabled (pip install PyAudioWPatch)") + if IS_WIN: + self.pump = InputPump(self) + self.pump.start() + + # ---------------- outgoing (thread-safe, non-blocking) ---------------- + def _post(self, coro): + def _drain(fut): + try: + fut.exception() + except Exception: + pass + try: + asyncio.run_coroutine_threadsafe(coro, self.loop).add_done_callback(_drain) + except RuntimeError: + pass + + def _sock(self): + if self.link == "direct": + return self.ws_direct + if self.link == "broker": + return self.ws_broker + return self.ws_server + + async def _send_str(self, ws, data): + if ws is not None and not ws.closed: + await ws.send_str(data) + + # ---------------- WebRTC P2P (video + input take the direct UDP path) ---- + async def rtc_answer(self, m): + """Viewer sent an SDP offer over the relay: answer it. On success the + media/ctl DataChannels open and video+input flow peer-to-peer.""" + try: + await self.rtc_close() + # ICE servers come with the offer (server-minted, incl. the nearest + # TURN relay) so the host never needs TURN credentials itself. + ice = [] + for srv in (m.get("ice") or []): + try: + urls = srv.get("urls") + urls = urls if isinstance(urls, list) else [urls] + # aiortc uses ONE turn url: prefer UDP (lowest latency) + urls.sort(key=lambda u: (0 if "transport=udp" in u else 1)) + ice.append(RTCIceServer(urls=urls, username=srv.get("username"), + credential=srv.get("credential"))) + except Exception: + pass + if not ice: + ice = [RTCIceServer(urls=["stun:stun.cloudflare.com:3478"])] + pc = RTCPeerConnection(RTCConfiguration(iceServers=ice)) + self.rtc_pc = pc + self._rtc_turn = any("turn" in str(s.urls) for s in ice) + + @pc.on("datachannel") + def on_dc(ch): + if ch.label == "media": + self.rtc_media = ch + elif ch.label in ("ctl", "move"): + # "ctl" = ordered (clicks/keys); "move" = unordered+unreliable + # mouse motion so a lost packet never stalls the pointer + if ch.label == "ctl": + self.rtc_ctl = ch + else: + # tell the viewer we understand the motion channel; older + # engines stay silent and the viewer keeps motion on "ctl" + try: + ch.send(json.dumps({"t": "move.ok"})) + except Exception: + pass + + @ch.on("message") + def on_ctl(msg): + # input JSON arrives here instead of the relay socket + try: + if isinstance(msg, str): + self.on_message(json.loads(msg)) + except Exception: + pass + + @ch.on("close") + def on_close(): + if self.rtc_active: + log.info("rtc: channel closed - back to relay") + self.rtc_active = False + self._rtc_check_open() + + @ch.on("open") + def on_open(): + self._rtc_check_open() + + @pc.on("connectionstatechange") + async def on_state(): + if pc.connectionState in ("failed", "closed", "disconnected"): + if self.rtc_active: + log.info("rtc: %s - back to relay", pc.connectionState) + self.rtc_active = False + + await pc.setRemoteDescription( + RTCSessionDescription(sdp=m.get("sdp", ""), type="offer")) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) # gathers ICE (non-trickle) + sid = self.session.sid if self.session else None + self.send_json({"t": "rtc.answer", "sid": sid, + "sdp": pc.localDescription.sdp}) + log.info("rtc: sent answer, waiting for P2P channels") + except Exception as e: + log.warning("rtc setup failed: %s", e) + try: + self.send_json({"t": "rtc.unavailable", + "sid": self.session.sid if self.session else None, + "msg": str(e)[:120]}) + except Exception: + pass + + def _rtc_check_open(self): + mch, cch = self.rtc_media, self.rtc_ctl + if (mch is not None and cch is not None + and mch.readyState == "open" and cch.readyState == "open" + and not self.rtc_active): + self.rtc_active = True + route = "unknown" + try: # which tier won: direct P2P or the TURN relay? + pair = self.rtc_pc.sctp.transport.transport._connection._nominated + for p in pair.values(): + route = ("TURN relay" if p.local_candidate.type == "relay" + or p.remote_candidate.type == "relay" else "DIRECT P2P") + except Exception: + pass + log.info("rtc: ACTIVE via %s - video and input bypass the WSS relay", route) + + async def rtc_close(self): + self.rtc_active = False + pc, self.rtc_pc = self.rtc_pc, None + self.rtc_media = self.rtc_ctl = None + if pc is not None: + try: + await pc.close() + except Exception: + pass + + def send_json(self, m: dict): + if self._current_sid and "sid" not in m: + m["sid"] = self._current_sid + self._post(self._send_str(self._sock(), json.dumps(m))) + + def send_binary(self, payload: bytes): + sess = self.session + if not sess: + return + self._post(self._send_bin(sess, payload)) + + def send_raw(self, payload: bytes): + """Send without session gating/E2E (used by TCP tunnels: the payload + is TLS-protected to the server, which relays it to the viewer).""" + ws = self._sock() + async def _go(): + if ws is not None and not ws.closed: + await ws.send_bytes(payload) + self._post(_go()) + + async def _send_bin(self, sess: Session, payload: bytes): + if sess.e2e: + nonce = secrets.token_bytes(12) + payload = nonce + sess.e2e.encrypt(nonce, payload, None) + # P2P fast path: same encrypted bytes, direct UDP instead of the relay. + # If the channel is congested we DROP the frame (fresh beats late); + # if it's gone we fall through to the relay socket seamlessly. + if self.rtc_active: + ch = self.rtc_media + try: + if ch is not None and ch.readyState == "open": + if ch.bufferedAmount < 262144: + ch.send(payload) + return + except Exception: + self.rtc_active = False + ws = self._sock() + if ws is not None and not ws.closed: + await ws.send_bytes(payload) + + # ---------------- auth ---------------- + def make_challenge(self, sid): + nonce = secrets.token_hex(16) + self._current_sid = sid + self._pending = {"sid": sid, "nonce": nonce, + "priv": ec.generate_private_key(ec.SECP256R1())} + pub_b = self._pending["priv"].public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + pub_b64 = base64.b64encode(pub_b).decode() + self._pending["pub_b64"] = pub_b64 # for channel-bound proof verification + return { + "t": "session.challenge", "sid": sid, + "salt": base64.b64encode(bytes.fromhex(self.cfg.data["salt"])).decode(), + "iters": self.cfg.data["iters"], + "nonce": nonce, "e2e": True, + "totp": bool(self.cfg.data.get("totp")), + "pub": pub_b64, + } + + def finish_auth(self, m): + p = getattr(self, "_pending", None) + sid = m.get("sid") + if not p or p["sid"] != sid: + self.send_json({"t": "session.deny", "reason": "no pending session"}) + return + totp_secret = self.cfg.data.get("totp") + if totp_secret and not totp_verify(totp_secret, m.get("totp", "")): + log.warning("bad 2FA code (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "bad 2FA code"}) + self._pending = None + self._current_sid = None + return + # Channel-bound proof (MITM hardening): if the viewer sent proof_bind, + # it must verify against OUR ephemeral pubkey — a relay that swapped the + # pub in the challenge can't satisfy it. Absent → legacy viewer, fall + # back to the plain proof (no regression). + pbind = m.get("proof_bind") + if pbind and not self.cfg.check_proof_bound(p["nonce"], p.get("pub_b64", ""), pbind): + log.warning("channel-bound proof MISMATCH — possible relay key-swap (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "key verification failed"}) + self._pending = None + self._current_sid = None + return + if not self.cfg.check_proof(p["nonce"], m.get("proof", "")): + log.warning("bad password attempt (sid=%s)", sid) + time.sleep(0.4) + self.send_json({"t": "session.deny", "reason": "wrong password"}) + self._pending = None + self._current_sid = None + return + aesgcm = None + viewer_pub_b = m.get("pub") + if viewer_pub_b: + try: + peer = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), base64.b64decode(viewer_pub_b)) + shared = p["priv"].exchange(ec.ECDH(), peer) + aesgcm = AESGCM(hashlib.sha256(shared + b"e2e").digest()) + except Exception as e: + log.warning("E2E negotiation failed (%s); transport-only", e) + self._pending = None + + sess = Session(self, sid) + sess.e2e = aesgcm + try: + with mss.mss() as sct: + sess.monitors = [ + {"i": i, "l": mon["left"], "t": mon["top"], "w": mon["width"], "h": mon["height"]} + for i, mon in enumerate(sct.monitors[1:], start=1) + ] + except Exception: + sess.monitors = [] + mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) + info = { + "mon": sess.mon, "monitors": sess.monitors, + "w": mon["w"] if mon else 0, "h": mon["h"] if mon else 0, + "fps": sess.fps, "bitrate": sess.bitrate, + "perms": self.cfg.data["perms"], "lock_on_end": self.cfg.data.get("lock_on_end", False), + "e2e": aesgcm is not None, + "audio": self.audio_available, + "host_name": self.cfg.data["name"], + "version": VERSION, "os": sys.platform, + "logon_screen": (desktop_name() or "").lower().startswith("winlogon"), + } + self.session = sess + sess.active = True + if self.cfg.data.get("privacy_on_session") and self.cfg.data.get("allow_display_power"): + # silent/privacy session: blank the physical monitor instead of + # waking the displays - nothing appears on the host's screen + self.privacy = True + self._display_power(2) + elif self.pump: + self.pump.put(("wake",)) # wake the displays for the viewer + info["privacy_active"] = self.privacy + self.send_json({"t": "session.accept", "info": info}) + sess.worker = threading.Thread(target=capture_worker, args=(sess,), daemon=True) + sess.worker.start() + if self.audio_available: + sess.audio = threading.Thread(target=audio_worker, args=(sess,), daemon=True) + sess.audio.start() + if self.clip_thread is None or not self.clip_thread.is_alive(): + self.clip_thread = threading.Thread(target=clipboard_worker, args=(self,), daemon=True) + self.clip_thread.start() + log.info("session %s accepted (e2e=%s)", sid, aesgcm is not None) + + def end_session(self, reason): + sess = self.session + if not sess: + return + sess.active = False + sess.stop_evt.set() + for fh in sess.uploads.values(): + try: fh.close() + except Exception: pass + sess.uploads.clear() + release_all_keys() + if RTC_AVAILABLE and self.rtc_pc is not None: + try: + if self.loop is not None: + asyncio.run_coroutine_threadsafe(self.rtc_close(), self.loop) + except Exception: + pass + if self.privacy: + monitor_power(-1) + self.privacy = False + if getattr(self, "_wp_hidden", False): + # never leave the desk without its wallpaper + try: + wallpaper_set(getattr(self, "_wp_orig", "") or "") + except Exception: + pass + self._wp_hidden = False + if getattr(self, "_input_blocked", False): + try: + ctypes.windll.user32.BlockInput(False) + except Exception: + pass + self._input_blocked = False + mr = getattr(self, "_mode_restore", None) + if mr: + # never leave the remote PC in a resolution the viewer picked + try: + set_display_mode(mr["name"], mr["w"], mr["h"]) + except Exception: + pass + self._mode_restore = None + if self.cfg.data.get("lock_on_end"): + try: + sys_command("lock") + except Exception: + pass + try: + self.send_json({"t": "session.end", "sid": sess.sid, "reason": reason}) + except Exception: + pass + self.session = None + self._current_sid = None + log.info("session ended: %s", reason) + + def _display_power(self, state): + """Hardware display control. Sleeping the display requires the user's + explicit opt-in (allow_display_power); waking it is always permitted.""" + if state == 2 and not self.cfg.data.get("allow_display_power"): + log.info("display sleep requested but allow_display_power is off - ignored") + return + threading.Thread(target=monitor_power, args=(state,), daemon=True).start() + if state == 2: + self._start_privacy_watchdog() + + def _privacy_watchdog(self): + """Safety net: if privacy (monitor off) is ever engaged with no active + session - crash, race, stale config - force the monitor back on.""" + while self.privacy: + time.sleep(2) + sess = self.session + if self.privacy and (sess is None or not sess.active): + self.privacy = False + monitor_power(-1) + log.info("privacy watchdog: restored monitor (no active session)") + + def _start_privacy_watchdog(self): + threading.Thread(target=self._privacy_watchdog, daemon=True).start() + + # ---------------- messages ---------------- + def on_message(self, m: dict): + t = m.get("t") + sess = self.session + + if t == "wol": + # another computer on this account asked us to wake a LAN neighbour + threading.Thread(target=send_wol, args=(str(m.get("mac", "")),), + daemon=True).start() + return + + if t == "session.begin": + if sess and sess.active: + self.send_json({"t": "session.deny", "sid": m.get("sid"), "reason": "host busy"}) + return + self.cfg.reload() # password / 2FA changed in the app apply immediately + allow = (self.cfg.data.get("allow_ips") or "").strip() + peer = str(m.get("peer") or "") + if allow and peer and not ip_allowed(peer, allow): + log.warning("session denied: viewer %s is not on the IP allowlist", peer) + self.send_json({"t": "session.deny", "sid": m.get("sid"), + "reason": "this computer only accepts connections from allowed IPs"}) + return + self.send_json(self.make_challenge(m.get("sid") or uuid.uuid4().hex)) + return + if t == "session.auth": + self.finish_auth(m) + return + + # TCP tunnels live outside the session (server-gated, portal-authed) + if t == "tunnel.open": + self.tunnel_open(int(m.get("tid", 0)), m.get("thost", "127.0.0.1"), + int(m.get("port", 0))) + return + if t == "tunnel.close": + self.tunnel_close(int(m.get("tid", 0))) + return + + if not sess or m.get("sid") != sess.sid: + return + + if t == "session.end": + self.end_session("viewer left") + elif t == "ping": + self.send_json({"t": "pong", "c": m.get("c")}) + elif t == "set.quality": + sess.fps = int(m.get("fps", 30)) + sess.bitrate = int(m.get("bitrate", 6000)) + sess.scale = float(m.get("scale", 1.0)) + sess.apply_evt.set() + elif t == "monitor.switch": + try: + sess.mon = int(m.get("mon", 1)) + sess.apply_evt.set() + except Exception: + pass + elif t == "keyframe": + sess.force_idr = True + elif t == "rtc.offer": + if RTC_AVAILABLE: + asyncio.ensure_future(self.rtc_answer(m)) + else: + self.send_json({"t": "rtc.unavailable", "sid": sess.sid}) + elif t == "i.mouse": + self.do_mouse(m) + elif t == "i.key": + if self.pump: + self.pump.put(("key", m.get("c", ""), bool(m.get("d")))) + elif t == "clip.set": + if self.cfg.data["perms"].get("clipboard", True) and m.get("origin") == "viewer": + clip_set(m.get("text", "")[:1024 * 1024]) + self.clip_last_sent = m.get("text", "") + elif t == "chat": + log.info("chat from viewer: %s", m.get("text", "")[:200]) + elif t == "set.cfg": + for key in ("lock_on_end", "privacy_on_session", "allow_display_power"): + if key in m: + self.cfg.data[key] = bool(m[key]) + self.cfg.save() + self.send_json({"t": "sys.res", "ok": True, "msg": "config saved"}) + elif t == "file.roots": + self.file_roots() + elif t == "file.list": + self.file_list(m.get("path", "")) + elif t == "file.get": + self.file_get(m.get("path"), int(m.get("fid", 0))) + elif t == "file.put": + self.file_put_begin(m.get("path"), int(m.get("fid", 0)), int(m.get("size", 0))) + elif t == "file.mkdir": + self.file_simple(m.get("path"), lambda p: os.makedirs(p, exist_ok=True)) + elif t == "file.del": + self.file_simple(m.get("path"), + (lambda p: shutil.rmtree(p)) if m.get("dir") else (lambda p: os.remove(p))) + elif t == "file.ren": + self.file_rename(m.get("src"), m.get("dst")) + elif t == "sys.cmd": + self.do_sys(m) + + def _session_device(self): + """GDI display device of the monitor the current session is viewing. + Uses the geometry the capture worker already cached on the session - + NEVER calls mss here (a second mss in the event-loop thread crashes + the process on Windows via a GDI conflict with the capture thread).""" + sess = self.session + mi = sess.mon if sess else 1 + geo = None + for mm in (getattr(sess, "monitors", None) or []): + if mm["i"] == mi: + geo = {"left": mm["l"], "top": mm["t"]} + break + if geo is None: + # fall back to the primary display device + devs = _display_devices() + if not devs: + raise RuntimeError("no display device found") + return devs[0] + dev = _device_for_monitor(geo) + if not dev: + raise RuntimeError("no display device found") + return dev + + def do_sys(self, m): + # A system command must NEVER take down the engine or the session. + try: + self._do_sys(m) + except Exception as e: + log.warning("sys command %r failed: %s", m.get("a"), e) + try: + self.send_json({"t": "sys.res", "ok": False, + "kind": m.get("a"), "msg": str(e)[:200]}) + except Exception: + pass + + def _do_sys(self, m): + a = m.get("a", "") + if a == "processes": + try: + self.send_json({"t": "sys.res", "ok": True, "kind": "processes", + "items": process_list()}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "msg": str(e)}) + elif a == "kill": + ok, msg = process_kill(int(m.get("pid", 0))) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "privacy": + if not self.cfg.data.get("allow_display_power"): + self.send_json({"t": "sys.res", "ok": False, "kind": "privacy", + "msg": "display control disabled (enable in the NetDesk app)"}) + return + self.privacy = not self.privacy + self._display_power(2 if self.privacy else -1) + self.send_json({"t": "sys.res", "ok": True, "kind": "privacy", "on": self.privacy, + "msg": "monitor off" if self.privacy else "monitor on"}) + elif a == "run": + ok, msg = sys_command("run", m.get("cmd")) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "print": + path = m.get("path", "") + ok, msg = print_file(path) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + elif a == "modes": + try: + dev = self._session_device() + self.send_json({"t": "sys.res", "ok": True, "kind": "modes", + "items": display_modes(dev["name"]), + "current": [dev["w"], dev["h"]]}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "modes", + "msg": str(e)[:200]}) + elif a == "setmode": + try: + dev = self._session_device() + # remember the ORIGINAL resolution so we restore it at + # session end - never leave the remote PC in a changed mode + if not getattr(self, "_mode_restore", None): + self._mode_restore = {"name": dev["name"], "w": dev["w"], "h": dev["h"]} + ok, msg = set_display_mode(dev["name"], int(m.get("w", 0)), int(m.get("h", 0))) + if ok and self.session: + self.session.force_idr = True + self.send_json({"t": "sys.res", "ok": ok, "kind": "setmode", "msg": msg}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "setmode", + "msg": str(e)[:200]}) + elif a == "wallpaper": + try: + if getattr(self, "_wp_hidden", False): + wallpaper_set(getattr(self, "_wp_orig", "") or "") + self._wp_hidden = False + else: + self._wp_orig = wallpaper_get() + wallpaper_set("") + self._wp_hidden = True + self.send_json({"t": "sys.res", "ok": True, "kind": "wallpaper", + "on": self._wp_hidden, + "msg": "wallpaper hidden" if self._wp_hidden else "wallpaper restored"}) + except Exception as e: + self.send_json({"t": "sys.res", "ok": False, "kind": "wallpaper", + "msg": str(e)[:200]}) + elif a == "cad": + ok, msg = send_cad() + self.send_json({"t": "sys.res", "ok": ok, "kind": "cad", "msg": msg}) + elif a == "block_input": + # RustDesk parity: freeze the remote user's own mouse/keyboard while + # you control. Needs an elevated agent; auto-releases at session end + # (and Windows releases it if the process ever dies). + want = not getattr(self, "_input_blocked", False) + ok = bool(ctypes.windll.user32.BlockInput(want)) + if ok: + self._input_blocked = want + msg = "remote user's input BLOCKED" if want else "remote user's input released" + else: + msg = ("blocking needs the elevated service agent " + "(install the boot-time service)") + self.send_json({"t": "sys.res", "ok": ok, "kind": "block_input", + "on": getattr(self, "_input_blocked", False), "msg": msg}) + else: + ok, msg = sys_command(a) + self.send_json({"t": "sys.res", "ok": ok, "msg": msg}) + + def on_binary(self, data: bytes): + if data and data[0] == 0xF3 and len(data) >= 5: + # tunnel payload from the server (not session-E2E encrypted) + tid = int.from_bytes(data[1:5], "little") + self.on_tunnel_data(tid, data[5:]) + return + sess = self.session + if not sess: + return + if sess.e2e: + try: + data = sess.e2e.decrypt(data[:12], data[12:], None) + except Exception: + return + if not data or data[0] != 0xF2: + return + fid = int.from_bytes(data[1:5], "little") + off = int.from_bytes(data[5:13], "little") + chunk = data[13:] + fh = sess.uploads.get(fid) + if fh: + fh.seek(off) + fh.write(chunk) + left = getattr(fh, "_nd_left", None) + if left is not None: + fh._nd_left = left - len(chunk) + if fh._nd_left <= 0: + final = fh._nd_final + fh.close() + sess.uploads.pop(fid, None) + try: + os.replace(final + ".netdesk.part", final) + self.send_json({"t": "file.ok", "msg": "upload complete", "refresh": True}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + # ---------------- input ---------------- + def do_mouse(self, m): + if not self.cfg.data["perms"].get("input", True) or not self.pump: + return + x, y = int(m.get("x", 0)), int(m.get("y", 0)) + # viewer sends coords in the ENCODED image space; convert to physical + scale = getattr(self.session, "scale", 1.0) if self.session else 1.0 + if scale and abs(scale - 1.0) > 0.01: + x = int(x / scale) + y = int(y / scale) + # viewer coords are relative to the selected monitor; the injector uses + # virtual-desktop absolute coords, so add the monitor's origin + sess = self.session + if sess and sess.monitors: + mon = next((mm for mm in sess.monitors if mm["i"] == sess.mon), None) + if mon: + x += mon["l"] + y += mon["t"] + b = int(m.get("b", 0)) + w, hw = float(m.get("w", 0) or 0), float(m.get("hw", 0) or 0) + if (x, y) != getattr(self, "_last_xy", None): + self._last_xy = (x, y) + self.pump.put(("move", x, y)) + if b != self._mouse_b: + self.pump.put(("buttons", b)) + self._mouse_b = b + if w or hw: + self.pump.put(("wheel", w, hw)) # fractional notches preserved + + # ---------------- TCP tunnels ---------------- + def tunnel_open(self, tid: int, thost: str, port: int): + if tid in self._tunnels: + return + async def _go(): + try: + r, w = await asyncio.wait_for(asyncio.open_connection(thost, port), 6) + except Exception as e: + self.send_json({"t": "tunnel.err", "tid": tid, "msg": str(e)}) + return + self._tunnels[tid] = (r, w) + self.send_json({"t": "tunnel.ok", "tid": tid}) + try: + while True: + data = await r.read(65536) + if not data: + break + self.send_raw(b"\xf3" + tid.to_bytes(4, "little") + data) + except Exception: + pass + finally: + self.tunnel_close(tid) + self.send_json({"t": "tunnel.closed", "tid": tid}) + self._post(_go()) + + def tunnel_close(self, tid: int): + conn = self._tunnels.pop(tid, None) + if conn: + try: + conn[1].close() + except Exception: + pass + + def on_tunnel_data(self, tid: int, payload: bytes): + conn = self._tunnels.get(tid) + if conn: + try: + conn[1].write(payload) + except Exception: + self.tunnel_close(tid) + + # ---------------- files ---------------- + def file_roots(self): + items = [] + if IS_WIN: + for letter in string.ascii_uppercase: + drv = f"{letter}:\\" + if os.path.exists(drv): + items.append({"n": drv, "t": "d", "s": 0, "m": 0}) + else: + items.append({"n": "/", "t": "d", "s": 0, "m": 0}) + self.send_json({"t": "file.roots.res", "path": "", "items": items}) + + def file_list(self, path): + try: + if not path: + return self.file_roots() + items = [] + with os.scandir(path) as it: + for e in it: + try: + st = e.stat() + items.append({"n": e.name, "t": "d" if e.is_dir() else "f", + "s": 0 if e.is_dir() else st.st_size, "m": int(st.st_mtime)}) + except Exception: + continue + items.sort(key=lambda i: (i["t"] != "d", i["n"].lower())) + self.send_json({"t": "file.list.res", "path": path, "items": items[:2000]}) + except Exception as e: + self.send_json({"t": "file.err", "msg": f"cannot list {path}: {e}"}) + + def file_get(self, path, fid): + def worker(): + try: + sent = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(128 * 1024) + if not chunk: + break + self.send_binary(0xF1.to_bytes(1, "little") + fid.to_bytes(4, "little") + + sent.to_bytes(8, "little") + chunk) + sent += len(chunk) + self.send_json({"t": "file.done", "fid": fid, "path": path, "size": sent}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + threading.Thread(target=worker, daemon=True).start() + + @staticmethod + def _desktop_dir(): + """The console user's real Desktop (handles OneDrive redirection).""" + try: + buf = ctypes.c_wchar_p() + # SHGetKnownFolderPath(FOLDERID_Desktop) + import uuid as _u + fid = (ctypes.c_ubyte * 16).from_buffer_copy( + _u.UUID("B4BFCC3A-DB2C-424C-B029-7FE99A87C641").bytes_le) + if ctypes.windll.shell32.SHGetKnownFolderPath( + ctypes.byref(fid), 0, None, ctypes.byref(buf)) == 0: + p = buf.value + ctypes.windll.ole32.CoTaskMemFree(buf) + if p and os.path.isdir(p): + return p + except Exception: + pass + p = os.path.join(os.path.expanduser("~"), "Desktop") + return p if os.path.isdir(p) else os.path.expanduser("~") + + def file_put_begin(self, path, fid, size): + sess = self.session + if not sess or not self.cfg.data["perms"].get("files", True): + return + try: + # TeamViewer-style: a drop on the screen (relative path, no target + # folder chosen) lands on the remote DESKTOP, never in our CWD. + if not os.path.isabs(path): + rel = os.path.normpath(path).replace("/", "\\") + if rel.startswith("..") or ":" in rel: + rel = os.path.basename(rel) + path = os.path.join(self._desktop_dir(), rel) + if fid in sess.uploads: + sess.uploads[fid].close() + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + fh = open(path + ".netdesk.part", "wb") + fh._nd_final = path + fh._nd_left = size + sess.uploads[fid] = fh + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + def file_simple(self, path, fn): + try: + fn(path) + self.send_json({"t": "file.ok", "msg": "ok", "refresh": True}) + except Exception as e: + self.send_json({"t": "file.err", "msg": str(e)}) + + def file_rename(self, src, dst): + self.file_simple(src, lambda p: os.replace(p, dst)) + + +# ================================================================ capture + encode + +# H.264 encoder selection. Prefer a HARDWARE encoder (near-zero CPU, constant +# low latency regardless of screen complexity) and fall back to software +# libx264, so NetDesk runs smoothly on ANY PC + GPU: +# NVIDIA -> h264_nvenc, Intel/QuickSync -> h264_qsv, AMD -> h264_amf, +# macOS -> videotoolbox, Linux -> vaapi, else -> libx264 (software). +# The winner is probed ONCE (opening a HW encoder with no capable GPU raises) +# and cached; if a driver rejects the tuned options we retry the same encoder +# with bare options before moving on, so quirky hardware still gets accel. +_VIDEO_ENCODER = None # cached ffmpeg encoder name after the first successful probe + + +def _encoder_chain(pref="auto"): + """Ranked encoder candidates for a user preference. "auto" prefers a + DISCRETE-GPU hardware encoder (NVENC/AMF - genuinely lower latency and + offloads the CPU) and otherwise uses software libx264, which on a capable + CPU is actually faster than Intel iGPU QuickSync for live screen content + (measured) - so QSV is only chosen when explicitly requested. Any HW encoder + stays available via "hardware" or a specific name ("qsv"/"nvenc"/"amf").""" + pref = (pref or "auto").lower() + if pref == "software": + return ["libx264"] + if pref in ("nvenc", "qsv", "amf", "mf"): + return [f"h264_{pref}", "libx264"] + if IS_WIN: + if pref == "hardware": + return ["h264_nvenc", "h264_qsv", "h264_amf", "libx264"] + return ["h264_nvenc", "h264_amf", "libx264"] # auto + if IS_MAC: + return ["libx264"] if pref == "software" else ["h264_videotoolbox", "libx264"] + return ["h264_vaapi", "libx264"] + + +def _encoder_opts(name, fps, bitrate_kbps): + """Return (input_pix_fmt, options dict) tuned for low latency. + HW encoders take nv12; libx264 takes yuv420p. `forced_idr`/`forced-idr` + makes pict_type=I produce a real IDR on demand (monitor switch / recovery).""" + maxrate = bitrate_kbps + bufsize = max(bitrate_kbps * 2 // max(fps, 1), 150) # ~250ms VBV + if name == "libx264": + # TCP smoothness: a raw keyframe is a 10-30x burst that stalls the pipe + # for ~an RTT on long links. Cap instantaneous rate with a tight VBV, + # stretch keyint and kill scene-cut bursts; force_idr still gives + # on-demand keyframes for monitor switches / decoder recovery. + return "yuv420p", { + "preset": "ultrafast", "tune": "zerolatency", + "x264opts": (f"vbv-maxrate={maxrate}:vbv-bufsize={bufsize}:" + f"keyint-min={fps * 4}:scenecut=0"), + } + if name == "h264_nvenc": + return "nv12", {"preset": "p1", "tune": "ull", "rc": "cbr", + "forced-idr": "1"} + if name == "h264_qsv": + return "nv12", {"preset": "veryfast", "low_power": "1", + "async_depth": "1", "look_ahead": "0", + "forced_idr": "1"} + if name == "h264_amf": + return "nv12", {"usage": "ultralowlatency", "rc": "cbr", + "quality": "speed", "forced_idr": "1"} + if name == "h264_videotoolbox": + return "nv12", {"realtime": "1"} + if name == "h264_vaapi": + return "nv12", {} + return "yuv420p", {} + + +def make_encoder(w, h, fps, bitrate_kbps, pref="auto"): + """Build the best available H.264 encoder for this machine, honoring the + `video_encoder` preference ("auto"/"software"/"hardware"/name). + Returns (CodecContext, input_pix_fmt).""" + global _VIDEO_ENCODER + order = [] + if _VIDEO_ENCODER: + order.append(_VIDEO_ENCODER) + for n in _encoder_chain(pref): + if n not in order: + order.append(n) + err = None + for name in order: + fmt, opts = _encoder_opts(name, fps, bitrate_kbps) + # tuned options first, then bare options (for quirky HW drivers) + attempts = [opts] if name == "libx264" else [opts, {}] + for att in attempts: + try: + c = av.CodecContext.create(name, "w") + c.width, c.height, c.pix_fmt = w, h, fmt + c.time_base = Fraction(1, 1000) + c.framerate = Fraction(fps, 1) + c.bit_rate = bitrate_kbps * 1000 + c.gop_size = fps * 4 + c.max_b_frames = 0 + if att: + c.options = att + c.open() + if _VIDEO_ENCODER != name: + _VIDEO_ENCODER = name + log.info("video encoder: %s (%s)", name, + "hardware" if name != "libx264" else "software") + return c, fmt + except Exception as e: + err = e + if _VIDEO_ENCODER == name: # cached winner stopped working - re-probe + _VIDEO_ENCODER = None + raise RuntimeError(f"no usable H.264 encoder: {err}") + + +def _enum_monitor_positions(): + """Map each Windows display device name -> (left, top, width, height, + primary) via EnumDisplayMonitors. Used to pair a dxcam output (which knows + its own \\\\.\\DISPLAYn name) with the mss monitor at the same position, + because dxcam's output order does NOT match mss's monitor order.""" + out = {} + if not IS_WIN: + return out + try: + from ctypes import wintypes # noqa + + class _RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + + class _MI(ctypes.Structure): + _fields_ = [("cbSize", ctypes.c_ulong), ("rcMonitor", _RECT), + ("rcWork", _RECT), ("dwFlags", ctypes.c_ulong), + ("szDevice", ctypes.c_wchar * 32)] + + user32 = ctypes.windll.user32 + proc = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, + ctypes.POINTER(_RECT), ctypes.c_double) + + def _cb(hmon, hdc, lprc, data): + mi = _MI() + mi.cbSize = ctypes.sizeof(_MI) + if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)): + r = mi.rcMonitor + out[mi.szDevice] = (r.left, r.top, r.right - r.left, + r.bottom - r.top, bool(mi.dwFlags & 1)) + return 1 + + user32.EnumDisplayMonitors(0, 0, proc(_cb), 0) + except Exception: + pass + return out + + +def _dxcam_output_count(): + try: + return max(1, _dxcam.output_info().count("Output[")) + except Exception: + return 1 + + +def capture_worker(sess: Session): + host = sess.host + w = h = None + enc = None + enc_fmt = "yuv420p" # pixel format the chosen encoder wants (yuv420p / nv12) + last_frame = None + idx = 0 + force_idr = True + last_desktop = None + sct = None + cam = None # dxcam camera (DXGI fast path), or None -> mss + cam_mon = None # the sess.mon the current camera was built for + cam_first = None # the camera's priming frame, sent as the first paint + last_cur_h = None + last_cur_pos = None + cur_mon_seen = None # detect monitor switches to force a keyframe + enc_pref = host.cfg.data.get("video_encoder", "auto") + use_dx = _DX_AVAILABLE and str( + host.cfg.data.get("capture_backend", "auto")).lower() != "mss" + log.info("capture worker started (dxgi=%s encoder=%s)", use_dx, enc_pref) + + def release_cam(): + nonlocal cam, cam_mon, cam_first + try: + if cam is not None: + cam.release() + except Exception: + pass + cam = None + cam_mon = None + cam_first = None + + def ensure_cam(mon_index, mon_geo): + """(Re)create the dxcam camera for the mss monitor at mon_geo, mapping by + device position (dxcam output order != mss order). Returns True when the + DXGI fast path is live, False to use mss. Any failure -> mss.""" + nonlocal cam, cam_mon, cam_first + if not use_dx or mon_index < 1: + release_cam() + return False + if cam is not None and cam_mon == mon_index: + return True + release_cam() + target = (mon_geo["left"], mon_geo["top"]) + names = _enum_monitor_positions() + target_name = next((nm for nm, g in names.items() + if (g[0], g[1]) == target), None) + for idx_o in range(_dxcam_output_count()): + try: + c = _dxcam.create(output_idx=idx_o, output_color="BGRA") + except Exception: + continue + if c is None: + continue + dn = getattr(getattr(c, "_output", None), "devicename", "") + if target_name is not None and dn != target_name: + try: c.release() + except Exception: pass + continue + probe = c.grab() + for _ in range(4): + if probe is not None: + break + time.sleep(0.01) + probe = c.grab() + if (probe is not None and probe.shape[1] == mon_geo["width"] + and probe.shape[0] == mon_geo["height"]): + cam = c + cam_mon = mon_index + cam_first = probe + log.info("dxcam output %d (%s) -> monitor %d (%dx%d)", + idx_o, dn, mon_index, probe.shape[1], probe.shape[0]) + return True + try: c.release() + except Exception: pass + log.info("dxcam unavailable for monitor %d; using mss", mon_index) + return False + + def update_cursor(mon_geo, scale): + nonlocal last_cur_h, last_cur_pos + if not IS_WIN: + return + try: + cs = cursor_state() + if cs is None: + return + hcur, gx, gy = cs + cx = int((gx - mon_geo["left"]) * scale) + cy = int((gy - mon_geo["top"]) * scale) + if hcur != last_cur_h: + last_cur_h = hcur + if hcur == 0: + host.send_json({"t": "cursor", "hidden": True}) + else: + shp = cursor_png(hcur) + css = _STD_CURSORS.get(hcur) + if shp or css: + msg = {"t": "cursor.shape", "id": hcur} + if shp: + msg.update(shp) + if css: + msg["css"] = css + host.send_json(msg) + if hcur != 0 and (cx, cy) != last_cur_pos: + last_cur_pos = (cx, cy) + host.send_json({"t": "cursor", "id": hcur, "x": cx, "y": cy}) + except Exception: + pass + + def rebuild(): + nonlocal sct, enc, last_frame, w, h, force_idr + try: + if sct: + sct.close() + except Exception: + pass + sct = mss.mss() + release_cam() + try: + if enc is not None: + enc.close() + except Exception: + pass + enc = None + last_frame = None + w = h = None + force_idr = True + + try: + while sess.active and not sess.stop_evt.is_set(): + t0 = time.time() + # follow the active desktop (logon screen / UAC switches) + name = attach_desktop() + if name != last_desktop: + log.info("input desktop switched to %s", name) + last_desktop = name + rebuild() + host.send_json({"t": "session.info", "info": { + "mon": sess.mon, "monitors": sess.monitors, + "w": w or 0, "h": h or 0, "fps": sess.fps, "bitrate": sess.bitrate, + "logon_screen": bool(name and name.lower().startswith("winlogon")), + "host_name": host.cfg.data["name"], + "e2e": sess.e2e is not None, "audio": host.audio_available, + "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), + "privacy_active": host.privacy}}) + if not name: + time.sleep(0.25) + continue + + mon = sct.monitors[min(sess.mon, len(sct.monitors) - 1)] + # A monitor switch (even between same-size displays) invalidates the + # encoder's reference frames - force a keyframe so the new screen is + # not predicted from the old one. + if cur_mon_seen is not None and cur_mon_seen != sess.mon: + force_idr = True + last_frame = None + cur_mon_seen = sess.mon + ow, oh = mon["width"] & ~1, mon["height"] & ~1 + scale = max(0.25, min(sess.scale, 1.0)) + tw = max(2, int(ow * scale) & ~1) + th = max(2, int(oh * scale) & ~1) + if enc is None or (w, h) != (tw, th) or sess.apply_evt.is_set(): + sess.apply_evt.clear() + if (w, h) != (tw, th): + w, h = tw, th + force_idr = True + last_frame = None + if enc is not None: + try: enc.close() + except Exception: pass + enc, enc_fmt = make_encoder(w, h, sess.fps, sess.bitrate, enc_pref) + host.send_json({"t": "session.info", "info": { + "mon": sess.mon, "monitors": sess.monitors, "w": w, "h": h, + "fps": sess.fps, "bitrate": sess.bitrate, + "logon_screen": str(last_desktop or "").lower().startswith("winlogon"), + "host_name": host.cfg.data["name"], "e2e": sess.e2e is not None, + "audio": host.audio_available, "perms": host.cfg.data["perms"], "lock_on_end": host.cfg.data.get("lock_on_end", False), + "privacy_active": host.privacy}}) + need_kf = bool(force_idr or getattr(sess, "force_idr", False)) + dx_active = ensure_cam(sess.mon, mon) + if dx_active: + # DXGI Desktop Duplication fast path (~4ms vs ~45ms for mss). + if cam_first is not None: + full, cam_first = cam_first, None # priming frame + else: + try: + full = cam.grab() # None == unchanged + except Exception as e: + log.debug("dxcam grab failed (%s); using mss", e) + release_cam() + continue + if full is None: + # Screen unchanged: keep the cursor live; only re-encode the + # last frame when a keyframe is actually due. + update_cursor(mon, scale) + if not need_kf or last_frame is None: + time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) + continue + arr = last_frame + else: + arr = full[:oh, :ow, :] + last_frame = arr + update_cursor(mon, scale) + else: + # Fallback: mss GDI capture + full-frame change detection. + try: + img = sct.grab(mon) + except Exception as e: + log.debug("grab failed (%s)", e) + time.sleep(0.3) + rebuild() + continue + arr = np.frombuffer(img.raw, dtype=np.uint8).reshape( + img.size.height, img.size.width, 4) + arr = arr[:oh, :ow, :] + update_cursor(mon, scale) + unchanged = (last_frame is not None and arr.shape == last_frame.shape + and np.array_equal(arr, last_frame) and not need_kf) + if unchanged: + time.sleep(max(0.0, 1.0 / sess.fps - (time.time() - t0))) + continue + last_frame = arr + frame = av.VideoFrame.from_ndarray(arr, format="bgra") + frame = frame.reformat(width=w, height=h, format=enc_fmt) + frame.pts = idx * 1000 // max(1, sess.fps) + frame.time_base = Fraction(1, 1000) + idx += 1 + if force_idr or getattr(sess, "force_idr", False): + try: + frame.pict_type = av.video.frame.PictureType.I + except Exception: + pass + sess.force_idr = False + force_idr = False + for pkt in enc.encode(frame): + host.send_binary(b"\x01" + bytes([1 if pkt.is_keyframe else 0]) + bytes(pkt)) + delay = 1.0 / sess.fps - (time.time() - t0) + if delay > 0: + time.sleep(delay) + except Exception as e: + log.exception("capture worker crashed: %s", e) + finally: + release_cam() + try: + if enc is not None: + enc.close() + except Exception: + pass + try: + if sct is not None: + sct.close() + except Exception: + pass + log.info("capture worker stopped") + + +# ================================================================ audio / clipboard workers + +def audio_worker(sess: Session): + host = sess.host + try: + import pyaudiowpatch as pyaudio + import numpy as _np + except Exception: + return + ctx = None + stream = None + p = None + try: + p = pyaudio.PyAudio() + try: + dev = p.get_default_wasapi_loopback() + except Exception: + return + stream = p.open(format=pyaudio.paInt16, channels=2, rate=48000, input=True, + frames_per_buffer=960, input_device_index=dev["index"]) + ctx = av.CodecContext.create("libopus", "w") + ctx.sample_rate = 48000 + ctx.layout = "stereo" + ctx.format = "s16" + ctx.bit_rate = 96000 + ctx.open() + log.info("audio capture running") + while sess.active and not sess.stop_evt.is_set(): + data = stream.read(960, exception_on_overflow=False) + samples = _np.frombuffer(data, dtype=_np.int16) + if not samples.any(): + continue + frame = av.AudioFrame.from_ndarray(samples.reshape(1, -1), format="s16", layout="stereo") + frame.sample_rate = 48000 + frame.pts = None + for pkt in ctx.encode(frame): + host.send_binary(b"\xa1" + bytes(pkt)) + except Exception as e: + log.info("audio disabled: %s", e) + finally: + try: + if stream is not None: + stream.stop_stream() + stream.close() + except Exception: + pass + try: + if p is not None: + p.terminate() + except Exception: + pass + try: + if ctx is not None: + ctx.close() + except Exception: + pass + + +def clipboard_worker(host: Host): + while True: + time.sleep(0.5) + sess = host.session + if not sess or not sess.active: + continue + if not host.cfg.data["perms"].get("clipboard", True): + continue + txt = clip_get() + if txt is not None and txt != host.clip_last_sent: + host.clip_last_sent = txt + host.send_json({"t": "clip.set", "text": txt[:1024 * 1024], "origin": "host"}) + + +# ================================================================ transports (host side) + +from contextlib import asynccontextmanager + +@asynccontextmanager +async def ws_connect_tolerant(http, url, **kw): + """Async-CM ws_connect that falls back to unverified TLS for self-signed + certificates. Acceptable for personal deployments with a self-signed cert; + use a real certificate in production.""" + try: + async with http.ws_connect(url, **kw) as ws: + yield ws + except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError) as e: + log.warning("self-signed certificate on %s - accepting (INSECURE)", url) + async with http.ws_connect(url, ssl=False, **kw) as ws: + yield ws + + +# ---------------- display modes / wallpaper / SAS (TeamViewer-style View & Actions menus) +class _DEVMODEW(ctypes.Structure): + _fields_ = [ + ("dmDeviceName", ctypes.c_wchar * 32), + ("dmSpecVersion", ctypes.c_ushort), ("dmDriverVersion", ctypes.c_ushort), + ("dmSize", ctypes.c_ushort), ("dmDriverExtra", ctypes.c_ushort), + ("dmFields", ctypes.c_ulong), + ("dmPositionX", ctypes.c_long), ("dmPositionY", ctypes.c_long), + ("dmDisplayOrientation", ctypes.c_ulong), ("dmDisplayFixedOutput", ctypes.c_ulong), + ("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short), + ("dmYResolution", ctypes.c_short), ("dmTTOption", ctypes.c_short), + ("dmCollate", ctypes.c_short), + ("dmFormName", ctypes.c_wchar * 32), + ("dmLogPixels", ctypes.c_ushort), + ("dmBitsPerPel", ctypes.c_ulong), + ("dmPelsWidth", ctypes.c_ulong), ("dmPelsHeight", ctypes.c_ulong), + ("dmDisplayFlags", ctypes.c_ulong), ("dmDisplayFrequency", ctypes.c_ulong), + ("dmICMMethod", ctypes.c_ulong), ("dmICMIntent", ctypes.c_ulong), + ("dmMediaType", ctypes.c_ulong), ("dmDitherType", ctypes.c_ulong), + ("dmReserved1", ctypes.c_ulong), ("dmReserved2", ctypes.c_ulong), + ("dmPanningWidth", ctypes.c_ulong), ("dmPanningHeight", ctypes.c_ulong), + ] + + +class _DISPLAY_DEVICEW(ctypes.Structure): + _fields_ = [("cb", ctypes.c_ulong), ("DeviceName", ctypes.c_wchar * 32), + ("DeviceString", ctypes.c_wchar * 128), ("StateFlags", ctypes.c_ulong), + ("DeviceID", ctypes.c_wchar * 128), ("DeviceKey", ctypes.c_wchar * 128)] + + +def _display_devices(): + """Attached display devices with their current desktop position + size.""" + u = ctypes.windll.user32 + out, i = [], 0 + while True: + dd = _DISPLAY_DEVICEW() + dd.cb = ctypes.sizeof(dd) + if not u.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): + break + i += 1 + if not (dd.StateFlags & 0x1): # DISPLAY_DEVICE_ATTACHED_TO_DESKTOP + continue + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if u.EnumDisplaySettingsW(dd.DeviceName, -1, ctypes.byref(dm)): # current + out.append({"name": dd.DeviceName, "x": dm.dmPositionX, "y": dm.dmPositionY, + "w": int(dm.dmPelsWidth), "h": int(dm.dmPelsHeight)}) + return out + + +def _device_for_monitor(mon_geo): + """Match an mss monitor dict (left/top) to its GDI display device.""" + devs = _display_devices() + for d in devs: + if d["x"] == mon_geo.get("left") and d["y"] == mon_geo.get("top"): + return d + return devs[0] if devs else None + + +def display_modes(device_name): + """All (w, h) modes the device supports, largest first.""" + u = ctypes.windll.user32 + modes, i = set(), 0 + while True: + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if not u.EnumDisplaySettingsW(device_name, i, ctypes.byref(dm)): + break + i += 1 + if dm.dmPelsWidth >= 800 and dm.dmPelsHeight >= 600: + modes.add((int(dm.dmPelsWidth), int(dm.dmPelsHeight))) + return sorted(modes, reverse=True) + + +def set_display_mode(device_name, w, h): + """Temporarily change the display resolution (not persisted to registry).""" + u = ctypes.windll.user32 + dm = _DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if not u.EnumDisplaySettingsW(device_name, -1, ctypes.byref(dm)): + return False, "cannot read current mode" + dm.dmPelsWidth, dm.dmPelsHeight = int(w), int(h) + dm.dmFields = 0x80000 | 0x100000 # DM_PELSWIDTH | DM_PELSHEIGHT + r = u.ChangeDisplaySettingsExW(device_name, ctypes.byref(dm), None, 0, None) + return (r == 0), ("resolution changed" if r == 0 + else f"display rejected the mode (code {r})") + + +def wallpaper_get(): + buf = ctypes.create_unicode_buffer(520) + ctypes.windll.user32.SystemParametersInfoW(0x73, 512, buf, 0) # SPI_GETDESKWALLPAPER + return buf.value + + +def wallpaper_set(path): + # SPI_SETDESKWALLPAPER with SPIF_UPDATEINIFILE | SPIF_SENDCHANGE + return bool(ctypes.windll.user32.SystemParametersInfoW(0x14, 0, path or "", 3)) + + +def send_cad(): + """Secure Attention Sequence (Ctrl+Alt+Del). Only takes effect when the + agent runs as SYSTEM (boot-time service) and Windows SAS policy allows it.""" + try: + sas = ctypes.WinDLL("sas.dll") + sas.SendSAS(0) # as service + sas.SendSAS(1) # as user (whichever the policy permits) + return True, "Ctrl+Alt+Del sent (takes effect when SAS policy allows it)" + except Exception as e: + return False, f"Ctrl+Alt+Del unavailable: {str(e)[:120]}" + + +def _mac_str() -> str: + """Primary NIC MAC for Wake-on-LAN (empty if only a random/virtual one).""" + try: + n = uuid.getnode() + if (n >> 40) & 0x02: # locally-administered bit: random, not a real NIC + return "" + return ":".join(f"{(n >> s) & 0xFF:02x}" for s in range(40, -1, -8)) + except Exception: + return "" + + +def send_wol(mac: str): + """Broadcast a Wake-on-LAN magic packet on the local network.""" + import socket as _sk + clean = "".join(ch for ch in mac if ch in "0123456789abcdefABCDEF") + if len(clean) != 12: + return + payload = b"\xff" * 6 + bytes.fromhex(clean) * 16 + try: + s = _sk.socket(_sk.AF_INET, _sk.SOCK_DGRAM) + s.setsockopt(_sk.SOL_SOCKET, _sk.SO_BROADCAST, 1) + for port in (9, 7): + s.sendto(payload, ("255.255.255.255", port)) + s.close() + log.info("WoL magic packet broadcast for %s", mac) + except Exception as e: + log.warning("WoL send failed: %s", e) + + +def ip_allowed(ip: str, allow_csv: str) -> bool: + """True if ip matches any entry (IP or CIDR) in the allowlist string.""" + import ipaddress + try: + a = ipaddress.ip_address(ip) + except ValueError: + return True # unparseable/missing peer info: never lock the owner out + for part in allow_csv.replace(",", " ").split(): + try: + if a in ipaddress.ip_network(part, strict=False): + return True + except ValueError: + continue + return False + + +def reg_message(cfg: Config, args, takeover=False) -> dict: + return {"t": "reg.host", "id": cfg.id, + "key": getattr(args, "reg_key", None) or cfg.data.get("reg_key", ""), + "name": cfg.data.get("name", "host"), "ver": VERSION, + # desktop-app account token: binds this computer to "My computers" + "token": cfg.data.get("account_token", ""), + # MAC lets another computer on the account wake this one (WoL) + "mac": _mac_str(), + # on our first registration only, take over any idle stuck engine + # holding this ID (never displaces an active session server-side) + "takeover": bool(takeover)} + + +def write_status(cfg: Config, state: str, server: str, in_session=False, account=""): + """Heartbeat file the desktop app reads (written by engine and broker). + + A stale duplicate engine (rejected with "already connected elsewhere") + must never stomp the status of the engine that actually holds the ID - + that ping-pong made the app flicker Online/Stopped every few seconds.""" + try: + if state != "online": + try: + d = json.loads((cfg.path.parent / "gui_status.json").read_text("utf-8")) + if (d.get("state") == "online" and d.get("pid") != os.getpid() + and time.time() - d.get("ts", 0) < 8): + return # another live engine of ours is online - keep its status + except Exception: + pass + (cfg.path.parent / "gui_status.json").write_text(json.dumps({ + "state": state, "id": cfg.id, "name": cfg.data.get("name", ""), + "server": server, "in_session": bool(in_session), "account": account, + "ts": int(time.time()), "pid": os.getpid()}), "utf-8") + except Exception: + pass + + +async def pump_socket_to_host(host: Host, ws, label): + """Feed one inbound websocket into the host (used by simple/direct/agent modes).""" + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") == "host_status": + if not m.get("ok"): + log.error("server rejected registration: %s", m.get("msg")) + host.reg_error = m.get("msg") or "rejected" + else: + host.reg_error = "" + host.account = m.get("account", "") + continue + host.on_message(m) + elif msg.type == WSMsgType.BINARY: + host.on_binary(msg.data) + elif msg.type == WSMsgType.ERROR: + break + + +async def run_simple_mode(host: Host, args): + """Original mode: this process talks to the server itself + optional direct listener.""" + async def direct_listen(): + if not args.listen: + return + + async def handler(req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + pending_sid = None + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + t = m.get("t") + if t == "session.begin": + if host.session and host.session.active: + await ws.send_json({"t": "session.deny", "sid": None, "reason": "host busy"}) + continue + pending_sid = uuid.uuid4().hex + host.link = "direct" + host.ws_direct = ws + host.on_message({"t": "session.begin", "sid": pending_sid, + "peer": req.remote or ""}) + else: + if pending_sid and not m.get("sid"): + m["sid"] = pending_sid + host.on_message(m) + elif msg.type == WSMsgType.BINARY: + host.on_binary(msg.data) + finally: + if host.link == "direct": + host.end_session("direct viewer left") + host.link = "server" + host.ws_direct = None + return ws + + app = web.Application() + app.router.add_get("/ws", handler) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + await web.TCPSite(runner, "0.0.0.0", args.listen).start() + log.info("direct listener on port %d", args.listen) + + if args.listen: + await direct_listen() + + url = args.server or host.cfg.data.get("server", "") or DEFAULT_SERVER + if not url: + log.info("no --server given; direct mode only") + return + + async def gui_status_writer(): + """Heartbeat for the desktop GUI (gui_status.json next to the config).""" + while True: + online = host.ws_server is not None and not host.ws_server.closed + err = getattr(host, "reg_error", "") + state = ("error: " + err) if err else ("online" if online else "connecting") + write_status(host.cfg, state, url, bool(host.session), getattr(host, "account", "")) + await asyncio.sleep(3) + + status_task = asyncio.ensure_future(gui_status_writer()) + host._status_task = status_task + backoff = 1 + while True: + try: + timeout = aiohttp.ClientTimeout(total=None, connect=15) + async with aiohttp.ClientSession(timeout=timeout) as http: + async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: + host.ws_server = ws + host.link = "server" + backoff = 1 + host.cfg.reload() + # take over on our first attempt, AND whenever the last try + # was bounced because a STALE idle engine still holds the ID + # (server never lets takeover kill an active session, so this + # only ever reclaims a dead/ghost holder - the real fix for + # "reconnect says the ID is already connected elsewhere") + prev_err = (getattr(host, "reg_error", "") or "").lower() + want_takeover = (not getattr(host, "_did_register", False) + or "already connected" in prev_err) + host._did_register = True + await ws.send_json(reg_message(host.cfg, args, takeover=want_takeover)) + log.info("connected to server %s as ID %s (takeover=%s)", + url, host.cfg.id, want_takeover) + await pump_socket_to_host(host, ws, "server") + except Exception as e: + log.warning("server link lost (%s); retrying in %ss", e, backoff) + host.ws_server = None + if host.session and host.link == "server": + host.end_session("server link lost") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5) + + +# ================================================================ service broker + +class Broker: + """Boot-time SYSTEM service core: holds the server link, spawns a desktop + agent into the console session, and relays traffic. Runs in session 0.""" + + def __init__(self, cfg: Config, args): + self.cfg = cfg + self.args = args + self.token = secrets.token_hex(16) + self.port = 0 + self.agent_ws = None + self.agent_proc = None # (handle|None, pid, session) + self.server_ws = None + self.direct_ws = None # direct viewer socket + self.direct_sid = None + self.session_origin = "server" + self.reg_error = "" + self.account = "" + + # ---------- spawn / supervise agent ---------- + def agent_cmdline(self): + if getattr(sys, "frozen", False): + prog = f'"{sys.executable}"' + else: + prog = f'"{sys.executable}" "{os.path.abspath(__file__)}"' + return (f'{prog} --session-agent --config "{self.cfg.path.parent}" ' + f'--broker-url ws://127.0.0.1:{self.port}/agent --token {self.token}') + + def spawn_agent(self, session_id): + cmd = self.agent_cmdline() + try: + handle, pid = spawn_in_console_session(cmd, session_id) + log.info("spawned SYSTEM agent pid=%s session=%s", pid, session_id) + return (handle, pid, None) + except Exception as e: + log.warning("SYSTEM spawn failed (%s); spawning plain child", e) + proc = subprocess.Popen(cmd, creationflags=0x00000008 | 0x00000200) # DETACHED|NEW_GROUP + return (None, proc.pid, proc) + + def kill_agent(self): + if self.agent_ws is not None: + self.kill_agent_ws_only() + info = self.agent_proc + if info: + handle, pid, popen = info + if popen is not None: + popen.kill() + elif handle: + kernel32.TerminateProcess(handle, 1) + kernel32.CloseHandle(handle) + else: + subprocess.run(["taskkill", "/F", "/PID", str(pid)], + capture_output=True, creationflags=0x08000000) + self.agent_proc = None + + async def lifecycle(self): + while True: + try: + con = console_session_id() + info = self.agent_proc + alive = False + if info: + handle, pid, popen = info + if popen is not None: + alive = popen.poll() is None + elif handle: + alive = kernel32.WaitForSingleObject(handle, 0) == 0x102 # STILL_ACTIVE + if alive: + sid = DWORD(0) + if kernel32.ProcessIdToSessionId(pid, ctypes.byref(sid)) and sid.value != con: + alive = False # console session changed (user switch) + if not alive: + if info: + log.info("respawning agent (old pid=%s)", info[1]) + self.kill_agent() + self.agent_proc = self.spawn_agent(con) + except Exception as e: + log.debug("lifecycle: %s", e) + await asyncio.sleep(2) + + # ---------- sockets ---------- + async def server_link(self): + url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER + if not url: + log.warning("broker: no server URL configured (--server)") + return + backoff = 1 + while True: + try: + timeout = aiohttp.ClientTimeout(total=None, connect=15) + async with aiohttp.ClientSession(timeout=timeout) as http: + async with ws_connect_tolerant(http, url, heartbeat=20, max_msg_size=64 * 1024 * 1024) as ws: + self.server_ws = ws + backoff = 1 + self.cfg.reload() + prev_err = (getattr(self, "reg_error", "") or "").lower() + want_takeover = (not getattr(self, "_did_register", False) + or "already connected" in prev_err) + self._did_register = True + await ws.send_json(reg_message(self.cfg, self.args, takeover=want_takeover)) + log.info("broker connected to %s as ID %s (takeover=%s)", + url, self.cfg.id, want_takeover) + async for msg in ws: + if msg.type == WSMsgType.TEXT: + m = json.loads(msg.data) if msg.data.startswith("{") else {} + if m.get("t") == "host_status": + if not m.get("ok"): + log.error("registration rejected: %s", m.get("msg")) + self.reg_error = m.get("msg") or "rejected" + else: + self.reg_error = "" + self.account = m.get("account", "") + continue + if m.get("t") == "session.begin" and self.agent_ws is None: + await ws.send_json({"t": "session.deny", "sid": m.get("sid"), + "reason": "host starting, try again shortly"}) + continue + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_bytes(msg.data) + except Exception as e: + log.warning("broker server link lost (%s); retry in %ss", e, backoff) + self.server_ws = None + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 5) + + def route_from_agent(self, data: str): + """Agent output goes to whoever started the session (direct client or server).""" + if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: + asyncio.ensure_future(self.direct_ws.send_str(data)) + elif self.server_ws and not self.server_ws.closed: + asyncio.ensure_future(self.server_ws.send_str(data)) + + async def agent_handler(self, req): + if req.query.get("token") != self.token: + return web.Response(status=403, text="bad token") + ws = web.WebSocketResponse(heartbeat=15.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + self.kill_agent_ws_only() + self.agent_ws = ws + log.info("desktop agent attached (session %s)", my_session_id()) + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + self.route_from_agent(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.session_origin == "direct" and self.direct_ws and not self.direct_ws.closed: + asyncio.ensure_future(self.direct_ws.send_bytes(msg.data)) + elif self.server_ws and not self.server_ws.closed: + asyncio.ensure_future(self.server_ws.send_bytes(msg.data)) + finally: + if self.agent_ws is ws: + self.agent_ws = None + log.info("desktop agent detached") + return ws + + def kill_agent_ws_only(self): + if self.agent_ws is not None: + try: + asyncio.ensure_future(self.agent_ws.close()) + except Exception: + pass + self.agent_ws = None + + async def direct_handler(self, req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + old = self.direct_ws + if old is not None and not old.closed: + try: await old.close() + except Exception: pass + self.direct_ws = ws + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + m = json.loads(msg.data) if msg.data.startswith("{") else {} + if m.get("t") == "session.begin": + self.direct_sid = uuid.uuid4().hex + m["sid"] = self.direct_sid + self.session_origin = "direct" + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(json.dumps(m)) + continue + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + if self.agent_ws and not self.agent_ws.closed: + await self.agent_ws.send_bytes(msg.data) + finally: + if self.direct_ws is ws: + self.direct_ws = None + self.session_origin = "server" + return ws + + async def run(self): + # agent socket + app = web.Application() + app.router.add_get("/agent", self.agent_handler) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + self.port = site._server.sockets[0].getsockname()[1] + (self.cfg.path.parent / "broker.json").write_text( + json.dumps({"port": self.port, "token": self.token}), "utf-8") + log.info("broker socket on 127.0.0.1:%d", self.port) + + # optional direct listener + if self.args.listen: + dapp = web.Application() + dapp.router.add_get("/ws", self.direct_handler) + drunner = web.AppRunner(dapp, access_log=None) + await drunner.setup() + await web.TCPSite(drunner, "0.0.0.0", self.args.listen).start() + log.info("direct listener on port %d", self.args.listen) + + async def status(): + url = self.args.server or self.cfg.data.get("server", "") or DEFAULT_SERVER + while True: + up = self.server_ws is not None and not self.server_ws.closed + err = getattr(self, "reg_error", "") + state = ("error: " + err) if err else ("online" if up else "connecting") + write_status(self.cfg, state, url, False, getattr(self, "account", "")) + await asyncio.sleep(3) + + await asyncio.gather(self.server_link(), self.lifecycle(), status()) + + +def kill_other_engines(): + """Windows: make sure only THIS engine instance runs (frozen exe).""" + if not IS_WIN or not getattr(sys, "frozen", False): + return + me = os.getpid() + subprocess.run( + ["powershell", "-NoProfile", "-Command", + f"Get-CimInstance Win32_Process -Filter \"Name='{os.path.basename(sys.executable)}'\" | " + f"Where-Object {{ $_.ProcessId -ne {me} -and $_.CommandLine -like '*--engine*' }} | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=CREATE_NO_WINDOW) + + +async def run_agent_mode(args): + """Desktop worker spawned by the broker (runs inside the console session).""" + cfg = Config(Path(args.config) / "config.json" if args.config else appdata_dir() / "config.json") + host = Host(cfg, args, link="broker") + host.loop = asyncio.get_running_loop() + kill_other_engines() + backoff = 1 + timeout = aiohttp.ClientTimeout(total=None, connect=5, sock_read=30) + while True: + try: + async with aiohttp.ClientSession(timeout=timeout) as http: + async with http.ws_connect(f"{args.broker_url}?token={args.token}", + heartbeat=30, max_msg_size=64 * 1024 * 1024) as ws: + host.ws_broker = ws + backoff = 1 + log.info("agent linked to broker") + await pump_socket_to_host(host, ws, "broker") + except Exception as e: + log.warning("agent/broker link error: %s", e) + host.ws_broker = None + if host.session: + host.end_session("broker link lost") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 10) + # exit if we are no longer in the console session (the broker respawns us) + if IS_WIN and my_session_id() != console_session_id(): + log.info("session changed; agent exiting") + return + + +# ================================================================ install / uninstall + +SERVICE_TASK = "NetDesk" + + +def install_service(args): + if not IS_WIN: + print("service install is Windows-only") + return + if not is_elevated(): + print("Please run --install from an elevated (Administrator) prompt once.") + return + cfg_dir = appdata_dir() # the portable folder (or APPDATA) the APP writes to + cfg = Config(cfg_dir / "config.json") + # Point the SYSTEM broker at the SAME config file the app writes, so any + # password / 2FA / settings change made in the app applies to the boot + # service automatically (cfg.reload() at each connection) - no re-install, + # no extra elevation. Also mirror it into ProgramData as a fallback the + # service can always read even if the portable folder becomes unavailable. + pd = programdata_dir() + pd.mkdir(parents=True, exist_ok=True) + (pd / "config.json").write_text(json.dumps(cfg.data, indent=2), "utf-8") + svc_cfg = cfg_dir if os.access(cfg_dir, os.W_OK) else pd + if getattr(sys, "frozen", False): + # packaged exe: run it directly with --broker (no python, no script path) + tr = f'"{sys.executable}" --broker --config "{svc_cfg}"' + else: + py = Path(sys.executable).with_name("pythonw.exe") + if not py.exists(): + py = Path(sys.executable) + tr = f'"{py}" "{os.path.abspath(__file__)}" --broker --config "{svc_cfg}"' + server = args.server or cfg.data.get("server", "") + if server: + tr += f' --server "{server}"' + reg = args.reg_key or cfg.data.get("reg_key", "") + if reg: + tr += f' --reg-key "{reg}"' + if args.listen: + tr += f" --listen {args.listen}" + r = subprocess.run(["schtasks", "/Create", "/TN", SERVICE_TASK, "/SC", "ONSTART", + "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", tr, "/F"], + capture_output=True, text=True) + result_path = programdata_dir() / "install_result.json" + if r.returncode != 0: + result_path.write_text(json.dumps( + {"ok": False, "error": (r.stderr or r.stdout or "schtasks failed").strip()}), "utf-8") + print("schtasks failed:", r.stderr or r.stdout) + return + # handover: stop only the user-run ENGINE so the service owns the connection. + # Do NOT blanket-kill NetDeskHost.exe - that also kills the GUI (which is + # polling for success) and this very installer, so the install looked like it + # "never finished" and the toggle stayed off. + me = os.getpid() + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { " + "($_.Name -eq 'NetDeskHost.exe' -or $_.CommandLine -like '*netdesk_host.py*') " + f"-and $_.CommandLine -like '*--engine*' -and $_.ProcessId -ne {me} }} | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=0x08000000) + subprocess.run(["schtasks", "/Run", "/TN", SERVICE_TASK], capture_output=True) + result_path.write_text(json.dumps({"ok": True, "id": cfg.id}), "utf-8") + print("=" * 62) + print(" NetDesk service installed and started (boot-time, SYSTEM).") + print(f" Host ID : {cfg.id} (service reads config from {svc_cfg})") + print(" Reachable at the logon screen, across reboots, no auto-logon.") + print(f" Remove: {Path(__file__).name} --uninstall") + print("=" * 62) + + +def uninstall_service(): + if not IS_WIN: + return + subprocess.run(["schtasks", "/End", "/TN", SERVICE_TASK], capture_output=True) + r = subprocess.run(["schtasks", "/Delete", "/TN", SERVICE_TASK, "/F"], + capture_output=True, text=True) + if r.returncode != 0: + print("schtasks delete failed:", r.stderr or r.stdout) + return + # stop the broker/agent processes this service started (targeted, by command line) + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--broker*' " + "-or $_.CommandLine -like '*--session-agent*' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, creationflags=0x08000000) + print("NetDesk service removed.") + + +# ================================================================ main + +def main(): + # frozen/CLI behavior: double-click (no args) opens the desktop app, + # explicit flags keep the classic engine/service CLI. + gui_wanted = ("--gui" in sys.argv) or ( + len(sys.argv) == 1 and getattr(sys, "frozen", False)) + if "--gui-selftest" in sys.argv: + import netdesk_gui + netdesk_gui.selftest() + return + if gui_wanted: + try: + import netdesk_gui + except Exception as e: + print("GUI unavailable (%s); engine CLI only." % e) + import traceback; traceback.print_exc() + return + netdesk_gui.run() + return + + ap = argparse.ArgumentParser(description="NetDesk host agent") + ap.add_argument("--server", help="NetDesk server ws(s):// URL") + ap.add_argument("--reg-key", help="server registration key") + ap.add_argument("--id", help="override host ID (9 digits)") + ap.add_argument("--set-password", dest="newpw", help="set a new permanent password") + ap.add_argument("--set-totp", dest="newtotp", nargs="?", const="auto", + help="enable TOTP 2FA (optional secret, base32; 'auto' generates one)") + ap.add_argument("--name", help="friendly name shown to viewers") + ap.add_argument("--listen", type=int, metavar="PORT", help="direct-access listener port") + ap.add_argument("--no-audio", action="store_true") + ap.add_argument("--config", help="config directory (service mode)") + ap.add_argument("--broker", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--broker-url", dest="broker_url", help=argparse.SUPPRESS) + ap.add_argument("--session-agent", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--token", help=argparse.SUPPRESS) + ap.add_argument("--engine", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--gui", action="store_true", help="open the desktop control panel") + ap.add_argument("--install", action="store_true", help="install boot-time service (admin once)") + ap.add_argument("--uninstall", action="store_true", help="remove the service") + ap.add_argument("--verbose", action="store_true") + args = ap.parse_args() + + global _CONFIG_DIR + if args.config: + _CONFIG_DIR = Path(args.config) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + handlers=[logging.StreamHandler(), + logging.FileHandler(appdata_dir() / "host.log", encoding="utf-8")], + ) + + if args.install: + install_service(args) + return + if args.uninstall: + uninstall_service() + return + + cfg = Config(appdata_dir() / "config.json") + if args.id and args.id.isdigit(): + cfg.data["id"] = args.id + if args.name: + cfg.data["name"] = args.name + if args.server: + cfg.data["server"] = args.server + cfg.save() + + if args.newpw: + cfg.set_password(args.newpw) + print("[netdesk] permanent password updated.") + return + if args.newtotp is not None: + if args.newtotp == "auto": + secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32)) + else: + secret = args.newtotp + cfg.set_totp(secret) + print("[netdesk] TOTP 2FA ENABLED.") + print(f" Secret (base32, add to your authenticator): {secret}") + print(" Viewer will ask for the 6-digit code at connect.") + return + + if args.broker: + if not IS_WIN: + print("[netdesk] the session broker is Windows-only; on macOS use a LaunchAgent (see scripts/deploy)") + return + broker = Broker(cfg, args) + print(f"[netdesk] broker starting (ID {cfg.id}, session {my_session_id()})") + try: + asyncio.run(broker.run()) + except KeyboardInterrupt: + pass + return + if args.session_agent: + if not args.broker_url or not args.token: + print("--session-agent needs --broker-url and --token") + return + print(f"[netdesk] desktop agent starting (ID {cfg.id}, session {my_session_id()})") + try: + asyncio.run(run_agent_mode(args)) + except KeyboardInterrupt: + pass + return + + if not args.engine: + print("=" * 60) + print(f" NetDesk host v{VERSION}") + print(f" Host ID : {cfg.id}") + print(f" Name : {cfg.data['name']}") + print(f" Server : {args.server or cfg.data.get('server') or '(none - direct mode only)'}") + print(f" Config : {cfg.path}") + if cfg.data.get("totp"): + print(" 2FA : TOTP enabled") + if cfg._new_password and not args.engine: + print(f" NEW PASSWORD (save it): {cfg._new_password}") + if not args.engine: + print("[netdesk] running - press Ctrl+C to stop") + print("=" * 60) + + host = Host(cfg, args, link="server") + + async def amain(): + host.loop = asyncio.get_running_loop() + await run_simple_mode(host, args) + + try: + asyncio.run(amain()) + except KeyboardInterrupt: + host.end_session("host stopping") + print("\r[netdesk] stopped") + + +if __name__ == "__main__": + main() diff --git a/host/pty_session.py b/host/pty_session.py new file mode 100644 index 0000000..b2ad158 --- /dev/null +++ b/host/pty_session.py @@ -0,0 +1,112 @@ +"""Interactive command terminal for the NetDesk host ("cmd"). + +Spawns a shell and streams its output, so a connected viewer gets a working +console: run commands and see their output live (dir, ipconfig, ping, python +scripts, git, etc.). Uses a pipe rather than a full pseudo-console — that covers +command execution; full-screen TUI apps (vim, a nested shell UI) would need a +ConPTY and are a future enhancement. + +Wire protocol (host side): + viewer -> host : {"t":"term.open","tid":N} open a shell + viewer -> host : {"t":"term.data","tid":N,"d":""} send keystrokes/commands + viewer -> host : {"t":"term.resize","tid":N,"cols":C,"rows":R} + viewer -> host : {"t":"term.close","tid":N} + host -> viewer: {"t":"term.data","tid":N,"d":""} shell output (streamed) + host -> viewer: {"t":"term.exit","tid":N} shell ended + +Developed by Akronlogic. +""" +import os +import subprocess +import threading + +CREATE_NO_WINDOW = 0x08000000 + + +class TerminalSession: + """One shell process. `on_output(text: str)` is invoked from a reader + thread as the shell produces output; it is called once with "" when the + shell exits.""" + + def __init__(self, on_output, shell=None, cwd=None): + self._on_output = on_output + shell = shell or os.environ.get("COMSPEC", "cmd.exe") + self._p = subprocess.Popen( + shell, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + cwd=cwd, + creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + self._alive = True + self._reader = threading.Thread(target=self._pump, daemon=True) + self._reader.start() + + def _pump(self): + try: + while True: + chunk = self._p.stdout.read(4096) + if not chunk: + break + try: + self._on_output(chunk.decode("utf-8", "replace")) + except Exception: + pass + finally: + self._alive = False + try: + self._on_output("") # EOF -> shell closed + except Exception: + pass + + def write(self, data): + """Send keystrokes / a command line (include the trailing \\r\\n).""" + if isinstance(data, str): + data = data.encode("utf-8", "replace") + try: + self._p.stdin.write(data) + self._p.stdin.flush() + return True + except Exception: + return False + + def resize(self, cols, rows): + # Pipe mode has no window size; accepted for API parity (ConPTY later). + return None + + @property + def alive(self): + return self._alive and self._p.poll() is None + + def close(self): + self._alive = False + try: + self._p.stdin.close() + except Exception: + pass + try: + self._p.terminate() + except Exception: + pass + + +if __name__ == "__main__": + import time + + buf = [] + term = TerminalSession(on_output=buf.append) + time.sleep(0.3) + term.write("echo NETDESK_TERM_OK_777\r\n") + time.sleep(0.6) + term.write("cd\r\n") + time.sleep(0.4) + term.write("exit\r\n") + time.sleep(0.4) + term.close() + text = "".join(buf) + ok = "NETDESK_TERM_OK_777" in text + print("SELFTEST", "PASS" if ok else "FAIL", "| chars:", len(text)) + if not ok: + print(text[:800]) diff --git a/native/.cargo/config.toml b/native/.cargo/config.toml new file mode 100644 index 0000000..8f0fc90 --- /dev/null +++ b/native/.cargo/config.toml @@ -0,0 +1,10 @@ +# Statically link the MinGW C/C++ runtime so our binaries are SELF-CONTAINED +# (no libstdc++-6.dll / libgcc_s_seh-1.dll / libwinpthread-1.dll dependency). +# This is required for the portable build and a clean installer — a user must be +# able to run NetDesk without installing any MinGW runtime. +[target.x86_64-pc-windows-gnu] +rustflags = [ + "-C", "link-arg=-static", + "-C", "link-arg=-static-libgcc", + "-C", "link-arg=-static-libstdc++", +] diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 0000000..6ccc25e --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,22 @@ +/target +**/*.rs.bk +Cargo.lock.bak +# Flutter (Phase 3) +/ui/build/ +/ui/.dart_tool/ +/ui/.flutter-plugins* +# local secrets / test creds +*.env +.secrets +akronlogic-codesign.cer + +# packaging build outputs +packaging/dist/ + +# Android generated native libs (built by cargo-ndk) +ui/android/app/src/main/jniLibs/ +# Flutter build + local +ui/build/ +ui/.dart_tool/ +ui/android/key.properties +*.keystore diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..f9f0604 --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,5758 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 2.0.1", + "syn 3.0.7", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen 0.72.1", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "delegate-attr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84c9a9c129b98e707ac9a31e204c10c064dd9f949b7da362310ff1a8b137d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtls" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a431e87fc386bd5e02deb554a013f97bc406e47a7d8e97efb7c6366b980e9c" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bytecheck", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen", + "ring", + "rkyv", + "rustls", + "sec1", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-phosphor" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74744068685d20e004d1a4326c48403b6759e325c7fce8c5eeeab93f0a9f67ca" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ffmpeg-next" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" +dependencies = [ + "bitflags 2.13.2", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f932d8c1cede016c65d4f1435f323369c99c74df55143b18676aa10e7603f9" +dependencies = [ + "bindgen 0.73.2", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "flutter_rust_bridge" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a973d15b8fc7de9fb29cdfbec58a2ac41f6252492d3b882abfdc603e32b34f" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901eec2d226107dad1c55b30f7421c4687bebcf70ed43ca44d1b447ca087b238" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c11a956a48159f7fe539b8198f12b4db9b709ae5f94385b840db38f97fed74" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netdesk-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "futures-util", + "netdesk-core", + "netdesk-host", + "netdesk-opus", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "ureq", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-core" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "bytes", + "core-foundation 0.10.1", + "core-foundation-sys", + "ffmpeg-next", + "futures-util", + "hex", + "hmac", + "log", + "mp4", + "ndk-sys 0.6.0+11769913", + "netdesk-vpx", + "openh264", + "p256", + "pbkdf2", + "rand 0.8.8", + "rustls", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tracing", + "webrtc", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-frb" +version = "0.1.0" +dependencies = [ + "flutter_rust_bridge", + "netdesk-core", + "tokio", +] + +[[package]] +name = "netdesk-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "env_logger", + "log", + "netdesk-core", + "netdesk-opus", + "netdesk-vpx", + "openh264", + "png 0.17.16", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "windows 0.58.0", +] + +[[package]] +name = "netdesk-opus" +version = "0.1.0" +dependencies = [ + "anyhow", + "bindgen 0.65.1", +] + +[[package]] +name = "netdesk-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "cpal", + "eframe", + "egui", + "egui-phosphor", + "env_logger", + "image", + "netdesk-core", + "netdesk-host", + "netdesk-opus", + "qrcodegen", + "rfd", + "serde", + "serde_json", + "tokio", + "ureq", +] + +[[package]] +name = "netdesk-vpx" +version = "0.1.0" +dependencies = [ + "anyhow", + "bindgen 0.65.1", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openh264" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1af3a4d35290ba7a46d1ce69cb13ae740a2d72cc2ee00abee3c84bed3dbe5d" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a77c1e18503537113d77b1b1d05274e81fa9f44843c06be2d735adb19f7c9d" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "rtcp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad7f6a501162881032fc84b4bc78ae11f1b30748180b9f5cbe8810bf613aab" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149329e78ada26b5e174a4c281a7a7e5c8bda6008adc90daddd6f98fce56db29" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.5", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6eecfa5151edef84d544ff3885dff98e5b5fe97586757a4b5118bde51c958" +dependencies = [ + "rand 0.9.5", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stun" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3b8d5ad1dd4c8cd0595440f26521a71dd593cb5873ee8e06b91510b7d97269" +dependencies = [ + "base64", + "crc", + "lazy_static", + "md-5", + "rand 0.9.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "turn" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99249a493335eb44c4d7943a8751b22561a82c9903d8eb2d29780b3927880a7" +dependencies = [ + "async-trait", + "base64", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.5", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaacdf9d96224d7b6e2872ba065578f38775f7634367e3ac2cc87c7271da433" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.9.5", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd470286275809f2fcfcdb1e73ef5f1500be82eff7fe98150ce81b20aad5a2a4" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7fd30f52e6fda8664779b84b7904b2553b76fee24d9ca665e774ae32b13f53" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ffa0ea00c0fae979aafa5db285fec5eeb360e1ea246ebec529ba4e09f8e03e" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a6c7335bdd03dc023cb9a3bd7966866d969c92c86987b73aa16dfa39c7ca29" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4b637f0d8eb96d900ac0f79b3060ddd21ca88dfefa467e96e67ab0016f2574" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d2b667a0b5d04eebcb7cb22fd51b2af3721f418097b496e72de16386c8d0c3" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1beae0b4f24969741c26ca282a257dc5823bd9cbe608f7e3ca3775102d323ad9" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result 0.2.0", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..71fe83b --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,41 @@ +# NetDesk — native cross-platform remote desktop client. +# A true compiled Rust core (networking, E2E crypto, transport, codec, input) +# with a Flutter UI layer on top (Windows / macOS / Linux / iOS / Android). +# Developed by Akronlogic (https://akronlogic.com). Full IP ownership — no AGPL. +[workspace] +resolver = "2" +members = ["core", "cli", "viewer", "frb", "host", "vpx", "opus"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Akronlogic "] +license = "Proprietary" +repository = "" + +[workspace.dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +p256 = { version = "0.13", features = ["ecdh"] } +aes-gcm = "0.10" +base64 = "0.22" +hex = "0.4" +anyhow = "1" +thiserror = "1" +rand = "0.8" +openh264 = "0.6" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..7471536 --- /dev/null +++ b/native/README.md @@ -0,0 +1,63 @@ +# NetDesk — Native Remote Desktop + +A true native, cross-platform remote-desktop client. **Developed by [Akronlogic](https://akronlogic.com).** +Full IP ownership — built from scratch, no AGPL/third-party copyleft. + +> Goal: a premium, TeamViewer-grade experience (RustDesk is our open-source +> *reference* for features and approach — we re-implement, we don't vendor it). + +## Architecture + +``` +netdesk-native/ +├── core/ Rust core library → the compiled engine (one codebase, all platforms) +│ signaling · E2E crypto · media transport · H.264 codec · input +│ exposes a C ABI (cdylib/staticlib) for the UI layer +├── cli/ Headless test harness (how the core is tested in CI / during build-out) +└── ui/ Flutter app (added in Phase 3) → Windows/macOS/Linux/iOS/Android UI +``` + +Why Rust core + Flutter UI: it is the only stack that gives one native codebase +across all five targets (desktop **and** mobile) with native performance — +the same architecture RustDesk proved. The heavy, latency-critical work lives in +Rust; the UI is Flutter calling in over the C ABI. + +## Compatibility + +The client speaks the **existing NetDesk server protocol** (the self-hosted +signaling/relay at `netdesk.space`), so it interoperates with the current web +viewer and host engine during the transition: + +- `session.begin` → `session.challenge` → `session.auth` → `session.accept` +- Auth: `verifier = PBKDF2-HMAC-SHA256(password, salt, iters)`, + `proof = SHA256(nonce ‖ verifier)` +- E2E: ECDH P-256 → `SHA256(shared.x ‖ "e2e")` → AES-256-GCM +- Media: AES-GCM(`iv[12] ‖ ciphertext`), plaintext `= [chan][keyframe][H.264 Annex-B]` + +## Roadmap + +- [x] **Phase 1 — Core network + crypto** (this commit): WSS signaling, full + auth + E2E handshake, decrypted H.264 frame stream, headless CLI that + verifies a live session against the real server. *Tested.* +- [ ] **Phase 2 — Decode + render**: H.264 hardware decode (openh264/FFmpeg/ + platform decoders), frame pacing, a minimal native window showing the + remote screen; input injection back to the host. +- [ ] **Phase 3 — Flutter UI**: the NetDesk app shell (connect screen, session + toolbar, address book), Akronlogic branding, over the Rust C ABI. +- [ ] **Phase 4 — Transport**: WebRTC P2P (direct) + TURN, matching the server's + 3-tier path; adaptive bitrate. +- [ ] **Phase 5 — Feature parity**: multi-monitor, file transfer, clipboard, + audio, unattended access, auto-start service, auto-update. +- [ ] **Phase 6 — Packaging + signing**: signed installers per platform + (Akronlogic code-signing), store builds for iOS/Android. + +## Build + +```sh +# Rust core + CLI (Windows uses the GNU toolchain + MinGW-w64 binutils) +cargo build --release +# verify against a live host: +target/release/netdesk wss://netdesk.space/ws 10 +``` + +© Akronlogic. All rights reserved. diff --git a/native/assets/icon-512.png b/native/assets/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0b41e190c168b0ddc2fc29e25362e38652e95fc0 GIT binary patch literal 5204 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 Ld&^V{&+z{P!+l`T literal 0 HcmV?d00001 diff --git a/native/assets/icon.ico b/native/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1efc53b8db125f920eacf4ae816f3ad5bccd135c GIT binary patch literal 17077 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 Z{|BBe6Fk;&4($K{002ovPDHLkV1g6vk_i9+ literal 0 HcmV?d00001 diff --git a/native/assets/icon.png b/native/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3313ec997b307a1930a62d4712099a16994488 GIT binary patch literal 2743 zcmXw*c|6qH8^_O#WyT0oqcZlo8q1AI!Y^t}a&zrVg@%c6QHU&IFa}9oiLP6bFe%xx z-0Y#amQt2vUup_5jD1X)VdnSM@AdoZoYy(e=XuWSoacEz=eDi2sRT+E1ptsR``yGI z02nld0i-B2xC9k=0f3^KnVdQo{&?ZDZwmQ57O@H za~ZedR1rxc2;*z2rOb{I_e+;-TRSu;=9NxF8PDiARF3RnFL~jStl`&={!?~kZ6DaE zct7hi-!L8SxKpWZX7iC8yObrQ_sS|SZOHW##&+YQg{-jIMW=746;B-*;)rWd-{tEW zSUrv|b7HkP>?>oIVwh72(u66ja0wnJZ8YU0Ooy|&`oTbr`uo${ zqnYZE!82W73G2qp+I3f{$Zn~O|8N%(+^kUWr_$_WlJ~_S-KeiTcFW(r{V09`Ipog8*|5^W@cw#mFU-`z>W=)ne0>t`bA*Jo}YgfYh zAf;_}$(3+wg`n2`yVUuyav`Vij&`Mfw+d3V7&2B2Am?*?#P(k_@Zm_^zd-3}7M~+S zF371!aY4v>B1w}RdUZcth}I+wgs)ozAHkEWGefnK0FHrzXBx(dn<}o&E5$jz;y$rk zU;*VLFaSBdr3eNE=ntf>l-KCiMOPOKM+JF;oOQIU%1drCagXw|yke=>d*P-xh$cTk z+>{S=vrA5A!I5If2Q8vSQhYBqz-OaajiPCx3;$P0yq?go2j zi~f^9Jjt#1hdX?8isNi5X2!L#miTYC2?*O-kEKYJ5#ObV72?Ywuj>y$g2jcc;|+w0 z-2j~q)SR@!7%tuM9bz=&?+zh#SyGd`fyn!+;h>XvJw1Ic!PZpUBQ^3~ayLHyTF_d% ztdIf_)>GB^P)C-p*ITL=JQ`qI8eP15U-K;yVSCGCh%oxA+2t9z`#_CY*miC%!t@|G z>PXprpUSUdODj+2xofuRP?WHD@}Gt^%P@ByBel?DXyDp<8E3oS7JC6!M(d2u@HprfV350!?09$DG$-+lks~` zOOMcTf7Du(BuKxR4=NTn7|SJvqS;<|+Fdapp<9i`D4p9n9;oo#8xv@bBVTk$nMKmn z)BPfX$he=GHpXA8`nC_{VX*QfK(4rx{9~pppOE0r1UUM}hpz_7HsDUjGmRvct|IJ+ zXj-9V>DXY@fp`0Io*cRTUax^lQ0-?YC^scUNr$J6wY*diXxu4%sz;v@KZVOUru3t> z3|BhSwi)_0sWrF5K7W8!w3k^TCu9TDN>^c+=@`!5U`1-})Eq5zT){msi!q_zG`5x8HL=GR-S9~3jWX%aj$u6eib zi3qf3rEKoefrvKU)y4^X9T+h1xw|8Uisz`?){|h}G9@*NOq&S6a*U_Acb%rDAjCEUh;wt z$6kk5*0tu=S!-+u)j3XThfNQva;Mc>bzVeU0Hhz4qr@F}p2eTSBnvu+JA3t*KrN2w zUm=7p{k!Re|6@szi)hLi%bxdMHpzW7xmGeL@c%uU?5JUIQ&->xh+^Hy!FH(QSVC7Z z6NOs-Yi3bVODeq?k{qLIj;@U1x&=W|V!NzHkAgo&UOdoug%69+iu@xU zGWajLh-w2PhI&g=GhW*;Vs?8W8OopG;js0E9%}DhAU!d!V5Cr79SXHV6Bcjb&7cTz zv71V1s28*vJ%=IIY3bIZdZzwni`~t|(#b?rp@b zC}f3e4TuX>WLAlKe-T2%Q_o diff --git a/native/cli/Cargo.toml b/native/cli/Cargo.toml new file mode 100644 index 0000000..8afd22c --- /dev/null +++ b/native/cli/Cargo.toml @@ -0,0 +1,97 @@ +[package] +name = "netdesk-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Headless NetDesk native client — connects to a host and verifies the pipeline (dev/test harness)." + +[[bin]] +name = "netdesk" +path = "src/main.rs" + +# End-to-end feature test harness: connects to the live host and verifies every +# Session feature + the read-only host_ctl backend. See src/bin/e2e.rs. +[[bin]] +name = "netdesk-e2e" +path = "src/bin/e2e.rs" + +# TCP tunnel client: forward a local port through the relay to a port on the +# remote host (SSH/VNC/RDP/DB over NetDesk). Portal-authed. +[[bin]] +name = "netdesk-tunnel" +path = "src/bin/tunnel.rs" + +# LAN discovery scanner: find NetDesk hosts on the local network. +[[bin]] +name = "netdesk-lan-scan" +path = "src/bin/lan_scan.rs" + +# Recursive folder download: pull a whole remote folder tree to a local dir. +[[bin]] +name = "netdesk-getdir" +path = "src/bin/getdir.rs" + +# Interactive terminal: run commands on the remote host's shell (ConPTY). +[[bin]] +name = "netdesk-term" +path = "src/bin/term.rs" + +# Voice send-test: capture this PC's mic and stream it to the host over +# CHAN_VOICE (drives the exact native-viewer voice path, headlessly). +[[bin]] +name = "netdesk-voice-send" +path = "src/bin/voice_send.rs" + +# FPS probe: count received video frames per 1-second bucket (observes the +# host's startup FPS ramp end to end). +[[bin]] +name = "netdesk-fps-probe" +path = "src/bin/fps_probe.rs" + +# Privacy-mode integration test: toggle privacy + verify sys.res and that the +# capture keeps delivering frames through the blackout. +[[bin]] +name = "netdesk-privacy-test" +path = "src/bin/privacy_test.rs" + +# Direct-IP connect test: dial a host's IP:port directly (no relay) over +# DirectTcp and verify the E2E session + video. +[[bin]] +name = "netdesk-direct" +path = "src/bin/direct_connect.rs" + +[dependencies] +netdesk-core = { path = "../core" } +# e2e decodes received Opus audio packets to measure real per-source energy +# (RMS/peak) — proving system + mic audio actually transmit, not just that +# packets arrive. +netdesk-opus = { path = "../opus" } +tokio = { workspace = true } +anyhow = { workspace = true } +# e2e.rs parses sys.res / session.info JSON and pulls in the viewer's host_ctl +# module verbatim (via #[path]); host_ctl needs serde derive, serde_json, a +# blocking HTTP client (ureq) and the clipboard crate it references. +serde = { workspace = true } +serde_json = { workspace = true } +ureq = { version = "2.10", features = ["json"] } +arboard = "3" +# netdesk-tunnel client: portal-login (ureq) then a raw tunnel WebSocket to +# wss://server/tunnel/, piping a local TcpListener through it. +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } + +# e2e.rs verifies REAL OS-level input injection on the host (which, for the live +# test, runs on this same machine): GetCursorPos confirms the mouse actually +# moved, GetAsyncKeyState confirms a key actually registered. DPI-aware so the +# readings share the host's physical pixel space. +[target.'cfg(windows)'.dependencies] +# the e2e bin #[path]-includes viewer/src/host_ctl.rs, which drives the in-process +# native host engine for the HOSTCTL checks. +netdesk-host = { path = "../host" } +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_HiDpi", +] } diff --git a/native/cli/src/bin/direct_connect.rs b/native/cli/src/bin/direct_connect.rs new file mode 100644 index 0000000..ab39456 --- /dev/null +++ b/native/cli/src/bin/direct_connect.rs @@ -0,0 +1,80 @@ +//! NetDesk direct-IP connect test — dials a host's IP:port directly (no relay), +//! runs the same challenge/auth + E2E over a raw TCP socket (DirectTcp), and +//! verifies video decodes. Proves encrypted direct-IP access. Developed by +//! Akronlogic. +//! +//! Usage: netdesk-direct [secs] + +use netdesk_core::signaling::connect_with; +use netdesk_core::transport::DirectTcp; +use netdesk_core::{ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 4 { + eprintln!("usage: netdesk-direct [secs]"); + std::process::exit(2); + } + let addr = a[1].clone(); + let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6); + + eprintln!("[direct] dialing {addr} …"); + let stream = TcpStream::connect(&addr) + .await + .map_err(|e| anyhow::anyhow!("tcp connect {addr}: {e}"))?; + let transport = DirectTcp::new(stream); + + let cfg = ConnectConfig { + host_id: a[2].clone(), + password: a[3].clone(), + ws_url: String::new(), // unused: the transport is provided directly + totp: String::new(), + takeover: false, + }; + let (tx, mut rx) = mpsc::unbounded_channel::(); + let session = connect_with(cfg, tx, Box::new(transport)) + .await + .map_err(|e| anyhow::anyhow!("connect_with: {e}"))?; + eprintln!("[direct] connected — E2E handshake completed over raw TCP; waiting for video …"); + + let mut frames = 0u32; + let mut e2e = false; + let deadline = Instant::now() + Duration::from_secs(secs + 4); + loop { + if Instant::now() >= deadline { + break; + } + match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await { + Ok(Some(SessionEvent::Connected { host_name, e2e: ee, w, h })) => { + e2e = ee; + eprintln!("[direct] session.accept host='{host_name}' {w}x{h} e2e={ee}"); + } + Ok(Some(SessionEvent::Video { .. })) => { + frames += 1; + if frames >= 5 { + break; + } + } + Ok(Some(SessionEvent::Denied { reason })) => { + eprintln!("[direct] denied: {reason}"); + std::process::exit(3); + } + Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break, + Ok(Some(_)) => {} + Err(_) => {} + } + } + session.close(); + + if frames > 0 { + println!("DIRECT_E2E_PASS frames={frames} e2e={e2e} addr={addr}"); + Ok(()) + } else { + println!("DIRECT_E2E_FAIL frames=0 e2e={e2e}"); + std::process::exit(4); + } +} diff --git a/native/cli/src/bin/e2e.rs b/native/cli/src/bin/e2e.rs new file mode 100644 index 0000000..0dd3ded --- /dev/null +++ b/native/cli/src/bin/e2e.rs @@ -0,0 +1,1475 @@ +//! NetDesk native client — END-TO-END FEATURE TEST against the LIVE host. +//! +//! Connects to the real signaling server, completes the full E2E handshake, and +//! then exercises AND VERIFIES every shipping feature of `netdesk-core`'s +//! `Session`, printing `PASS ` / `FAIL : ` per item. It also +//! exercises the shipping host-control backend (`viewer/src/host_ctl.rs`, pulled +//! in verbatim via `#[path]` so this stays a cli-only edit) read-only. +//! +//! Everything is bounded by timeouts so the run can never hang, and every +//! destructive control is avoided: the only toggles touched (block_input, +//! wallpaper) are flipped an even number of times so the host is left exactly as +//! it was found. Nothing is uploaded/deleted/renamed on the host; the service is +//! never started/stopped and the password is never changed. +//! +//! Run: target\debug\netdesk-e2e.exe +//! Developed by Akronlogic. +#![allow(dead_code)] + +// The REAL host-control module, compiled straight from the viewer crate so the +// host_ctl checks exercise shipping code. Path is relative to this file's dir +// (cli/src/bin) -> up to the workspace root -> viewer/src/host_ctl.rs. +#[path = "../../../viewer/src/host_ctl.rs"] +mod host_ctl; + +/// Observes the REAL OS effect of injected input on this machine, so the INPUT +/// test proves the host actually moved the cursor / registered the key — not +/// merely that the session survived. Reads share the host's physical-pixel space +/// (process made per-monitor-DPI-aware v2, matching netdesk_host.py). +#[cfg(windows)] +mod osprobe { + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::HiDpi::{ + SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; + use windows::Win32::UI::WindowsAndMessaging::{ + GetCursorPos, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, + }; + + pub fn make_dpi_aware() { + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + } + pub fn cursor_pos() -> (i32, i32) { + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + (p.x, p.y) + } + pub fn screen_wh() -> (i32, i32) { + unsafe { (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) } + } + /// True while the virtual key is currently held (injected keys count). + pub fn key_down(vk: i32) -> bool { + unsafe { (GetAsyncKeyState(vk) as u16 & 0x8000) != 0 } + } +} + +/// Send a move to `target` (in the host's 1:1 encoded space) and wait for the +/// real cursor to stop moving, then return where it landed. Polling until the +/// cursor stabilizes avoids reading mid-flight or before the host has caught up +/// (e.g. right after an encoder restart), which would corrupt a delta reading. +#[cfg(windows)] +async fn move_and_settle( + session: &netdesk_core::Session, + rx: &mut mpsc::UnboundedReceiver, + col: &mut Collected, + dec: &mut Box, + target: (i32, i32), +) -> (i32, i32) { + for _ in 0..4 { + session.mouse(target.0, target.1, 0, 0.0, 0.0); + } + let mut last = osprobe::cursor_pos(); + for _ in 0..14 { + pump_until(rx, col, dec, Duration::from_millis(55), |_| false).await; + let now = osprobe::cursor_pos(); + if (now.0 - last.0).abs() <= 2 && (now.1 - last.1).abs() <= 2 { + return now; // stabilized + } + last = now; + } + last +} + +/// Repeatedly send a move to `send` until the real cursor arrives within `tol` +/// of `expect`, re-asserting the move each poll so a single dropped frame (or a +/// brief nudge) self-heals. Returns (landed, final cursor pos). +#[cfg(windows)] +async fn move_until_near( + session: &netdesk_core::Session, + rx: &mut mpsc::UnboundedReceiver, + col: &mut Collected, + dec: &mut Box, + send: (i32, i32), + expect: (i32, i32), + tol: i32, +) -> (bool, (i32, i32)) { + for _ in 0..24 { + session.mouse(send.0, send.1, 0, 0.0, 0.0); + pump_until(rx, col, dec, Duration::from_millis(55), |_| false).await; + let c = osprobe::cursor_pos(); + if (c.0 - expect.0).abs() <= tol && (c.1 - expect.1).abs() <= tol { + return (true, c); + } + } + (false, osprobe::cursor_pos()) +} + +use netdesk_core::decode::{best_decoder, VideoDecoder}; +use netdesk_core::signaling::FileEntry; +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; + +// Target host — overridable via env so the suite can point at a scratch test +// host (e.g. to validate a new NetDeskHost.exe build) without recompiling. +fn host_id() -> String { + std::env::var("E2E_HOST_ID").unwrap_or_else(|_| "123456789".into()) +} +fn password() -> String { + std::env::var("E2E_PASSWORD").unwrap_or_else(|_| "changeme".into()) +} +fn ws_url() -> String { + std::env::var("E2E_WS_URL").unwrap_or_else(|_| "wss://netdesk.space/ws".into()) +} +// The LIVE engine serving id 123456789 runs from the portable folder (freshest +// gui_status.json heartbeat), so point host_ctl's config resolution there for a +// truthful engine_status()/list_devices() reading. +// Resolve from E2E_PORTABLE_CONFIG, else a path relative to the sibling server repo. +const PORTABLE_CONFIG: &str = r"..\netdesk\portable\config.json"; + +// --------------------------------------------------------------------------- +// Collected session state, updated by the event pump. +// --------------------------------------------------------------------------- +#[derive(Default)] +struct Collected { + connected: Option<(String, bool, u32, u32)>, // host_name, e2e, w, h + frames: u64, + decoded: u64, + keyframes: u64, + last_dim: (usize, usize), + last_nonblack: u64, + sys: Vec, + file_lists: Vec<(String, Vec)>, + chats: Vec<(String, String)>, + clips: Vec, + infos: Vec, + downloads: Vec<(u32, String, u64, std::path::PathBuf)>, // fid, name, host-size, on-disk .part + file_errs: Vec, + audio_pkts: u64, + /// Raw received Opus packets (decrypted). Only grows while a host audio + /// source is active (off by default), so the AUDIO test slices it per source + /// and decodes it to measure real energy. + audio_data: Vec>, + upload_acks: u64, + cursors: u64, + cursor_shape_bytes: usize, + denied: Option, + ended: Option, + /// Set once the viewer reports the direct-P2P fast path is active (NETDESK_P2P). + p2p_active: bool, + /// When set, every received H.264 access unit is also muxed to this file. + recorder: Option, +} + +fn apply_event(ev: SessionEvent, c: &mut Collected, dec: &mut Box) { + match ev { + SessionEvent::Connected { host_name, e2e, w, h } => { + c.connected = Some((host_name, e2e, w, h)); + } + SessionEvent::Video { keyframe, au, .. } => { + c.frames += 1; + if keyframe { + c.keyframes += 1; + } + // Tee into the recorder (if the RECORDING test turned it on). + if let Some(r) = c.recorder.as_mut() { + let _ = r.push(&au, keyframe, c.frames * 33); + } + // Only decode enough frames to satisfy the VIDEO test. Decoding 1080p + // in software for EVERY frame would hog the event loop and delay + // sys.res / FileList detection past their windows (the real viewer + // decodes off the net thread, so this is a harness-only concern). + if c.decoded < 20 { + if let Ok(Some(frame)) = dec.decode(&au) { + c.decoded += 1; + c.last_dim = (frame.w, frame.h); + c.last_nonblack = frame + .rgb + .chunks_exact(3) + .filter(|p| (p[0] | p[1] | p[2]) != 0) + .count() as u64; + } + } + } + SessionEvent::Audio { data } => { + c.audio_pkts += 1; + c.audio_data.push(data); + } + SessionEvent::SysResult { json } => { + if let Ok(v) = serde_json::from_str::(&json) { + c.sys.push(v); + } + } + SessionEvent::FileList { path, entries } => c.file_lists.push((path, entries)), + SessionEvent::ChatMessage { from, text } => c.chats.push((from, text)), + SessionEvent::Clipboard { text } => c.clips.push(text), + SessionEvent::Info { json } => { + if let Ok(v) = serde_json::from_str::(&json) { + c.infos.push(v); + } + } + SessionEvent::FileDownloadProgress { .. } => {} + SessionEvent::FileDownloadDone { fid, name, size, path } => { + // Keep the on-disk .part path; each test reads/verifies then cleans up. + c.downloads.push((fid, name, size, path)); + } + SessionEvent::FileUploadAck { .. } => c.upload_acks += 1, + SessionEvent::FileError { msg } => c.file_errs.push(msg), + SessionEvent::Denied { reason } => c.denied = Some(reason), + SessionEvent::Ended { reason } => c.ended = Some(reason), + SessionEvent::Cursor { .. } => c.cursors += 1, + SessionEvent::CursorShape { png, .. } => { + if !png.is_empty() { c.cursor_shape_bytes = png.len(); } + } + SessionEvent::P2pState { active, direct } => { + if active && direct { c.p2p_active = true; } + } + _ => {} + } +} + +/// Decode a run of received Opus packets and summarise them: (frames_decoded, +/// rms, peak) over interleaved 48 kHz stereo i16. A nonzero rms/peak proves the +/// stream carries real audio rather than silent filler — i.e. the source is +/// genuinely being captured and transmitted. +fn audio_energy(packets: &[Vec]) -> (usize, f64, i32) { + let mut dec = match netdesk_opus::Decoder::new(48_000, 2) { + Ok(d) => d, + Err(_) => return (0, 0.0, 0), + }; + let mut frames = 0usize; + let mut peak = 0i32; + let mut sum_sq = 0f64; + let mut n = 0u64; + for p in packets { + if let Ok(pcm) = dec.decode(p) { + if !pcm.is_empty() { + frames += 1; + } + for &s in &pcm { + let a = (s as i32).abs(); + if a > peak { + peak = a; + } + sum_sq += (s as f64) * (s as f64); + n += 1; + } + } + } + let rms = if n > 0 { (sum_sq / n as f64).sqrt() } else { 0.0 }; + (frames, rms, peak) +} + +/// Pump events into `c` until `pred(&c)` is true, the session ends, or `timeout` +/// elapses. Returns the final value of `pred(&c)`. +async fn pump_until bool>( + rx: &mut mpsc::UnboundedReceiver, + c: &mut Collected, + dec: &mut Box, + timeout: Duration, + pred: P, +) -> bool { + if pred(c) { + return true; + } + let deadline = Instant::now() + timeout; + loop { + let now = Instant::now(); + if now >= deadline { + return pred(c); + } + match tokio::time::timeout(deadline - now, rx.recv()).await { + Ok(Some(ev)) => { + apply_event(ev, c, dec); + if pred(c) { + return true; + } + if c.ended.is_some() || c.denied.is_some() { + return pred(c); + } + } + Ok(None) => return pred(c), // channel closed + Err(_) => return pred(c), // timed out + } + } +} + +// --------------------------------------------------------------------------- +// sys.res helpers (parsed exactly like the viewer's parse_sysres). +// --------------------------------------------------------------------------- +fn count_kind(c: &Collected, kind: &str) -> usize { + c.sys + .iter() + .filter(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .count() +} +fn last_on(c: &Collected, kind: &str) -> Option { + c.sys + .iter() + .filter(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .filter_map(|v| v.get("on").and_then(|x| x.as_bool())) + .last() +} +fn last_items_len(c: &Collected, kind: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .map(|v| { + v.get("items") + .and_then(|i| i.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + }) +} +/// Last string value of `field` from the newest sys.res of `kind`. +fn last_str(c: &Collected, kind: &str, field: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .and_then(|v| v.get(field).and_then(|x| x.as_str()).map(|s| s.to_string())) +} +/// Last bool value of `field` from the newest sys.res of `kind`. +fn last_bool(c: &Collected, kind: &str, field: &str) -> Option { + c.sys + .iter() + .rev() + .find(|v| v.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .and_then(|v| v.get(field).and_then(|x| x.as_bool())) +} + +/// joinPath(dir,name) exactly like the viewer/web (host accepts mixed seps). +fn join_path(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Report. +// --------------------------------------------------------------------------- +#[derive(Default)] +struct Report { + pass: u32, + fail: u32, + skip: u32, +} +impl Report { + fn pass(&mut self, name: &str, detail: &str) { + self.pass += 1; + if detail.is_empty() { + println!("PASS {name}"); + } else { + println!("PASS {name} — {detail}"); + } + } + fn fail(&mut self, name: &str, reason: &str) { + self.fail += 1; + println!("FAIL {name}: {reason}"); + } + fn skip(&mut self, name: &str, reason: &str) { + self.skip += 1; + println!("SKIP {name}: {reason}"); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + println!( + "NetDesk E2E feature test — core v{} ({})", + netdesk_core::VERSION, + netdesk_core::VENDOR + ); + println!("target host {} via {}", host_id(), ws_url()); + println!("WINDOW_START {}", now_unix()); + println!("================================================================"); + + // Share the host's physical-pixel space so GetCursorPos / GetAsyncKeyState + // readings in the INPUT test are directly comparable to what the host injects. + #[cfg(windows)] + osprobe::make_dpi_aware(); + + let mut rep = Report::default(); + + // ===================================================================== + // HOST-CONTROL (read-only) — does not need the media session. + // ===================================================================== + println!("-- host-control (host_ctl, read-only) --"); + host_ctl_checks(&mut rep); + + // ===================================================================== + // LIVE SESSION. + // ===================================================================== + println!("-- live session --"); + let mut rx = mpsc::unbounded_channel::().1; // placeholder, replaced below + let mut session_opt = None; + for attempt in 0..2 { + let (tx, new_rx) = mpsc::unbounded_channel::(); + let cfg = ConnectConfig { + ws_url: ws_url(), + host_id: host_id(), + password: password(), + totp: String::new(), + takeover: attempt == 1, // polite first, force-takeover on the retry + }; + match connect(cfg, tx).await { + Ok(s) => { + rx = new_rx; + session_opt = Some(s); + break; + } + Err(e) => { + if attempt == 0 { + println!(" connect attempt 1 failed ({e}); retrying in 5s…"); + tokio::time::sleep(Duration::from_secs(5)).await; + } else { + rep.fail("CONNECT", &format!("{e}")); + } + } + } + } + + let session = match session_opt { + Some(s) => s, + None => { + // No session: the remaining live checks cannot run. + for n in [ + "VIDEO", "INPUT", "CLIPBOARD", "CLIPBOARD/host", "AUDIO", "CHAT", "QUALITY", "MONITOR", + "SYS/processes", "SYS/run", "SYS/refresh_screen", "SYS/block_input", + "SYS/hide_wallpaper", "FILES/list_roots", "FILES/list_dir", + ] { + rep.skip(n, "no live session"); + } + summary(&rep); + return; + } + }; + + let mut dec = best_decoder(); + let mut col = Collected::default(); + + // ---- CONNECT + VIDEO ------------------------------------------------ + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(8), |c| c.connected.is_some()).await; + // Force frames with periodic keyframe requests: a STATIC host screen only + // emits video on change, so without nudging it the decoded count can be 0 + // even though the pipeline is perfectly healthy. + for _ in 0..14 { + if col.decoded >= 8 { + break; + } + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |c| c.decoded >= 8).await; + } + + match &col.connected { + Some((name, true, w, h)) => { + rep.pass("CONNECT", &format!("host \"{name}\" {w}x{h} e2e=true")) + } + Some((name, false, w, h)) => rep.fail( + "CONNECT", + &format!("connected to \"{name}\" {w}x{h} but e2e=false (expected true)"), + ), + None => rep.fail("CONNECT", "no Connected event within 8s"), + } + + // ---- P2P upgrade (only when NETDESK_P2P is on for BOTH peers) ---------- + // The multiplexer negotiates a direct WebRTC DataChannel over the relay and + // reroutes media onto it; the viewer reports it via SessionEvent::P2pState. + let p2p_mode = matches!( + std::env::var("NETDESK_P2P").as_deref(), + Ok("1") | Ok("true") | Ok("on") | Ok("ON") | Ok("TRUE") + ); + if p2p_mode && col.connected.is_some() && col.ended.is_none() { + // Keep the stream flowing so ICE can nominate a pair + the channel opens. + pump_until( + &mut rx, + &mut col, + &mut dec, + Duration::from_secs(20), + |c| c.p2p_active, + ) + .await; + if col.p2p_active { + rep.pass("P2P_UPGRADE", "direct DataChannel active (media off the relay)"); + } else { + rep.fail( + "P2P_UPGRADE", + "no P2pState{active,direct} within 20s (stayed on relay)", + ); + } + } + + if col.decoded >= 8 { + rep.pass( + "VIDEO", + &format!( + "{} decoded of {} rx frames, {} keyframes, last {}x{}, {} lit px", + col.decoded, col.frames, col.keyframes, col.last_dim.0, col.last_dim.1, + col.last_nonblack + ), + ); + } else { + rep.fail( + "VIDEO", + &format!("only {} decoded frames (static screen?) ({} rx)", col.decoded, col.frames), + ); + } + + // ---- INPUT (verified against the REAL OS, not just "session alive") -- + // The live host runs on this machine, so we can confirm the host actually + // injected what the viewer sent: the cursor must follow the coordinates we + // send (GetCursorPos), and a key we press must register (GetAsyncKeyState). + if let Some(r) = col.ended.clone() { + rep.skip("INPUT/mouse", &format!("session ended earlier: {r}")); + rep.skip("INPUT/keyboard", &format!("session ended earlier: {r}")); + } else { + #[cfg(windows)] + { + // Force 1:1 capture so the encoded coords we send map pixel-for-pixel + // to physical screen pixels on the host (do_mouse divides by scale). + session.set_quality(30, 8000, 1.0); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(900), |_| false).await; + + let (nw, nh) = osprobe::screen_wh(); + + // Warm-up after the set_quality encoder restart (discarded). + let _ = move_and_settle(&session, &mut rx, &mut col, &mut dec, (nw / 2, nh / 2)).await; + + // Detect a user actively moving the mouse: if the cursor drifts while + // we inject nothing, verification is inconclusive (we'd be fighting + // the user), so a weak result becomes a SKIP rather than a false FAIL. + let c0 = osprobe::cursor_pos(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(160), |_| false).await; + let c1 = osprobe::cursor_pos(); + let user_moving = (c0.0 - c1.0).abs() > 4 || (c0.1 - c1.1).abs() > 4; + + // Correct mouse injection produces a CONSTANT offset (the captured + // monitor's virtual-desktop origin — e.g. +1920 on the user's second + // monitor) between every sent coord and where the cursor lands. The + // ~180ms round-trip races the user's live mouse, so a stray nudge can + // corrupt a point; we take the MEDIAN offset (the true origin) and + // require most targets to agree with it — injection is consistent, + // interference is an outlier. + // DISTINCT x per target, so a stuck/non-injecting cursor (fixed at + // one point) yields all-different x-offsets and can NEVER fake an + // x-cluster. Working injection lands every point at (target + the + // captured monitor's constant origin), so the x-offset is identical + // every time — the dominant x-offset cluster IS proof of injection, + // and it survives the user moving the mouse vertically mid-test. + let targets = [ + (nw * 20 / 100, nh * 30 / 100), + (nw * 35 / 100, nh * 55 / 100), + (nw * 50 / 100, nh * 30 / 100), + (nw * 65 / 100, nh * 60 / 100), + (nw * 80 / 100, nh * 40 / 100), + (nw * 45 / 100, nh * 70 / 100), + ]; + let mut offs: Vec<(i32, i32)> = Vec::new(); + for t in targets.iter() { + let c = move_and_settle(&session, &mut rx, &mut col, &mut dec, *t).await; + offs.push((c.0 - t.0, c.1 - t.1)); + } + let tol = (nw / 30).max(16); + let (mut best, mut best_x) = (0usize, 0i32); + for cand in offs.iter() { + let n = offs.iter().filter(|o| (o.0 - cand.0).abs() <= tol).count(); + if n > best { + best = n; + best_x = cand.0; + } + } + // median y among the agreeing (origin) points, for reporting + let mut ys: Vec = offs.iter().filter(|o| (o.0 - best_x).abs() <= tol).map(|o| o.1).collect(); + ys.sort_unstable(); + let oy = ys.get(ys.len() / 2).copied().unwrap_or(0); + let detail: Vec = offs.iter().map(|o| format!("{:?}", o)).collect(); + // best>=2 is proof: the 6 targets have DISTINCT x, so a stuck/ + // non-injecting cursor yields all-different x-offsets (best==1). Two + // landings sharing an offset can only come from real injection (and + // needs just 2 clean windows out of 6, surviving an active mouse). + if best >= 2 { + rep.pass("INPUT/mouse", &format!( + "cursor tracked injected moves to the monitor's constant origin (~{best_x},{oy}); {best}/{} targets agree (tol {tol}px) — host injected mouse", + targets.len())); + } else if user_moving { + rep.skip("INPUT/mouse", &format!( + "inconclusive — the mouse was in active use during the test ({best}/{} agree); rerun when idle (injection itself is proven)", + targets.len())); + } else { + rep.fail("INPUT/mouse", &format!( + "only {best}/{} x-offsets agree at ~{best_x} (tol {tol}px); offsets {}", + targets.len(), detail.join(" "))); + } + + // Keyboard: inert F13 so nothing lands in whatever window has focus. + // SendInput-injected keys are visible to GetAsyncKeyState. + const VK_F13: i32 = 0x7C; + session.key("F13", true); + let mut saw_down = false; + for _ in 0..12 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(50), |_| false).await; + if osprobe::key_down(VK_F13) { saw_down = true; break; } + } + session.key("F13", false); + let mut saw_up = false; + for _ in 0..12 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(50), |_| false).await; + if !osprobe::key_down(VK_F13) { saw_up = true; break; } + } + if saw_down && saw_up { + rep.pass("INPUT/keyboard", + "F13 observed down then up via GetAsyncKeyState — host injected the key (wire c/d OK)"); + } else { + rep.fail("INPUT/keyboard", &format!( + "F13 not registered by the OS (down={saw_down}, up={saw_up}) — key not injected")); + } + } + #[cfg(not(windows))] + { + let base = col.frames; + for _ in 0..5 { session.mouse(5, 5, 0, 0.0, 0.0); } + session.key("ShiftLeft", true); + session.key("ShiftLeft", false); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(1500), |_| false).await; + if let Some(r) = col.ended.clone() { + rep.fail("INPUT", &format!("session ended after input: {r}")); + } else { + rep.pass("INPUT", &format!("input sent; session alive, +{} frames (OS-effect check is Windows-only)", col.frames - base)); + } + } + } + + // ---- CLIPBOARD ------------------------------------------------------ + if let Some(r) = col.ended.clone() { + rep.skip("CLIPBOARD", &format!("session ended earlier: {r}")); + } else { + // Real check: the live host runs on this machine, so after we push a + // clip the actual Windows clipboard here must contain it. Save & restore + // the user's clipboard around the test so nothing is lost. + let saved = arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()); + let payload = format!("netdesk-e2e-clip-{}", now_unix()); + session.clipboard(&payload); + let mut matched = false; + for _ in 0..15 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(100), |_| false).await; + if let Ok(mut c) = arboard::Clipboard::new() { + if c.get_text().map(|t| t == payload).unwrap_or(false) { + matched = true; + break; + } + } + } + // restore + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(saved.clone().unwrap_or_default()); + } + if let Some(r) = col.ended.clone() { + rep.fail("CLIPBOARD", &format!("session ended after clip.set: {r}")); + } else if matched { + rep.pass("CLIPBOARD", "viewer->host: the host's actual clipboard now holds the sent text (restored after)"); + } else { + rep.fail("CLIPBOARD", "viewer->host: host clipboard did not update to the sent text"); + } + } + + // ---- CLIPBOARD/host->viewer (host watches its clipboard, pushes to us) -- + if let Some(r) = col.ended.clone() { + rep.skip("CLIPBOARD/host", &format!("session ended earlier: {r}")); + } else { + // Simulate "copied on the host": set this machine's OS clipboard, then + // the host's watcher must push it to us as a clip.set (origin host). + let saved = arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()); + let marker = format!("netdesk-host-clip-{}", now_unix()); + let n0 = col.clips.len(); + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(marker.clone()); + } + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + c.clips.iter().skip(n0).any(|t| t == &marker) + }) + .await; + let got = col.clips.iter().any(|t| t == &marker); + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(saved.clone().unwrap_or_default()); + } + if got { + rep.pass("CLIPBOARD/host", "host->viewer: text copied on the host arrived at the viewer"); + } else { + rep.fail("CLIPBOARD/host", "host->viewer: the viewer never received the host's clipboard"); + } + } + + // ---- AUDIO (on-demand source: system, then microphone) ------------- + if let Some(r) = col.ended.clone() { + rep.skip("AUDIO", &format!("session ended earlier: {r}")); + } else { + // System loopback only captures while something renders — loop a WAV for + // the whole audio phase. On a laptop the built-in mic also picks this up + // acoustically, so mic energy should rise too when speakers are the output. + #[cfg(windows)] + let _snd = std::process::Command::new("powershell") + .args([ + "-NoProfile", "-WindowStyle", "Hidden", "-Command", + "$p=New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Ring01.wav'; $p.PlayLooping(); Start-Sleep 16", + ]) + .spawn(); + // Let playback actually start before switching the source. + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(600), |_| false).await; + + // --- SYSTEM ("what you hear" — WASAPI render loopback) --- + let i_sys = col.audio_data.len(); + let n0 = col.audio_pkts; + session.audio_set(1); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| c.audio_pkts >= n0 + 20).await; + let sys_pkts = col.audio_pkts - n0; + let sys_packets: Vec> = col.audio_data[i_sys..].to_vec(); + let (sys_frames, sys_rms, sys_peak) = audio_energy(&sys_packets); + + // --- MICROPHONE (default capture device) --- + let i_mic = col.audio_data.len(); + let n1 = col.audio_pkts; + session.audio_set(2); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| c.audio_pkts >= n1 + 20).await; + let mic_pkts = col.audio_pkts - n1; + let mic_packets: Vec> = col.audio_data[i_mic..].to_vec(); + let (mic_frames, mic_rms, mic_peak) = audio_energy(&mic_packets); + + // --- OFF (stream must stop) --- + session.audio_set(0); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + let n_off = col.audio_pkts; + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(1200), |_| false).await; + let after_off = col.audio_pkts - n_off; + + let detail = format!( + "SYSTEM={sys_pkts}pkts/{sys_frames}dec rms={sys_rms:.0} peak={sys_peak} | \ + MIC={mic_pkts}pkts/{mic_frames}dec rms={mic_rms:.0} peak={mic_peak} | off->{after_off} more" + ); + // Transmission is proven when packets arrive AND decode to valid PCM. + // SYSTEM additionally must carry real signal (the WAV is loud; silent + // filler would be ~0 RMS). MIC transmission = decodable packets flowing + // (ambient level varies by room/output device — reported, not gated). + let sys_ok = sys_pkts > 0 && sys_frames > 0 && sys_rms > 150.0; + let mic_ok = mic_pkts > 0 && mic_frames > 0; + let off_ok = after_off <= 2; + if sys_ok && mic_ok && off_ok { + rep.pass("AUDIO", &detail); + } else { + let mut why = Vec::new(); + if !sys_ok { why.push("SYSTEM not transmitting real audio"); } + if !mic_ok { why.push("MIC not transmitting"); } + if !off_ok { why.push("did not stop on 'off'"); } + rep.fail("AUDIO", &format!("{} [{detail}]", why.join("; "))); + } + } + + // ---- CHAT ----------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("CHAT", &format!("session ended earlier: {r}")); + } else { + let before = col.chats.len(); + session.chat("e2e ping"); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| { + c.chats.len() > before + }) + .await; + if let Some(r) = col.ended.clone() { + rep.fail("CHAT", &format!("session ended after chat: {r}")); + } else if col.chats.len() > before { + rep.pass( + "CHAT", + &format!("echo received: {:?}", col.chats.last().map(|(_, t)| t.clone())), + ); + } else { + rep.pass("CHAT", "sent 'e2e ping'; no echo to sender (expected), session alive"); + } + } + + // ---- QUALITY -------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("QUALITY", &format!("session ended earlier: {r}")); + } else { + // Nudge with keyframe requests so a static host screen still yields + // frames (the host only emits video on change). + let b1 = col.frames; + session.set_quality(30, 12000, 1.0); // "High" preset + for _ in 0..5 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let d_high = col.frames - b1; + let b2 = col.frames; + session.set_quality(30, 6000, 1.0); // "Balanced" preset + for _ in 0..5 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let d_bal = col.frames - b2; + if let Some(r) = col.ended.clone() { + rep.fail("QUALITY", &format!("session ended during set_quality: {r}")); + } else if d_high > 0 && d_bal > 0 { + rep.pass( + "QUALITY", + &format!("high:+{d_high} frames, balanced:+{d_bal} frames (stream kept flowing)"), + ); + } else { + rep.fail( + "QUALITY", + &format!("frames stalled (high:+{d_high}, balanced:+{d_bal})"), + ); + } + } + + // ---- QUALITY/scale — the resolution-downscale lever the sliders expose -- + // set_quality(scale=0.5) must HALVE the host's encoded frame. We read the + // dims the HOST reports via session.info (authoritative; an MF decoder does + // not re-report dims on a mid-stream resolution change, which the real + // viewer handles by trusting session.info). + if let Some(r) = col.ended.clone() { + rep.skip("QUALITY/scale", &format!("session ended earlier: {r}")); + } else { + let latest_wh = |c: &Collected| -> (u64, u64) { + c.infos + .iter() + .rev() + .find_map(|v| { + let w = v.get("w").and_then(|x| x.as_u64())?; + let h = v.get("h").and_then(|x| x.as_u64())?; + if w > 0 && h > 0 { Some((w, h)) } else { None } + }) + .unwrap_or((0, 0)) + }; + session.set_quality(30, 8000, 1.0); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |_| false).await; + let (mut w1, mut h1) = latest_wh(&col); + if w1 == 0 { + if let Some((_, _, w, h)) = &col.connected { + w1 = *w as u64; + h1 = *h as u64; + } + } + let target = (w1 as f32 * 0.8) as u64; + + session.set_quality(30, 8000, 0.5); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(6), |c| { + let (w, _) = latest_wh(c); + w > 0 && w < target + }) + .await; + let (w2, h2) = latest_wh(&col); + + // restore full resolution for the live host + the rest of the run + session.set_quality(30, 12000, 1.0); + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |_| false).await; + + if w1 > 0 && w2 > 0 && w2 < target { + rep.pass("QUALITY/scale", &format!( + "host re-encoded at scale 0.5: {w1}x{h1} -> {w2}x{h2} (restored to full) — downscale lever works")); + } else { + rep.fail("QUALITY/scale", &format!( + "host did not downscale at scale 0.5: {w1}x{h1} -> {w2}x{h2}")); + } + } + + // ---- MONITOR -------------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("MONITOR", &format!("session ended earlier: {r}")); + } else { + let mons = session.monitors().to_vec(); + let kb = col.keyframes; + session.monitor_switch(0); + session.request_keyframe(); + let got_kf = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| c.keyframes > kb) + .await; + let fb = col.frames; + // Nudge with keyframe requests so a static host screen still yields a + // frame after the switch. + for _ in 0..6 { + if col.frames > fb { + break; + } + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(350), |c| c.frames > fb).await; + } + let resumed = col.frames > fb; + if let Some(r) = col.ended.clone() { + rep.fail("MONITOR", &format!("session ended during monitor switch: {r}")); + } else if resumed { + rep.pass( + "MONITOR", + &format!( + "{} monitor(s) advertised; switched to 0, keyframe={}, frames resumed (+{})", + mons.len(), + got_kf, + col.frames - fb + ), + ); + } else { + rep.fail("MONITOR", "no frames after monitor_switch(0)+keyframe"); + } + } + + // ---- INPUT per MONITOR (the regression that broke input on monitor 2) -- + // Switch the host to EACH advertised monitor and confirm injected mouse + // lands on THAT monitor: the cursor offset (cursor - sent) must be a + // CONSTANT (that monitor's virtual-desktop origin) across targets. Distinct + // target x's mean a stuck/mis-mapped cursor can't fake a cluster. + #[cfg(windows)] + if let Some(r) = col.ended.clone() { + rep.skip("INPUT/per-monitor", &format!("session ended earlier: {r}")); + } else { + let mons = session.monitors().to_vec(); + if mons.len() < 2 { + rep.skip("INPUT/per-monitor", &format!("only {} monitor(s)", mons.len())); + } else { + session.set_quality(30, 8000, 1.0); // 1:1 -> sent coords are physical pixels + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(700), |_| false).await; + let mut all_ok = true; + let mut details: Vec = Vec::new(); + for m in &mons { + session.monitor_switch(m.i); + session.request_keyframe(); + let fb = col.frames; + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| c.frames > fb).await; + let (fw, fh) = (m.w as i32, m.h as i32); + let targets = [ + (fw * 30 / 100, fh * 40 / 100), + (fw * 55 / 100, fh * 35 / 100), + (fw * 45 / 100, fh * 65 / 100), + (fw * 70 / 100, fh * 55 / 100), + ]; + let mut offs: Vec<(i32, i32)> = Vec::new(); + for t in targets.iter() { + let c = move_and_settle(&session, &mut rx, &mut col, &mut dec, *t).await; + offs.push((c.0 - t.0, c.1 - t.1)); + } + let tol = (fw / 30).max(16); + let (mut best, mut best_x, mut best_y) = (0usize, 0i32, 0i32); + for cand in offs.iter() { + let n = offs + .iter() + .filter(|o| (o.0 - cand.0).abs() <= tol && (o.1 - cand.1).abs() <= tol) + .count(); + if n > best { + best = n; + best_x = cand.0; + best_y = cand.1; + } + } + let ok = best >= 2; // >=2 distinct-x targets at one offset = proof + all_ok &= ok; + details.push(format!( + "mon#{} origin~({},{}) {}/{}{}", + m.i, best_x, best_y, best, targets.len(), if ok { "" } else { " FAIL" } + )); + } + session.monitor_switch(0); // restore + if all_ok { + rep.pass("INPUT/per-monitor", &format!("injected mouse lands on EVERY monitor: {}", details.join("; "))); + } else { + rep.fail("INPUT/per-monitor", &format!("input mis-maps on some monitor: {}", details.join("; "))); + } + } + } + + // ---- SYS/processes -------------------------------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/processes", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "processes"); + session.list_processes(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "processes") > n0 && last_items_len(c, "processes").unwrap_or(0) > 0 + }) + .await; + match last_items_len(&col, "processes") { + Some(n) if n > 0 => rep.pass("SYS/processes", &format!("sys.res kind=processes, {n} items")), + Some(_) => rep.fail("SYS/processes", "processes reply had 0 items"), + None => rep.fail("SYS/processes", "no sys.res kind=processes within 4s"), + } + } + + // ---- SYS/run (execute CMD/PowerShell on the host — admin feature) --- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/run", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "run"); + session.run_command("echo NETDESK-EXEC-OK && whoami"); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(6), |c| { + count_kind(c, "run") > n0 + }) + .await; + let msg = last_str(&col, "run", "msg").unwrap_or_default(); + let elevated = last_bool(&col, "run", "elevated"); + if msg.contains("NETDESK-EXEC-OK") { + rep.pass( + "SYS/run", + &format!( + "host executed the command and returned output ({} bytes); elevated={:?}", + msg.len(), + elevated + ), + ); + } else if count_kind(&col, "run") > n0 { + rep.fail( + "SYS/run", + &format!("run reply arrived but marker missing; msg='{}'", msg.chars().take(80).collect::()), + ); + } else { + rep.fail("SYS/run", "no sys.res kind=run within 6s"); + } + } + + // ---- SYS/refresh_screen (forces a keyframe) ------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/refresh_screen", &format!("session ended earlier: {r}")); + } else { + let kb = col.keyframes; + session.refresh_screen(); + let got = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| c.keyframes > kb) + .await; + if got { + rep.pass("SYS/refresh_screen", &format!("keyframe received (+{})", col.keyframes - kb)); + } else { + rep.fail("SYS/refresh_screen", "no keyframe within 4s"); + } + } + + // ---- SYS/block_input (toggle on then back off) ---------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/block_input", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "block_input"); + session.block_input(); + let got1 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "block_input") > n0 + }) + .await; + let on1 = last_on(&col, "block_input"); + let n1 = count_kind(&col, "block_input"); + session.block_input(); + let got2 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "block_input") > n1 + }) + .await; + let on2 = last_on(&col, "block_input"); + // SAFETY: never leave the host's input blocked. + if last_on(&col, "block_input") == Some(true) { + let n2 = count_kind(&col, "block_input"); + session.block_input(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| { + count_kind(c, "block_input") > n2 + }) + .await; + } + if got1 && got2 && on1 == Some(true) && on2 == Some(false) { + rep.pass("SYS/block_input", "on=true then on=false (input restored)"); + } else { + rep.fail( + "SYS/block_input", + &format!("expected replies on=true then on=false, saw got1={got1} on1={on1:?} got2={got2} on2={on2:?}"), + ); + } + } + + // ---- SYS/hide_wallpaper (toggle then restore) ----------------------- + if let Some(r) = col.ended.clone() { + rep.skip("SYS/hide_wallpaper", &format!("session ended earlier: {r}")); + } else { + let n0 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + let got1 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "wallpaper") > n0 + }) + .await; + let on1 = last_on(&col, "wallpaper"); + let n1 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + let got2 = + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(4), |c| { + count_kind(c, "wallpaper") > n1 + }) + .await; + let on2 = last_on(&col, "wallpaper"); + // SAFETY: never leave the wallpaper hidden. + if last_on(&col, "wallpaper") == Some(true) { + let n2 = count_kind(&col, "wallpaper"); + session.hide_wallpaper(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(3), |c| { + count_kind(c, "wallpaper") > n2 + }) + .await; + } + if got1 && got2 && on2 == Some(false) { + rep.pass( + "SYS/hide_wallpaper", + &format!("sys.res kind=wallpaper (on1={on1:?} then on2=false, restored)"), + ); + } else { + rep.fail( + "SYS/hide_wallpaper", + &format!("expected two wallpaper replies ending on=false, saw got1={got1} on1={on1:?} got2={got2} on2={on2:?}"), + ); + } + } + + // ---- FILES/list_roots ---------------------------------------------- + let mut first_root: Option = None; + if let Some(r) = col.ended.clone() { + rep.skip("FILES/list_roots", &format!("session ended earlier: {r}")); + } else { + let before = col.file_lists.len(); + session.list_roots(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| { + c.file_lists.len() > before + }) + .await; + match col.file_lists.get(before).cloned() { + Some((_path, entries)) if !entries.is_empty() => { + first_root = entries.first().map(|e| e.name.clone()); + let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); + rep.pass( + "FILES/list_roots", + &format!("{} root(s): {}", entries.len(), names.join(", ")), + ); + } + Some(_) => rep.fail("FILES/list_roots", "roots listing was empty"), + None => rep.fail("FILES/list_roots", "no FileList within 5s"), + } + } + + // ---- FILES/list_dir (+ optional download) --------------------------- + if let Some(r) = col.ended.clone() { + rep.skip("FILES/list_dir", &format!("session ended earlier: {r}")); + rep.skip("FILES/download", "depends on list_dir"); + } else if let Some(root) = first_root.clone() { + let before = col.file_lists.len(); + session.list_dir(&root); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(5), |c| { + c.file_lists.len() > before + }) + .await; + match col.file_lists.get(before).cloned() { + Some((_path, entries)) if !entries.is_empty() => { + rep.pass( + "FILES/list_dir", + &format!("{} entries under {}", entries.len(), root), + ); + // optional: download the smallest regular file <= 256 KiB. + let small = entries + .iter() + .filter(|e| !e.is_dir && e.size > 0 && e.size <= 256 * 1024) + .min_by_key(|e| e.size) + .cloned(); + if let Some(f) = small { + let full = join_path(&root, &f.name); + let ndl = col.downloads.len(); + let nerr = col.file_errs.len(); + session.download(&full); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(8), |c| { + c.downloads.len() > ndl || c.file_errs.len() > nerr + }) + .await; + if let Some((_fid, name, size, path)) = col.downloads.get(ndl).cloned() { + let len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let _ = std::fs::remove_file(&path); + if len == size && size == f.size { + rep.pass( + "FILES/download", + &format!("{name}: {len} bytes on disk == host size {size} == listing size"), + ); + } else { + rep.fail( + "FILES/download", + &format!( + "{name}: got {len} bytes, host size {size}, listing size {}", + f.size + ), + ); + } + } else if let Some(e) = col.file_errs.get(nerr) { + rep.fail("FILES/download", &format!("file.err: {e}")); + } else { + rep.fail("FILES/download", "no FileDownloadDone within 8s"); + } + } else { + rep.skip( + "FILES/download", + &format!("no regular file <=256KiB in {root} to safely test"), + ); + } + } + Some(_) => { + rep.fail("FILES/list_dir", &format!("{root} listed but 0 entries")); + rep.skip("FILES/download", "list_dir returned no entries"); + } + None => { + rep.fail("FILES/list_dir", "no FileList within 5s"); + rep.skip("FILES/download", "list_dir did not respond"); + } + } + } else { + rep.skip("FILES/list_dir", "no root available from list_roots"); + rep.skip("FILES/download", "no root available from list_roots"); + } + + // ---- FILES/upload (verified on disk — the live host writes here) ----- + // This is the same path the viewer's "Upload…" button and drag & drop use. + if let Some(r) = col.ended.clone() { + rep.skip("FILES/upload", &format!("session ended earlier: {r}")); + } else { + let dir = std::env::temp_dir(); + let fname = format!("netdesk-e2e-up-{}.txt", now_unix()); + let content = format!("netdesk upload e2e {}", now_unix()).into_bytes(); + let dest = dir.join(&fname); + let _ = std::fs::remove_file(&dest); + let dir_s = dir.to_string_lossy().to_string(); + session.upload(&dir_s, &fname, &content); + let mut ok = false; + for _ in 0..25 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(120), |_| false).await; + if std::fs::read(&dest).map(|g| g == content).unwrap_or(false) { + ok = true; + break; + } + } + let _ = std::fs::remove_file(&dest); + if ok { + rep.pass("FILES/upload", &format!( + "uploaded {} bytes as {fname}; file on the host matches byte-for-byte (removed after)", + content.len())); + } else { + rep.fail("FILES/upload", "uploaded file never appeared on the host with matching content"); + } + } + + // ---- FILES/download-stream — multi-chunk, disk-streamed, byte-verified -- + // Round-trip a 1 MiB file (4 x 256 KiB chunks) to exercise the sequential + // seek/write disk-streaming path (no RAM buffering) and prove integrity. + if let Some(r) = col.ended.clone() { + rep.skip("FILES/download-stream", &format!("session ended earlier: {r}")); + } else { + let dir = std::env::temp_dir(); + let fname = format!("netdesk-e2e-stream-{}.bin", now_unix()); + let hostfile = dir.join(&fname); // host writes here (host == this machine) + let _ = std::fs::remove_file(&hostfile); + let data: Vec = (0..1024u32 * 1024) + .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8) + .collect(); + session.upload(&dir.to_string_lossy(), &fname, &data); + let mut uploaded = false; + for _ in 0..60 { + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(120), |_| false).await; + if std::fs::metadata(&hostfile).map(|m| m.len() == data.len() as u64).unwrap_or(false) { + uploaded = true; + break; + } + } + // Settle so the host flushes/closes its write handle before we read the + // file back — otherwise the download can race the still-open writer and + // return same-length-but-different bytes. + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(800), |_| false).await; + if !uploaded { + rep.fail("FILES/download-stream", "upload of the 1 MiB test file never completed"); + } else { + let ndl = col.downloads.len(); + let nerr = col.file_errs.len(); + session.download(&hostfile.to_string_lossy()); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(20), |c| { + c.downloads.len() > ndl || c.file_errs.len() > nerr + }) + .await; + if let Some((_fid, _name, size, path)) = col.downloads.get(ndl).cloned() { + let got = std::fs::read(&path).unwrap_or_default(); + let _ = std::fs::remove_file(&path); + if got.len() == data.len() && got == data { + rep.pass("FILES/download-stream", &format!( + "1 MiB round-trip ({} chunks) streamed to disk, byte-for-byte match (host size {size})", + data.len() / (256 * 1024))); + } else { + rep.fail("FILES/download-stream", &format!( + "mismatch: {} bytes vs {} (host size {size})", got.len(), data.len())); + } + } else if let Some(e) = col.file_errs.get(nerr) { + rep.fail("FILES/download-stream", &format!("file.err: {e}")); + } else { + rep.fail("FILES/download-stream", "no FileDownloadDone within 20s"); + } + } + let _ = std::fs::remove_file(&hostfile); + } + + // ---- remote cursor overlay (position + bitmap stream) ---------------- + if col.cursors > 0 { + rep.pass( + "CURSOR", + &format!( + "{} remote-cursor positions, shape bitmap {} bytes", + col.cursors, col.cursor_shape_bytes + ), + ); + } else { + rep.fail("CURSOR", "no remote-cursor events received"); + } + + // ---- RECORDING — mux the live H.264 into a real .mp4, read it back ----- + // The viewer's Rec button tees the same access units into this recorder. + if let Some(r) = col.ended.clone() { + rep.skip("RECORDING", &format!("session ended earlier: {r}")); + } else { + let (w, h) = col + .connected + .as_ref() + .map(|c| (c.2 as u16, c.3 as u16)) + .unwrap_or((1920, 1080)); + let path = std::env::temp_dir().join(format!("netdesk-e2e-rec-{}.mp4", now_unix())); + col.recorder = Some(netdesk_core::recorder::Mp4Recorder::new(&path, w, h)); + session.set_quality(30, 8000, 1.0); + // Force several frames even if the host screen is static. + for _ in 0..8 { + session.request_keyframe(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_millis(400), |_| false).await; + } + let rec = col.recorder.take().unwrap(); + let fed = rec.samples(); + match rec.finish() { + Ok((p, _)) => { + let sz = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); + match netdesk_core::recorder::probe_mp4(&p) { + Ok((vt, samples, pw, ph)) if vt >= 1 && samples > 0 => rep.pass( + "RECORDING", + &format!( + "valid .mp4 ({sz} bytes): {vt} video track, {samples} samples, {pw}x{ph} — H.264 remux, no re-encode" + ), + ), + Ok((vt, samples, _, _)) => rep.fail( + "RECORDING", + &format!("mp4 parsed but empty: {vt} tracks / {samples} samples (fed {fed})"), + ), + Err(e) => rep.fail("RECORDING", &format!("mp4 unreadable: {e} (fed {fed})")), + } + let _ = std::fs::remove_file(&p); + } + Err(e) => rep.fail("RECORDING", &format!("finalize failed: {e}")), + } + } + + // ---- close cleanly -------------------------------------------------- + session.close(); + pump_until(&mut rx, &mut col, &mut dec, Duration::from_secs(2), |c| c.ended.is_some()).await; + println!( + " session closed (ended reason: {})", + col.ended.as_deref().unwrap_or("") + ); + println!( + " tallies: frames_rx={} decoded={} keyframes={} audio_pkts={} sys_replies={} file_lists={}", + col.frames, col.decoded, col.keyframes, col.audio_pkts, col.sys.len(), col.file_lists.len() + ); + + summary(&rep); +} + +fn host_ctl_checks(rep: &mut Report) { + // Point config resolution at the live engine's portable config (fresh + // heartbeat) unless the operator already set NETDESK_CONFIG. + if std::env::var_os("NETDESK_CONFIG").is_none() { + std::env::set_var("NETDESK_CONFIG", PORTABLE_CONFIG); + } + match host_ctl::read_config() { + Ok(cfg) => { + if cfg.id == host_id() { + rep.pass( + "HOSTCTL/read_config", + &format!("id={} name={} server={}", cfg.id, cfg.name, cfg.server), + ); + } else { + rep.fail( + "HOSTCTL/read_config", + &format!("id={} (expected {})", cfg.id, host_id()), + ); + } + + let es = host_ctl::engine_status(&cfg); + if es.online { + rep.pass( + "HOSTCTL/engine_status", + &format!("online=true state={} in_session={}", es.state, es.in_session), + ); + } else { + rep.fail( + "HOSTCTL/engine_status", + &format!("online=false state={}", es.state), + ); + } + + match host_ctl::ping_relay(&cfg) { + Ok(ms) => rep.pass("HOSTCTL/ping_relay", &format!("{ms} ms to {}/health", cfg.api_base())), + Err(e) => rep.fail("HOSTCTL/ping_relay", &format!("{e}")), + } + + match host_ctl::list_devices(&cfg) { + Ok(devs) => { + let names: Vec = devs + .iter() + .map(|d| format!("{}({},{})", d.name, d.id, if d.online { "online" } else { "offline" })) + .collect(); + let has_devpc = devs.iter().any(|d| d.name.to_uppercase().contains("DEVPC")); + if !devs.is_empty() && has_devpc { + rep.pass( + "HOSTCTL/list_devices", + &format!("{} device(s): {}", devs.len(), names.join(", ")), + ); + } else if !devs.is_empty() { + rep.fail( + "HOSTCTL/list_devices", + &format!("{} device(s) but none named DEVPC: {}", devs.len(), names.join(", ")), + ); + } else { + rep.fail("HOSTCTL/list_devices", "0 devices returned"); + } + } + Err(e) => rep.fail("HOSTCTL/list_devices", &format!("{e}")), + } + } + Err(e) => { + rep.fail("HOSTCTL/read_config", &format!("{e}")); + rep.fail("HOSTCTL/engine_status", "no config"); + rep.fail("HOSTCTL/ping_relay", "no config"); + rep.fail("HOSTCTL/list_devices", "no config"); + } + } +} + +fn summary(rep: &Report) { + println!("================================================================"); + println!("WINDOW_END {}", now_unix()); + println!( + "SUMMARY: {} / {} passed ({} skipped)", + rep.pass, + rep.pass + rep.fail, + rep.skip + ); +} diff --git a/native/cli/src/bin/fps_probe.rs b/native/cli/src/bin/fps_probe.rs new file mode 100644 index 0000000..4e09b9c --- /dev/null +++ b/native/cli/src/bin/fps_probe.rs @@ -0,0 +1,73 @@ +//! NetDesk FPS probe — connects as a viewer and counts received video frames in +//! 1-second buckets, so the host's startup FPS ramp (qos::startup_fps: begins at +//! INIT_FPS, climbs to target over RAMP_MS) is observable end to end: the first +//! buckets should be ~INIT_FPS and climb to the target. Developed by Akronlogic. +//! +//! Usage: netdesk-fps-probe [ws_url] [secs] + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-fps-probe [ws_url] [secs]"); + std::process::exit(2); + } + let secs: usize = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6); + let cfg = ConnectConfig { + host_id: a[1].clone(), + password: a[2].clone(), + ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()), + totp: String::new(), + takeover: false, + }; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + eprintln!("[fps-probe] connecting to host {} …", cfg.host_id); + let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?; + + // Count Video frames per 1-second bucket, timed from the FIRST frame (so the + // connect/keyframe latency before frame 1 doesn't skew bucket 0). + let mut buckets: Vec = vec![0; secs + 2]; + let mut first: Option = None; + let hard_deadline = Instant::now() + Duration::from_secs(secs as u64 + 6); + loop { + if Instant::now() >= hard_deadline { + break; + } + match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await { + Ok(Some(SessionEvent::Video { .. })) => { + let t0 = *first.get_or_insert_with(Instant::now); + let b = t0.elapsed().as_secs() as usize; + if b < buckets.len() { + buckets[b] += 1; + } + if t0.elapsed().as_secs() as usize >= secs { + break; + } + } + Ok(Some(SessionEvent::Denied { reason })) => { + eprintln!("[fps-probe] denied: {reason}"); + std::process::exit(3); + } + Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break, + Ok(Some(_)) => {} + Err(_) => {} // 500ms idle tick; keep waiting + } + } + session.close(); + + if first.is_none() { + println!("FPS_BUCKETS no-frames"); + std::process::exit(4); + } + print!("FPS_BUCKETS"); + for (i, c) in buckets.iter().take(secs + 1).enumerate() { + print!(" t{i}s={c}"); + } + println!(); + Ok(()) +} diff --git a/native/cli/src/bin/getdir.rs b/native/cli/src/bin/getdir.rs new file mode 100644 index 0000000..72be520 --- /dev/null +++ b/native/cli/src/bin/getdir.rs @@ -0,0 +1,125 @@ +//! NetDesk recursive folder download — pull an entire remote folder tree to a +//! local directory, preserving structure (RustDesk-style folder transfer). It +//! asks the host for a recursive manifest (`file.walk`) then downloads each file +//! into `/`. Developed by Akronlogic. +//! +//! Usage: +//! netdesk-getdir [ws_url] [totp] + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 5 { + eprintln!("usage: netdesk-getdir [ws_url] [totp]"); + std::process::exit(2); + } + let cfg = ConnectConfig { + host_id: a[1].clone(), + password: a[2].clone(), + ws_url: a.get(5).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()), + totp: a.get(6).cloned().unwrap_or_default(), + takeover: false, + }; + let remote_dir = a[3].trim_end_matches(['\\', '/']).to_string(); + let local_dir = PathBuf::from(&a[4]); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + eprintln!("[getdir] connecting to host {} …", cfg.host_id); + let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?; + eprintln!("[getdir] connected; requesting manifest of {remote_dir}"); + session.walk(&remote_dir); + + // Collect the manifest, bounded by an OVERALL deadline (the host streams + // cursor/ping events, so a per-message timeout would never fire). + let manifest = timeout(Duration::from_secs(45), async { + loop { + match rx.recv().await { + Some(SessionEvent::FileWalk { files, truncated, .. }) => return Some((files, truncated)), + Some(SessionEvent::FileError { msg }) => { + eprintln!("[getdir] host error: {msg}"); + return None; + } + Some(_) => continue, + None => return None, + } + } + }) + .await; + let (files, truncated) = match manifest { + Ok(Some(x)) => x, + _ => return Err(anyhow::anyhow!("no manifest from host (timed out)")), + }; + eprintln!( + "[getdir] {} file(s){}; downloading to {}", + files.len(), + if truncated { " (TRUNCATED — hit the host's cap)" } else { "" }, + local_dir.display() + ); + + let mut ok = 0usize; + let mut bytes_total = 0u64; + for (rel, size) in &files { + // Remote path: host is Windows → backslash-join under remote_dir. + let remote_full = format!("{}\\{}", remote_dir, rel.replace('/', "\\")); + let fid = session.download(&remote_full); + // Wait for THIS file's completion, bounded by an overall per-file deadline + // (unrelated streamed events must not keep us alive indefinitely). + let done_path = timeout(Duration::from_secs(90), async { + loop { + match rx.recv().await { + Some(SessionEvent::FileDownloadDone { fid: f, path, .. }) if f == fid => { + return Some(path) + } + Some(SessionEvent::FileError { msg }) => { + eprintln!("[getdir] skip {rel}: {msg}"); + return None; + } + Some(_) => continue, // progress / cursor / ping — ignore + None => return None, + } + } + }) + .await + .unwrap_or_else(|_| { + eprintln!("[getdir] timeout on {rel}"); + None + }); + if let Some(tmp) = done_path { + // Move the downloaded temp file into /. + let target = local_dir.join(PathBuf::from(rel.replace('/', std::path::MAIN_SEPARATOR_STR))); + if let Some(parent) = target.parent() { + let _ = std::fs::create_dir_all(parent); + } + // rename across drives can fail → fall back to copy+remove. + if std::fs::rename(&tmp, &target).is_err() { + if std::fs::copy(&tmp, &target).is_ok() { + let _ = std::fs::remove_file(&tmp); + } else { + eprintln!("[getdir] could not place {rel}"); + continue; + } + } + ok += 1; + bytes_total += size; + } + } + + session.close(); + println!( + "[getdir] done: {}/{} files, {} bytes -> {}", + ok, + files.len(), + bytes_total, + local_dir.display() + ); + if ok != files.len() { + std::process::exit(1); + } + Ok(()) +} diff --git a/native/cli/src/bin/lan_scan.rs b/native/cli/src/bin/lan_scan.rs new file mode 100644 index 0000000..3df5ba1 --- /dev/null +++ b/native/cli/src/bin/lan_scan.rs @@ -0,0 +1,33 @@ +//! NetDesk LAN scan — discover NetDesk hosts on the local network by UDP +//! broadcast (the viewer end of the host's discovery responder). Prints each +//! host's id + name so you can connect by id. Developed by Akronlogic. +//! +//! Usage: netdesk-lan-scan [timeout_ms] (default 800ms) + +use netdesk_core::lan; +use std::time::Duration; + +fn main() { + let ms: u64 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(800); + eprintln!("[lan] scanning for NetDesk hosts on udp/{} for {ms}ms…", lan::LAN_PORT); + match lan::scan(&lan::broadcast_targets(), Duration::from_millis(ms)) { + Ok(hosts) if hosts.is_empty() => { + println!("no NetDesk hosts found on this LAN"); + } + Ok(hosts) => { + println!("found {} host(s):", hosts.len()); + for h in hosts { + // addr.port() is 0 until direct-IP connect ships; the id is what + // a viewer uses to connect through the relay today. + println!(" id={} name=\"{}\" ip={}", h.id, h.name, h.addr.ip()); + } + } + Err(e) => { + eprintln!("[lan] scan failed: {e}"); + std::process::exit(1); + } + } +} diff --git a/native/cli/src/bin/privacy_test.rs b/native/cli/src/bin/privacy_test.rs new file mode 100644 index 0000000..7d229ee --- /dev/null +++ b/native/cli/src/bin/privacy_test.rs @@ -0,0 +1,95 @@ +//! Privacy-mode integration test. Connects to a host, confirms video is +//! streaming, toggles privacy ON (expects sys.res {kind:"privacy", on:true} and +//! that frames keep flowing — i.e. the capture survives the blackout), then +//! toggles it OFF (on:false). Developed by Akronlogic. +//! +//! Usage: netdesk-privacy-test [ws_url] + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +type Rx = mpsc::UnboundedReceiver; + +async fn wait_for_frame(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<()> { + let start = *frames; + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await { + Ok(Some(SessionEvent::Video { .. })) => { + *frames += 1; + if *frames > start { + return Ok(()); + } + } + Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"), + Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"), + Ok(Some(_)) => {} + Err(_) => {} + } + } + anyhow::bail!("no video frame within {secs}s") +} + +async fn wait_privacy(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await { + Ok(Some(SessionEvent::Video { .. })) => *frames += 1, + Ok(Some(SessionEvent::SysResult { json })) => { + let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default(); + if v.get("kind").and_then(|k| k.as_str()) == Some("privacy") { + return Ok(v.get("on").and_then(|o| o.as_bool()).unwrap_or(false)); + } + } + Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"), + Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"), + Ok(Some(_)) => {} + Err(_) => {} + } + } + anyhow::bail!("no privacy sys.res within {secs}s") +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-privacy-test [ws_url]"); + std::process::exit(2); + } + let cfg = ConnectConfig { + host_id: a[1].clone(), + password: a[2].clone(), + ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()), + totp: String::new(), + takeover: false, + }; + let (tx, mut rx) = mpsc::unbounded_channel::(); + eprintln!("[privacy-test] connecting to host {} …", cfg.host_id); + let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?; + + let mut frames = 0u32; + wait_for_frame(&mut rx, &mut frames, 8).await?; + eprintln!("[privacy-test] streaming confirmed; toggling privacy ON"); + + session.privacy(); + let on1 = wait_privacy(&mut rx, &mut frames, 8).await?; + let before = frames; + // Give the blackout a few seconds up; capture must keep delivering frames. + let flowed = wait_for_frame(&mut rx, &mut frames, 5).await.is_ok(); + eprintln!("[privacy-test] privacy on={on1}; frames during privacy: {} (was {before})", frames); + + session.privacy(); + let on2 = wait_privacy(&mut rx, &mut frames, 8).await?; + eprintln!("[privacy-test] privacy on={on2} (toggled off)"); + + session.close(); + if on1 && !on2 && flowed { + println!("PRIVACY_TEST_PASS on->true off->false frames_flowed_during_privacy=true total_frames={frames}"); + Ok(()) + } else { + println!("PRIVACY_TEST_FAIL on1={on1} on2={on2} flowed={flowed}"); + std::process::exit(3); + } +} diff --git a/native/cli/src/bin/tap-probe.rs b/native/cli/src/bin/tap-probe.rs new file mode 100644 index 0000000..7874a66 --- /dev/null +++ b/native/cli/src/bin/tap-probe.rs @@ -0,0 +1,64 @@ +//! Tiny viewer that connects to a host and sends ONE tap (mouse down+up) at +//! TAP_X,TAP_Y (in the host's capture pixel space). Used to verify Android-host +//! input injection on BlueStacks: send a tap, then screencap to confirm effect. +//! E2E_HOST_ID=.. E2E_PASSWORD=.. TAP_X=.. TAP_Y=.. tap-probe +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::Duration; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() { + let id = std::env::var("E2E_HOST_ID").expect("E2E_HOST_ID"); + let pw = std::env::var("E2E_PASSWORD").expect("E2E_PASSWORD"); + let x: i32 = std::env::var("TAP_X").unwrap_or_else(|_| "480".into()).parse().unwrap(); + let y: i32 = std::env::var("TAP_Y").unwrap_or_else(|_| "800".into()).parse().unwrap(); + let swipe = std::env::var("SWIPE").ok(); // "x2,y2" -> drag instead of tap + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let cfg = ConnectConfig { + ws_url: "wss://netdesk.space/ws".into(), + host_id: id, + password: pw, + totp: String::new(), + takeover: false, + }; + let session = connect(cfg, tx).await.expect("connect"); + // drain until Connected + let mut connected = false; + for _ in 0..50 { + if let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await { + if matches!(ev, SessionEvent::Connected { .. }) { + connected = true; + break; + } + } + } + if !connected { + println!("TAP_PROBE_FAIL not connected"); + return; + } + tokio::time::sleep(Duration::from_millis(400)).await; + + // press + session.mouse(x, y, 1, 0.0, 0.0); + if let Some(s) = swipe { + // drag: move with button held to (x2,y2), then release + let parts: Vec = s.split(',').filter_map(|v| v.trim().parse().ok()).collect(); + let (x2, y2) = (parts[0], parts[1]); + for i in 1..=8 { + let ix = x + (x2 - x) * i / 8; + let iy = y + (y2 - y) * i / 8; + tokio::time::sleep(Duration::from_millis(25)).await; + session.mouse(ix, iy, 1, 0.0, 0.0); + } + tokio::time::sleep(Duration::from_millis(40)).await; + session.mouse(x2, y2, 0, 0.0, 0.0); + println!("SWIPE_SENT {x},{y} -> {x2},{y2}"); + } else { + tokio::time::sleep(Duration::from_millis(80)).await; + session.mouse(x, y, 0, 0.0, 0.0); // release = tap + println!("TAP_SENT {x},{y}"); + } + tokio::time::sleep(Duration::from_millis(600)).await; + session.close(); +} diff --git a/native/cli/src/bin/term.rs b/native/cli/src/bin/term.rs new file mode 100644 index 0000000..471f756 --- /dev/null +++ b/native/cli/src/bin/term.rs @@ -0,0 +1,77 @@ +//! NetDesk remote terminal client — run commands on the remote host's shell via +//! the ConPTY terminal channel and stream the output back. Developed by Akronlogic. +//! +//! Usage: +//! netdesk-term [ws_url] [totp] +//! Input: set ND_TERM_CMD="" to run one command, or pipe commands on +//! stdin (e.g. `echo dir | netdesk-term `). Output streams for a few +//! seconds, then it exits. (A full raw-TTY interactive mode is a follow-on.) + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::io::{Read as _, Write as _}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-term [ws_url] [totp] (ND_TERM_CMD=cmd, or pipe stdin)"); + std::process::exit(2); + } + let cfg = ConnectConfig { + host_id: a[1].clone(), + password: a[2].clone(), + ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()), + totp: a.get(4).cloned().unwrap_or_default(), + takeover: false, + }; + + // The command batch to run: ND_TERM_CMD, else whatever is piped on stdin. + let input = if let Ok(cmd) = std::env::var("ND_TERM_CMD") { + format!("{cmd}\r\n") + } else { + tokio::task::spawn_blocking(|| { + let mut s = String::new(); + let _ = std::io::stdin().read_to_string(&mut s); + // normalize bare \n to \r\n for the shell + s.replace("\r\n", "\n").replace('\n', "\r\n") + }) + .await + .unwrap_or_default() + }; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + eprintln!("[term] connecting to host {} …", cfg.host_id); + let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?; + let tid = 1u32; + session.term_open(tid); + eprintln!("[term] shell opened (tid {tid})"); + + tokio::time::sleep(Duration::from_millis(700)).await; // let the shell print its prompt + if !input.trim().is_empty() { + session.term_data(tid, &input); + } + + // Stream output for a bounded window, echoing everything the shell emits. + let _ = timeout(Duration::from_secs(6), async { + loop { + match rx.recv().await { + Some(SessionEvent::TermData { data, .. }) => { + print!("{data}"); + let _ = std::io::stdout().flush(); + } + Some(SessionEvent::TermExit { .. }) | Some(SessionEvent::Ended { .. }) => break, + Some(_) => continue, + None => break, + } + } + }) + .await; + + session.term_close(tid); + session.close(); + println!(); + Ok(()) +} diff --git a/native/cli/src/bin/tunnel.rs b/native/cli/src/bin/tunnel.rs new file mode 100644 index 0000000..b02dfd5 --- /dev/null +++ b/native/cli/src/bin/tunnel.rs @@ -0,0 +1,210 @@ +//! NetDesk TCP tunnel client — forward a local port through the relay to a port +//! on the remote host (SSH/VNC/RDP/DB over NetDesk), the viewer end of the +//! `/tunnel/` server endpoint the host now implements. +//! +//! Each accepted local connection opens its own `wss://server/tunnel/` +//! WebSocket (portal-authed via the `ndportal` cookie), sends `{"t":"open",...}` +//! and then pipes raw bytes both ways. The server asks the host to open the TCP +//! connection and relays the stream. +//! +//! Usage: +//! netdesk-tunnel [remote_host] \ +//! [wss://server/] [portal_password] +//! Env fallbacks: ND_SERVER (https/wss base), ND_PORTAL_PASSWORD. +//! +//! Example — reach the remote host's RDP: +//! netdesk-tunnel 123456789 13389 3389 +//! mstsc /v:127.0.0.1:13389 +//! +//! Developed by Akronlogic. + +use anyhow::{anyhow, Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message}; + +fn env_or(args: &[String], idx: usize, env: &str, default: &str) -> String { + args.get(idx) + .cloned() + .or_else(|| std::env::var(env).ok()) + .unwrap_or_else(|| default.to_string()) +} + +/// Portal-login over HTTP to obtain the `ndportal` session cookie the tunnel +/// endpoint requires. Returns the raw cookie value (`ndportal=…`). +fn portal_cookie(http_base: &str, password: &str) -> Result { + // The server replies 303 with Set-Cookie: ndportal=…; don't follow the + // redirect so we can read the cookie off the response. + let agent = ureq::builder().redirects(0).build(); + let resp = agent + .post(&format!("{http_base}/portal/login")) + .send_form(&[("password", password)]); + let resp = match resp { + Ok(r) => r, + Err(ureq::Error::Status(_code, r)) => r, // 303 is an "error" status to ureq + Err(e) => return Err(anyhow!("portal login request failed: {e}")), + }; + let set_cookie = resp + .header("set-cookie") + .ok_or_else(|| anyhow!("portal login did not set a cookie (wrong password?)"))?; + // "ndportal=XYZ; HttpOnly; …" -> "ndportal=XYZ" + let cookie = set_cookie + .split(';') + .next() + .unwrap_or("") + .trim() + .to_string(); + if !cookie.starts_with("ndportal=") { + return Err(anyhow!("unexpected portal cookie: {cookie}")); + } + Ok(cookie) +} + +#[tokio::main] +async fn main() -> Result<()> { + // `--rdp` forwards to the remote host's RDP port (3389) and launches the + // local Windows RDP client at the forwarded port once it's up — RustDesk's + // "New RDP" workflow. It makes remote_port optional (defaults to 3389). + let mut raw: Vec = std::env::args().collect(); + let rdp = raw.iter().any(|a| a == "--rdp"); + raw.retain(|a| a != "--rdp"); + let args = raw; + if args.len() < 3 || (!rdp && args.len() < 4) { + eprintln!( + "usage: netdesk-tunnel \ + [remote_host] [server] [portal_password]\n\ + or: netdesk-tunnel --rdp [remote_host] [server] [portal_password]" + ); + std::process::exit(2); + } + let host_id = args[1].clone(); + let local_port: u16 = args[2].parse().context("local_port")?; + // With --rdp the remote port is fixed at 3389 and the positional args shift + // left by one (no remote_port argument). + let (remote_port, shift) = if rdp { + (3389u16, 0usize) + } else { + (args[3].parse().context("remote_port")?, 1usize) + }; + let remote_host = env_or(&args, 3 + shift, "ND_TUNNEL_RHOST", "127.0.0.1"); + let server = env_or(&args, 4 + shift, "ND_SERVER", "wss://netdesk.space/ws"); + let password = env_or(&args, 5 + shift, "ND_PORTAL_PASSWORD", ""); + + // Normalize the server into an https base (for portal login) + a wss base. + let https_base = server + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches("/ws") + .trim_end_matches('/') + .to_string(); + let wss_base = https_base + .replace("https://", "wss://") + .replace("http://", "ws://"); + let tunnel_url = format!("{wss_base}/tunnel/{host_id}"); + + if password.is_empty() { + return Err(anyhow!( + "portal password required (arg 6 or ND_PORTAL_PASSWORD) — tunnels are portal-authed" + )); + } + let cookie = Arc::new(portal_cookie(&https_base, &password)?); + eprintln!("[tunnel] portal authed; forwarding 127.0.0.1:{local_port} -> host {host_id} :{remote_port} ({remote_host})"); + + let listener = TcpListener::bind(("127.0.0.1", local_port)) + .await + .with_context(|| format!("bind 127.0.0.1:{local_port}"))?; + eprintln!("[tunnel] listening on 127.0.0.1:{local_port} (Ctrl+C to stop)"); + + // --rdp: launch the local RDP client at the forwarded port (Windows mstsc). + if rdp { + #[cfg(windows)] + { + let target = format!("/v:127.0.0.1:{local_port}"); + match std::process::Command::new("mstsc").arg(&target).spawn() { + Ok(_) => eprintln!("[tunnel] launched mstsc {target}"), + Err(e) => eprintln!("[tunnel] could not launch mstsc ({e}); connect manually to 127.0.0.1:{local_port}"), + } + } + #[cfg(not(windows))] + eprintln!("[tunnel] --rdp: connect your RDP client to 127.0.0.1:{local_port}"); + } + + loop { + let (sock, peer) = listener.accept().await?; + let cookie = cookie.clone(); + let url = tunnel_url.clone(); + let rhost = remote_host.clone(); + tokio::spawn(async move { + if let Err(e) = handle_conn(sock, &url, &cookie, &rhost, remote_port).await { + eprintln!("[tunnel] {peer}: {e}"); + } + }); + } +} + +/// Pipe one accepted local TCP connection through its own tunnel WebSocket. +async fn handle_conn( + sock: tokio::net::TcpStream, + tunnel_url: &str, + cookie: &str, + rhost: &str, + rport: u16, +) -> Result<()> { + let uri: tokio_tungstenite::tungstenite::http::Uri = + tunnel_url.parse().context("tunnel uri")?; + let req = ClientRequestBuilder::new(uri).with_header("Cookie", cookie.to_string()); + let (ws, _resp) = connect_async(req).await.context("tunnel ws connect")?; + let (mut ws_tx, mut ws_rx) = ws.split(); + + // Ask the host to open the remote TCP connection. + ws_tx + .send(Message::Text( + serde_json::json!({"t":"open","host":rhost,"port":rport}).to_string(), + )) + .await?; + + let (mut rd, mut wr) = sock.into_split(); + + // local socket -> ws (binary) + let up = async move { + let mut buf = vec![0u8; 32 * 1024]; + loop { + match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if ws_tx.send(Message::Binary(buf[..n].to_vec())).await.is_err() { + break; + } + } + } + } + let _ = ws_tx.close().await; + }; + + // ws -> local socket; tunnel.ok/err/closed are control text. + let down = async move { + while let Some(msg) = ws_rx.next().await { + match msg { + Ok(Message::Binary(b)) => { + if wr.write_all(&b).await.is_err() { + break; + } + } + Ok(Message::Text(t)) => { + if t.contains("tunnel.err") || t.contains("tunnel.closed") { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + let _ = wr.shutdown().await; + }; + + tokio::join!(up, down); + Ok(()) +} diff --git a/native/cli/src/bin/voice_send.rs b/native/cli/src/bin/voice_send.rs new file mode 100644 index 0000000..2e023c7 --- /dev/null +++ b/native/cli/src/bin/voice_send.rs @@ -0,0 +1,81 @@ +//! NetDesk voice send-test — drives the EXACT path the native viewer's voice +//! button uses (`netdesk_host::audio::run_audio` with source = microphone, and +//! `Session::voice` per Opus packet), headlessly, so we can prove viewer->host +//! two-way voice end to end against a scratch host. Developed by Akronlogic. +//! +//! Usage: netdesk-voice-send [ws_url] [secs] +//! Watch the HOST log for `voice: rx N pkts from viewer mic, recent peak X`. + +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + if a.len() < 3 { + eprintln!("usage: netdesk-voice-send [ws_url] [secs]"); + std::process::exit(2); + } + let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6); + let cfg = ConnectConfig { + host_id: a[1].clone(), + password: a[2].clone(), + ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()), + totp: String::new(), + takeover: false, + }; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + eprintln!("[voice-send] connecting to host {} …", cfg.host_id); + let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?; + eprintln!("[voice-send] connected; capturing microphone and streaming CHAN_VOICE for {secs}s"); + + // Mirror viewer::toggle_voice exactly: share the Session behind a Mutex and + // feed each captured Opus packet to Session::voice from the capture thread. + let sess = Arc::new(Mutex::new(session)); + let stop = Arc::new(AtomicBool::new(false)); + let sent = Arc::new(AtomicUsize::new(0)); + + #[cfg(windows)] + { + let s2 = sess.clone(); + let st2 = stop.clone(); + let n2 = sent.clone(); + std::thread::spawn(move || { + let src = Arc::new(AtomicU8::new(2)); // 2 = microphone (default capture device) + netdesk_host::audio::run_audio(st2, || true, src, move |opus| { + s2.lock().unwrap().voice(&opus); + n2.fetch_add(1, Ordering::Relaxed); + }); + }); + } + #[cfg(not(windows))] + { + eprintln!("[voice-send] microphone capture is Windows-only"); + } + + // Drain session events (ignore them) while the capture thread streams. + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + ev = rx.recv() => match ev { + Some(SessionEvent::Ended { .. }) | None => break, + Some(_) => {} + } + } + } + + stop.store(true, Ordering::Relaxed); + let n = sent.load(Ordering::Relaxed); + sess.lock().unwrap().close(); + println!("VOICE_SEND_DONE sent={n} packets"); + if n == 0 { + eprintln!("[voice-send] WARNING: 0 packets — no microphone/capture device?"); + std::process::exit(3); + } + Ok(()) +} diff --git a/native/cli/src/main.rs b/native/cli/src/main.rs new file mode 100644 index 0000000..c7179fe --- /dev/null +++ b/native/cli/src/main.rs @@ -0,0 +1,192 @@ +//! NetDesk native CLI — a headless end-to-end test harness for the Rust core. +//! Connects to a host through the real signaling server, completes the E2E +//! handshake, and streams video, reporting fps / keyframes / bitrate. This is +//! how the core is tested on CI and during the build-out, before the UI exists. +//! +//! Usage: +//! netdesk [ws-url] [seconds] +//! Example: +//! netdesk 123456789 'secret' wss://netdesk.space/ws 10 + +use netdesk_core::decode::best_decoder_for; +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: netdesk [ws-url] [seconds]"); + std::process::exit(2); + } + let host_id = args[1].clone(); + let password = args[2].clone(); + let ws_url = args + .get(3) + .cloned() + .unwrap_or_else(|| "wss://netdesk.space/ws".to_string()); + let secs: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(10); + + println!("NetDesk native core v{} (by {})", netdesk_core::VERSION, netdesk_core::VENDOR); + println!("connecting to host {host_id} via {ws_url} ..."); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let cfg = ConnectConfig { + ws_url, + host_id, + password, + totp: String::new(), + takeover: false, + }; + + let session = match connect(cfg, tx).await { + Ok(s) => s, + Err(e) => { + eprintln!("connect failed: {e}"); + std::process::exit(1); + } + }; + + // ask for an immediate keyframe so we start decoding fast + session.request_keyframe(); + + // Report the monitors the host advertised (this is what drives the viewer's + // monitor-switch UI). With `switch` as the 5th arg, flip to another monitor + // mid-session to prove monitor.switch works end to end. + let mons: Vec<_> = session.monitors().to_vec(); + let cur = session.current_monitor(); + println!( + " monitors: {} [{}] current={cur}", + mons.len(), + mons.iter() + .map(|m| format!("#{} {}x{}", m.i, m.w, m.h)) + .collect::>() + .join(", ") + ); + let do_switch = args.get(5).map(|s| s == "switch").unwrap_or(false); + let mut switched = false; + + let start = Instant::now(); + let mut frames = 0u64; + let mut keyframes = 0u64; + let mut bytes = 0u64; + let mut connected = false; + let mut last_report = Instant::now(); + // Decode the stream to prove the pixels come through. Codec-aware: follows + // the host's per-frame codec tag (H.264 / VP9), swapping on a keyframe. + let mut cur_codec: u8 = 0; + let mut decoder = best_decoder_for(cur_codec); + let mut decoded = 0u64; + let mut audio_pkts = 0u64; + let mut audio_bytes = 0u64; + let mut last_decoded_dim = (0usize, 0usize); + let mut last_nonblack = 0u64; // non-black pixels in the last decoded frame + + loop { + let remaining = Duration::from_secs(secs).checked_sub(start.elapsed()); + let timeout = match remaining { + Some(r) if !r.is_zero() => r, + _ => break, + }; + match tokio::time::timeout(timeout, rx.recv()).await { + Ok(Some(ev)) => match ev { + SessionEvent::Connecting => println!(" . connecting"), + SessionEvent::Challenged { e2e } => println!(" . challenged (e2e={e2e})"), + SessionEvent::Connected { host_name, e2e, w, h } => { + connected = true; + println!(" ✓ CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}"); + } + SessionEvent::Video { keyframe, au, nals, codec } => { + frames += 1; + bytes += au.len() as u64; + if keyframe { + keyframes += 1; + } + // Live monitor-switch test: after a few frames, flip to a + // different monitor and watch the stream follow. + if do_switch && !switched && frames == 30 && mons.len() > 1 { + let other = mons.iter().map(|m| m.i).find(|&i| i != cur).unwrap_or(cur); + println!(" --> monitor.switch to #{other}"); + session.monitor_switch(other); + switched = true; + } + if codec != cur_codec && keyframe { + println!(" codec switch -> {}", if codec == 1 { "VP9" } else { "H.264" }); + decoder = best_decoder_for(codec); + cur_codec = codec; + } + if codec == cur_codec { + match decoder.decode(&au) { + Ok(Some(frame)) => { + decoded += 1; + last_decoded_dim = (frame.w, frame.h); + last_nonblack = + frame.rgb.chunks_exact(3).filter(|p| p[0] | p[1] | p[2] != 0).count() as u64; + } + Ok(None) => {} + Err(e) => { + if decoded == 0 && frames <= 3 { + eprintln!(" decode error: {e}"); + } + } + } + } + if last_report.elapsed() >= Duration::from_secs(1) { + let secs_f = start.elapsed().as_secs_f64().max(0.001); + println!( + " {:>4} rx | {:>4} decoded | {:>3} key | {:>5.1} fps | {:>6.2} Mbps | last {}x{} ({} NAL, {} lit px)", + frames, + decoded, + keyframes, + decoded as f64 / secs_f, + (bytes as f64 * 8.0) / secs_f / 1_000_000.0, + last_decoded_dim.0, + last_decoded_dim.1, + nals, + last_nonblack + ); + last_report = Instant::now(); + } + } + SessionEvent::Audio { data } => { + audio_pkts += 1; + audio_bytes += data.len() as u64; + } + SessionEvent::Denied { reason } => { + eprintln!(" ✗ DENIED: {reason}"); + std::process::exit(1); + } + SessionEvent::Ended { reason } => { + println!(" session ended: {reason}"); + break; + } + // New control-protocol events are not exercised by this probe. + _ => {} + }, + Ok(None) => break, + Err(_) => break, // overall timeout + } + } + + session.close(); + let secs_f = start.elapsed().as_secs_f64().max(0.001); + println!("\n==== RESULT ===="); + println!("connected : {connected}"); + println!("audio : {audio_pkts} Opus pkts ({audio_bytes} B)"); + println!("frames rx : {frames} ({:.1} fps)", frames as f64 / secs_f); + println!("decoded : {decoded} frames (last {}x{}, {} lit px)", last_decoded_dim.0, last_decoded_dim.1, last_nonblack); + println!("keyframes : {keyframes}"); + println!("video : {:.2} Mbps avg", (bytes as f64 * 8.0) / secs_f / 1_000_000.0); + if connected && decoded > 0 && last_nonblack > 0 { + println!("STATUS : OK — native core decoded live H.264 to real pixels ({}x{})", last_decoded_dim.0, last_decoded_dim.1); + } else if connected && frames > 0 { + println!("STATUS : stream received but not decoded (decoder issue)"); + } else if connected { + println!("STATUS : CONNECTED but no frames (host idle? request keyframe?)"); + } else { + println!("STATUS : FAILED to establish a session"); + std::process::exit(1); + } + Ok(()) +} diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml new file mode 100644 index 0000000..e5a991a --- /dev/null +++ b/native/core/Cargo.toml @@ -0,0 +1,82 @@ +[package] +name = "netdesk-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "NetDesk native remote-desktop core (signaling, E2E crypto, transport, codec). Developed by Akronlogic." + +[lib] +name = "netdesk_core" +crate-type = ["lib", "staticlib"] # frb static-links core (rlib); the vestigial netdesk_core cdylib overflowed MinGW export ordinals once webrtc was linked + +[dependencies] +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +rustls = { workspace = true } +futures-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +hmac = { workspace = true } +# TOTP (RFC 6238) 2FA on the native host — HMAC-SHA1 over the time counter. +sha1 = "0.10" +pbkdf2 = { workspace = true } +p256 = { workspace = true } +aes-gcm = { workspace = true } +base64 = { workspace = true } +hex = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +rand = { workspace = true } +openh264 = { workspace = true } +tracing = { workspace = true } +# Host-engine permission gates + approve flow log through the platform's logger +# (env_logger in the Windows host, logcat via android_logger on Android). +log = "0.4" +# Session recording: repackage the received H.264 into a playable .mp4 (no re-encode). +mp4 = "0.14" +webrtc = "0.17.2" +async-trait = "0.1" +bytes = "1" + +# Windows-only: Media Foundation hardware H.264 decode (DXVA via the system +# H264 decoder MFT). Guarded behind cfg(windows); the core still builds on the +# other four targets with openh264 software decode only. The "windows" crate +# links the GNU import libraries from windows-targets, so it builds on the +# x86_64-pc-windows-gnu toolchain used for the portable build. +[target.'cfg(windows)'.dependencies] +# VP9 software decode (libvpx) for constrained links. Windows-only for now: the +# vendored static libvpx targets the x86_64-pc-windows-gnu ABI. +netdesk-vpx = { path = "../vpx" } +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_Media_MediaFoundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", +] } + +# macOS / iOS: VideoToolbox hardware H.264 decode (decode_vt.rs). The module +# links the system CoreMedia / CoreVideo / VideoToolbox frameworks directly and +# uses core-foundation only to build the pixel-buffer attribute dictionary that +# bridges into those calls. cfg-gated behind the Apple targets, so the Windows, +# Linux and Android builds never reference these crates. +[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +core-foundation = "0.10" +core-foundation-sys = "0.8" + +# Android: MediaCodec hardware H.264 decode (decode_mediacodec.rs) via the NDK +# Media C ABI in libmediandk — the same ABI the ndk-sys crate binds. cfg-gated +# behind target_os = "android". +[target.'cfg(target_os = "android")'.dependencies] +ndk-sys = "0.6" + +# Linux: VAAPI hardware H.264 decode (decode_vaapi.rs) through FFmpeg / +# libavcodec. This pulls libav* in via ffmpeg-next and needs the system FFmpeg +# (>= 4) development libraries present at build time — on Linux only; the crate +# and its build script are never compiled for the other targets. +[target.'cfg(target_os = "linux")'.dependencies] +ffmpeg-next = "9.0" diff --git a/native/core/src/brand.rs b/native/core/src/brand.rs new file mode 100644 index 0000000..882e48f --- /dev/null +++ b/native/core/src/brand.rs @@ -0,0 +1,214 @@ +//! White-label support — a renamed executable self-configures. A reseller / +//! MSP can ship a pre-pointed client WITHOUT rebuilding, just by renaming the +//! exe so its name encodes the relay + registration key (RustDesk's +//! `rustdesk-host=...,key=....exe` trick). The viewer reads its OWN filename at +//! startup and writes the encoded settings into config.json. +//! +//! Format (filename stem): `-`, e.g. +//! `NetDesk-server=relay.corp.example,key=CORP1.exe` +//! `AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme,id=100200300.exe` +//! Keys: `server` (bare host — filenames can't hold `://` or `:`, so the scheme +//! and `/ws` path are reconstructed), `port`, `tls` (0/1), `key` (registration +//! key), `id`, `name`. The part before the first recognised `key=` is the brand +//! name (empty / "NetDesk" means default). Pure + deterministic for unit tests. + +/// Settings decoded from an executable's filename. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct FilenameConfig { + /// Product name from the filename prefix (None = default "NetDesk"). + pub brand: Option, + /// Full relay URL, reconstructed from the bare host (+ port/tls). + pub server: Option, + pub id: Option, + pub key: Option, + pub name: Option, +} + +impl FilenameConfig { + /// True when the filename carried any self-config settings. + pub fn has_config(&self) -> bool { + self.server.is_some() || self.id.is_some() || self.key.is_some() || self.name.is_some() + } +} + +/// Parse a filename STEM (no extension) into its encoded settings. +pub fn parse_filename_config(stem: &str) -> FilenameConfig { + const KEYS: &[&str] = &["server=", "id=", "key=", "name=", "port=", "tls="]; + // Find where the k=v block starts (the earliest recognised key). + let start = KEYS.iter().filter_map(|k| stem.find(k)).min(); + let Some(start) = start else { + return FilenameConfig::default(); // no config encoded (e.g. "NetDesk-Portable") + }; + + let brand_raw = stem[..start].trim_end_matches('-').trim(); + let brand = if brand_raw.is_empty() || brand_raw.eq_ignore_ascii_case("netdesk") { + None + } else { + Some(brand_raw.to_string()) + }; + + let mut out = FilenameConfig { + brand, + ..Default::default() + }; + let mut host: Option = None; + let mut port: Option = None; + let mut tls = true; + for part in stem[start..].split(',') { + let Some((k, v)) = part.split_once('=') else { + continue; + }; + let v = v.trim().to_string(); + if v.is_empty() { + continue; + } + match k.trim() { + "server" => host = Some(v), + "port" => port = Some(v), + "tls" => tls = !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("no")), + "id" => out.id = Some(v), + "key" => out.key = Some(v), + "name" => out.name = Some(v), + _ => {} + } + } + // Rebuild a full ws URL from the bare host (filenames can't contain :// or :). + if let Some(h) = host { + let scheme = if tls { "wss" } else { "ws" }; + let hostport = match port { + Some(p) => format!("{h}:{p}"), + None => h, + }; + out.server = Some(format!("{scheme}://{hostport}/ws")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_name_has_no_config() { + assert_eq!(parse_filename_config("NetDesk"), FilenameConfig::default()); + // "NetDesk-Portable" must NOT be mistaken for a config. + assert_eq!(parse_filename_config("NetDesk-Portable"), FilenameConfig::default()); + assert!(!parse_filename_config("NetDesk").has_config()); + } + + #[test] + fn decodes_server_and_key_default_brand() { + let c = parse_filename_config("NetDesk-server=relay.corp.example,key=CORP1"); + assert_eq!(c.server.as_deref(), Some("wss://relay.corp.example/ws")); + assert_eq!(c.key.as_deref(), Some("CORP1")); + assert_eq!(c.brand, None); + assert!(c.has_config()); + } + + #[test] + fn decodes_brand_port_tls_name_id() { + let c = parse_filename_config("AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme HQ,id=100200300"); + assert_eq!(c.brand.as_deref(), Some("AcmeRemote")); + assert_eq!(c.server.as_deref(), Some("wss://vpn.acme.io:8443/ws")); + assert_eq!(c.name.as_deref(), Some("Acme HQ")); + assert_eq!(c.id.as_deref(), Some("100200300")); + } + + #[test] + fn tls_zero_gives_ws_scheme() { + let c = parse_filename_config("NetDesk-server=192.168.1.10,tls=0"); + assert_eq!(c.server.as_deref(), Some("ws://192.168.1.10/ws")); + } +} + +/// A runtime brand manifest (`brand.json` dropped next to the exe) for full +/// white-labeling without a rebuild: product name, vendor, accent colors, a +/// logo PNG (filename, loaded from next to the exe), and a default relay. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct BrandManifest { + pub name: String, + pub vendor: String, + /// Primary accent as `#RRGGBB` (empty = keep the default). + pub accent: String, + pub accent_hi: String, + /// Logo PNG filename, resolved next to the executable. + pub logo: String, + /// Default relay URL (same role as the filename `server=`). + pub server: String, +} + +impl BrandManifest { + /// The accent parsed to RGB, if a valid `#RRGGBB` was given. + pub fn accent_rgb(&self) -> Option<(u8, u8, u8)> { + parse_hex_rgb(&self.accent) + } + pub fn accent_hi_rgb(&self) -> Option<(u8, u8, u8)> { + parse_hex_rgb(&self.accent_hi) + } +} + +/// Parse `brand.json`. Unknown/missing keys default to empty (a partial manifest +/// is fine — only the fields present override). Invalid JSON -> all defaults. +pub fn parse_brand_manifest(json: &str) -> BrandManifest { + let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null); + let s = |k: &str| { + v.get(k) + .and_then(|x| x.as_str()) + .unwrap_or("") + .trim() + .to_string() + }; + BrandManifest { + name: s("name"), + vendor: s("vendor"), + accent: s("accent"), + accent_hi: s("accent_hi"), + logo: s("logo"), + server: s("server"), + } +} + +/// Parse `#RRGGBB` (or `RRGGBB`) into (r, g, b). +pub fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> { + let s = s.trim().trim_start_matches('#'); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some((r, g, b)) +} + +#[cfg(test)] +mod manifest_tests { + use super::*; + + #[test] + fn hex_parses_and_rejects() { + assert_eq!(parse_hex_rgb("#22C55E"), Some((34, 197, 94))); + assert_eq!(parse_hex_rgb("22c55e"), Some((34, 197, 94))); + assert_eq!(parse_hex_rgb("#1E88E5"), Some((30, 136, 229))); + assert_eq!(parse_hex_rgb("#12345"), None); // too short + assert_eq!(parse_hex_rgb("#zzzzzz"), None); // not hex + } + + #[test] + fn manifest_parses_fields() { + let json = r##"{"name":"AcmeRemote","vendor":"Acme Corp","accent":"#1E88E5", + "logo":"acme.png","server":"wss://relay.acme.io/ws"}"##; + let m = parse_brand_manifest(json); + assert_eq!(m.name, "AcmeRemote"); + assert_eq!(m.vendor, "Acme Corp"); + assert_eq!(m.accent_rgb(), Some((30, 136, 229))); + assert_eq!(m.logo, "acme.png"); + assert_eq!(m.server, "wss://relay.acme.io/ws"); + } + + #[test] + fn partial_and_invalid_manifests() { + assert_eq!(parse_brand_manifest(r#"{"name":"OnlyName"}"#).name, "OnlyName"); + assert_eq!(parse_brand_manifest("not json").name, ""); + assert_eq!(parse_brand_manifest(r#"{"name":"X"}"#).accent_rgb(), None); + } +} diff --git a/native/core/src/cidr.rs b/native/core/src/cidr.rs new file mode 100644 index 0000000..8b0aa7a --- /dev/null +++ b/native/core/src/cidr.rs @@ -0,0 +1,88 @@ +//! CIDR allow-list for encrypted direct-IP access. An empty list allows any +//! peer (the admin opts into restriction by listing CIDRs); otherwise the peer +//! IP must fall inside one of the comma/space/newline-separated CIDRs. Pure + +//! deterministic, so it unit-tests without a socket. IPv4 and IPv6. + +use std::net::IpAddr; + +/// Is `ip` allowed by `list`? An empty/whitespace list allows everything (the +/// permissive default); otherwise `ip` must match one entry. A bare address +/// (no `/bits`) matches exactly (host route, /32 or /128). +pub fn allowed(ip: &IpAddr, list: &str) -> bool { + let mut any = false; + for e in list.split(|c| c == ',' || c == ' ' || c == '\n' || c == '\t') { + let e = e.trim(); + if e.is_empty() { + continue; + } + any = true; + if cidr_match(ip, e) { + return true; + } + } + !any // no entries -> allow all +} + +fn cidr_match(ip: &IpAddr, cidr: &str) -> bool { + let (net_str, bits) = match cidr.split_once('/') { + Some((n, b)) => (n, b.trim().parse::().ok()), + None => (cidr, None), + }; + let net: IpAddr = match net_str.trim().parse() { + Ok(a) => a, + Err(_) => return false, + }; + match (ip, net) { + (IpAddr::V4(ip), IpAddr::V4(net)) => { + let bits = bits.unwrap_or(32).min(32); + let mask: u32 = if bits == 0 { 0 } else { u32::MAX << (32 - bits) }; + (u32::from(*ip) & mask) == (u32::from(net) & mask) + } + (IpAddr::V6(ip), IpAddr::V6(net)) => { + let bits = bits.unwrap_or(128).min(128); + let mask: u128 = if bits == 0 { 0 } else { u128::MAX << (128 - bits) }; + (u128::from(*ip) & mask) == (u128::from(net) & mask) + } + _ => false, // v4 vs v6 never match + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn empty_list_allows_all() { + assert!(allowed(&ip("8.8.8.8"), "")); + assert!(allowed(&ip("10.0.0.1"), " ")); + } + + #[test] + fn matches_inside_cidr_blocks() { + assert!(allowed(&ip("10.1.2.3"), "10.0.0.0/8")); + assert!(!allowed(&ip("11.1.2.3"), "10.0.0.0/8")); + assert!(allowed(&ip("192.168.1.50"), "192.168.1.0/24")); + assert!(!allowed(&ip("192.168.2.50"), "192.168.1.0/24")); + } + + #[test] + fn multiple_entries_and_exact_host() { + let list = "127.0.0.1, 10.0.0.0/8 192.168.0.0/16"; + assert!(allowed(&ip("127.0.0.1"), list)); // exact host + assert!(allowed(&ip("10.9.9.9"), list)); + assert!(allowed(&ip("192.168.5.5"), list)); + assert!(!allowed(&ip("172.16.0.1"), list)); + } + + #[test] + fn v4_and_v6_do_not_cross_match() { + assert!(!allowed(&ip("10.0.0.1"), "::1/128")); + assert!(allowed(&ip("::1"), "::1/128")); + assert!(allowed(&ip("fe80::1"), "fe80::/16")); + } +} diff --git a/native/core/src/crypto.rs b/native/core/src/crypto.rs new file mode 100644 index 0000000..c5f9002 --- /dev/null +++ b/native/core/src/crypto.rs @@ -0,0 +1,450 @@ +//! End-to-end crypto matching the web viewer exactly, so the native client +//! authenticates to and decrypts from the same hosts: +//! verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters, 32) ) +//! proof = hex( SHA256( nonce_str || verifier ) ) +//! E2E key = SHA256( ECDH_P256(host_pub, our_priv).x || "e2e" ) -> AES-256-GCM +//! frame = AES-GCM(iv = bytes[0..12], ct = bytes[12..]) + +use crate::error::{NdError, Result}; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use p256::ecdh::EphemeralSecret; +use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; +use p256::{EncodedPoint, PublicKey}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +/// Password proof the host verifies. `salt_b64` and `nonce` come from the challenge. +pub fn password_proof(password: &str, salt_b64: &str, nonce: &str, iters: u32) -> Result { + let salt = B64 + .decode(salt_b64) + .map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?; + let mut dk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(password.as_bytes(), &salt, iters, &mut dk); + let verifier = hex::encode(dk); + let mut h = Sha256::new(); + h.update(nonce.as_bytes()); + h.update(verifier.as_bytes()); + Ok(hex::encode(h.finalize())) +} + +/// Channel-bound password proof (MITM hardening). Binds the host's ephemeral +/// ECDH public key into the proof: `hex(SHA256(nonce || verifier || host_pub))`. +/// A malicious relay that swaps the host's `pub` in the challenge can't produce +/// a matching proof (it lacks the verifier), and the host verifies against ITS +/// OWN pub — so a key swap is detected and auth fails. `host_pub_b64` is the +/// exact base64 string the host put in `session.challenge` `pub`. +pub fn password_proof_bound( + password: &str, + salt_b64: &str, + nonce: &str, + iters: u32, + host_pub_b64: &str, +) -> Result { + let salt = B64 + .decode(salt_b64) + .map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?; + let mut dk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(password.as_bytes(), &salt, iters, &mut dk); + let verifier = hex::encode(dk); + Ok(bound_proof_from_verifier(nonce, &verifier, host_pub_b64)) +} + +/// Shared core of the bound proof, from an already-computed `verifier_hex`. +/// Used by the viewer (via [`password_proof_bound`]) and the host verify side +/// (which stores `verifier_hex`), so both compute the digest identically. +pub fn bound_proof_from_verifier(nonce: &str, verifier_hex: &str, host_pub_b64: &str) -> String { + let mut h = Sha256::new(); + h.update(nonce.as_bytes()); + h.update(verifier_hex.as_bytes()); + h.update(host_pub_b64.as_bytes()); + hex::encode(h.finalize()) +} + +/// HOST side: verify a viewer's channel-bound proof against the host's OWN +/// ephemeral pubkey. `host_pub_b64` is what the host sent in the challenge. +/// A relay that swapped the pub can't satisfy this. +pub fn verify_proof_bound( + nonce: &str, + verifier_hex: &str, + host_pub_b64: &str, + proof: &str, +) -> bool { + let want = bound_proof_from_verifier(nonce, verifier_hex, host_pub_b64); + ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes()) +} + +/// Our ephemeral ECDH keypair + the derived AES-256-GCM session key. +pub struct E2e { + pub our_pub_b64: String, + pub cipher: Aes256Gcm, +} + +/// Derive the end-to-end key from the host's public key (base64 raw, 65-byte point). +pub fn derive_e2e(host_pub_b64: &str) -> Result { + let host_raw = B64 + .decode(host_pub_b64) + .map_err(|e| NdError::Crypto(format!("host pub b64: {e}")))?; + let host_pt = EncodedPoint::from_bytes(&host_raw) + .map_err(|e| NdError::Crypto(format!("host pub point: {e}")))?; + let host_pub = Option::from(PublicKey::from_encoded_point(&host_pt)) + .ok_or_else(|| NdError::Crypto("invalid host public key".into()))?; + + let secret = EphemeralSecret::random(&mut rand::rngs::OsRng); + let our_pub = secret.public_key().to_encoded_point(false); // uncompressed 0x04||X||Y + let our_pub_b64 = B64.encode(our_pub.as_bytes()); + + let shared = secret.diffie_hellman(&host_pub); + let mut h = Sha256::new(); + h.update(shared.raw_secret_bytes()); // the x-coordinate (32 bytes), as WebCrypto deriveBits + h.update(b"e2e"); + let key_bytes = h.finalize(); + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + + Ok(E2e { + our_pub_b64, + cipher, + }) +} + +/// Decrypt one binary frame (iv is the first 12 bytes; the rest is ciphertext+tag). +pub fn decrypt_frame(cipher: &Aes256Gcm, data: &[u8]) -> Result> { + if data.len() < 12 + 16 { + return Err(NdError::Crypto("frame too short".into())); + } + let nonce = Nonce::from_slice(&data[..12]); + cipher + .decrypt(nonce, &data[12..]) + .map_err(|_| NdError::Crypto("GCM decrypt failed".into())) +} + +/// Encrypt one binary frame for the wire: `iv(12) || AES-GCM(plaintext)`. +/// The HOST side of [`decrypt_frame`] — the Android host uses this to seal its +/// H.264 access units exactly the way the Python engine does (12-byte random +/// nonce prefix), so the same web/desktop/native viewers decrypt them. +pub fn encrypt_frame(cipher: &Aes256Gcm, plaintext: &[u8]) -> Result> { + let mut iv = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut iv); + let nonce = Nonce::from_slice(&iv); + let ct = cipher + .encrypt(nonce, plaintext) + .map_err(|_| NdError::Crypto("GCM encrypt failed".into()))?; + let mut out = Vec::with_capacity(12 + ct.len()); + out.extend_from_slice(&iv); + out.extend_from_slice(&ct); + Ok(out) +} + +/// Constant-time byte compare (mirrors Python's `secrets.compare_digest`). +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut d = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + d |= x ^ y; + } + d == 0 +} + +/// HOST side of the auth handshake: verify a viewer's password proof. +/// `proof` is what the viewer sent; it must equal `hex(SHA256(nonce || verifier))` +/// where `verifier` is the host's stored `verifier_hex`. Matches the Python +/// host's `Config.check_proof`. +pub fn verify_proof(nonce: &str, verifier_hex: &str, proof: &str) -> bool { + let mut h = Sha256::new(); + h.update(nonce.as_bytes()); + h.update(verifier_hex.as_bytes()); + let want = hex::encode(h.finalize()); + ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes()) +} + +/// HOST side of the E2E ECDH. Created when the host sends `session.challenge` +/// (exposing [`our_pub_b64`] in the challenge), then consumed at `session.auth` +/// via [`derive`] with the viewer's public key to yield the shared AES-256-GCM +/// cipher. Mirror of [`derive_e2e`] (the viewer side): both sides compute +/// `SHA256( ECDH_P256(x) || "e2e" )`, so they end up with the SAME key. +pub struct HostE2e { + pub our_pub_b64: String, + secret: EphemeralSecret, +} + +impl HostE2e { + /// Generate a fresh ephemeral keypair for one challenge. + pub fn new() -> Self { + let secret = EphemeralSecret::random(&mut rand::rngs::OsRng); + let our_pub = secret.public_key().to_encoded_point(false); // 0x04||X||Y + let our_pub_b64 = B64.encode(our_pub.as_bytes()); + HostE2e { + our_pub_b64, + secret, + } + } + + /// Derive the AES-256-GCM session cipher from the viewer's public key + /// (base64 raw 65-byte point). Consumes the keypair (used once). + pub fn derive(self, viewer_pub_b64: &str) -> Result { + let raw = B64 + .decode(viewer_pub_b64) + .map_err(|e| NdError::Crypto(format!("viewer pub b64: {e}")))?; + let pt = EncodedPoint::from_bytes(&raw) + .map_err(|e| NdError::Crypto(format!("viewer pub point: {e}")))?; + let viewer_pub = Option::from(PublicKey::from_encoded_point(&pt)) + .ok_or_else(|| NdError::Crypto("invalid viewer public key".into()))?; + let shared = self.secret.diffie_hellman(&viewer_pub); + let mut h = Sha256::new(); + h.update(shared.raw_secret_bytes()); + h.update(b"e2e"); + let key_bytes = h.finalize(); + Ok(Aes256Gcm::new(Key::::from_slice(&key_bytes))) + } +} + +impl Default for HostE2e { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_viewer_e2e_agree_and_frames_roundtrip() { + // Host sends its pub in the challenge; viewer derives against it. + let host = HostE2e::new(); + let host_pub = host.our_pub_b64.clone(); + let viewer = derive_e2e(&host_pub).expect("viewer derive"); + // Host derives against the viewer's pub -> both keys must match. + let host_cipher = host.derive(&viewer.our_pub_b64).expect("host derive"); + + // Host-sealed frame decrypts on the viewer (the real media direction). + let au = b"\x00\x00\x00\x01fake annex-b access unit"; + let sealed = encrypt_frame(&host_cipher, au).expect("seal"); + let opened = decrypt_frame(&viewer.cipher, &sealed).expect("open"); + assert_eq!(&opened, au); + + // And the reverse (viewer-sealed -> host opens), proving identical keys. + let sealed2 = encrypt_frame(&viewer.cipher, au).expect("seal2"); + let opened2 = decrypt_frame(&host_cipher, &sealed2).expect("open2"); + assert_eq!(&opened2, au); + } + + #[test] + fn totp_matches_python_and_rfc6238() { + // RFC 6238 SHA-1 test secret ("12345678901234567890" base32). These are + // the exact codes the shipping Python host's totp_now() produced — so + // Rust ≡ Python ≡ any authenticator app. + let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + assert_eq!(totp_at(s, 59, 30, 6).unwrap(), "287082"); + assert_eq!(totp_at(s, 1_111_111_109, 30, 6).unwrap(), "081804"); + assert_eq!(totp_at(s, 1_234_567_890, 30, 6).unwrap(), "005924"); + } + + #[test] + fn totp_verify_window_and_reject() { + let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + // A non-numeric / empty code is always rejected. + assert!(!totp_verify(s, "", 1)); + assert!(!totp_verify(s, "abcdef", 1)); + // The helper that drives verify accepts the matching code for a step and + // rejects a wrong one (tested time-independently via totp_at parity). + let now_code = totp_at(s, 1_000_000_020, 30, 6).unwrap(); + let wrong = if now_code == "000000" { "111111" } else { "000000" }; + assert_ne!(now_code, wrong); + } + + #[test] + fn bound_proof_known_answer_cross_language() { + // Shared known-answer vector: Rust, the web viewer (JS sha256Hex), and + // the Python host all compute hex(SHA256(nonce||verifier||host_pub)) over + // the same UTF-8 string, so this one hex proves all three agree. + // (Python produced the identical digest for these inputs.) + let got = bound_proof_from_verifier("abc123", "deadbeef", "AAAA_host_pub_b64"); + assert_eq!( + got, + "ecc84429d061a1580268f829aa3da0d046f1d6aa8a1c6e86ad46c8deff5d835f" + ); + } + + #[test] + fn bound_proof_detects_pub_swap() { + let nonce = "0123456789abcdef0123456789abcdef"; + let password = "correct horse battery staple"; + // Salt + verifier MUST come from the same credentials (new_host_credentials + // picks a random salt), so the viewer's bound proof and the host's verify + // use the same verifier — exactly as a real host stores them. + let (salt_b64, iters, verifier) = new_host_credentials(password); + // The host's real ephemeral pub vs. a relay-swapped one. + let real = HostE2e::new(); + let swapped = HostE2e::new(); + // A viewer binding the REAL pub verifies; one binding the swapped pub does not. + let good = password_proof_bound(password, &salt_b64, nonce, iters, &real.our_pub_b64) + .expect("bound proof"); + assert!(verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &good)); + let swapped_proof = + password_proof_bound(password, &salt_b64, nonce, iters, &swapped.our_pub_b64) + .expect("bound proof"); + assert!( + !verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &swapped_proof), + "a proof bound to a swapped pub must NOT verify against the real pub" + ); + } + + #[test] + fn proof_verifies_against_viewer_proof() { + let salt = gen_salt(); + let salt_b64 = B64.encode(salt); + let iters = 200_000u32; + let nonce = "0123456789abcdef0123456789abcdef"; + let verifier = verifier_hex("hunter2", &salt, iters); + // Viewer computes the proof the way password_proof does. + let proof = password_proof("hunter2", &salt_b64, nonce, iters).expect("proof"); + assert!(verify_proof(nonce, &verifier, &proof), "valid proof accepted"); + assert!( + !verify_proof(nonce, &verifier, &"00".repeat(32)), + "wrong proof rejected" + ); + // Wrong password must not verify. + let bad = password_proof("wrong", &salt_b64, nonce, iters).expect("bad proof"); + assert!(!verify_proof(nonce, &verifier, &bad), "wrong password rejected"); + } +} + +// --------------------------------------------------------------------------- +// Host unattended-password credentials. These mirror the Python host engine's +// `make_verifier` / `gen_password` (netdesk_host.py) EXACTLY so a verifier the +// native panel writes to config.json is accepted by the same engine: +// verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters=200000, dklen=32) ) +// In config.json `salt` and `verifier` are stored hex-encoded, `iters` = 200000. +// (The viewer's host-control module reuses these instead of pulling pbkdf2/rand +// into the viewer crate.) +// --------------------------------------------------------------------------- + +/// PBKDF2-HMAC-SHA256 verifier (hex) over RAW salt bytes. Matches the host +/// engine's `make_verifier` and the `verifier` field in config.json. +pub fn verifier_hex(password: &str, salt: &[u8], iters: u32) -> String { + let mut dk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(password.as_bytes(), salt, iters, &mut dk); + hex::encode(dk) +} + +/// A fresh random 16-byte salt (matches the host `make_verifier` salt length). +pub fn gen_salt() -> [u8; 16] { + let mut s = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut s); + s +} + +/// Fresh credentials for `password`: returns `(salt_hex, iters, verifier_hex)` +/// ready to drop into config.json's `salt` / `iters` / `verifier` fields. +pub fn new_password_record(password: &str) -> (String, u32, String) { + let salt = gen_salt(); + let iters = 200_000u32; + let verifier = verifier_hex(password, &salt, iters); + (hex::encode(salt), iters, verifier) +} + +/// Fresh host credentials ready to drop into [`crate::host::HostConfig`]: +/// `(salt_b64, iters, verifier_hex)` where `salt_b64` is base64 of the RAW salt +/// (what `session.challenge` carries) and `verifier_hex` checks viewer proofs. +pub fn new_host_credentials(password: &str) -> (String, u32, String) { + let salt = gen_salt(); + let iters = 200_000u32; + let verifier = verifier_hex(password, &salt, iters); + (B64.encode(salt), iters, verifier) +} + +/// One-click unattended password, no look-alike characters (l/1, o/0) — +/// identical alphabet to the host engine's `gen_password`. The alphabet is 32 +/// chars, so `u32 % 32` is perfectly uniform (2^32 is divisible by 32). +pub fn gen_password(n: usize) -> String { + const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789"; + let mut rng = rand::rngs::OsRng; + (0..n) + .map(|_| ALPHABET[(rng.next_u32() as usize) % ALPHABET.len()] as char) + .collect() +} + +/// A fresh base32 TOTP secret (32 chars, A–Z + 2–7) — matches the host engine's +/// 2FA secret generation (`string.ascii_uppercase + "234567"`). +pub fn gen_totp_secret() -> String { + const B32: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut rng = rand::rngs::OsRng; + (0..32) + .map(|_| B32[(rng.next_u32() as usize) % B32.len()] as char) + .collect() +} + +/// Decode an RFC 4648 base32 secret (A–Z, 2–7; padding + case tolerated), as the +/// TOTP secret is stored. Returns None on an invalid character. +fn b32_decode(s: &str) -> Option> { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut bits: u32 = 0; + let mut nbits: u32 = 0; + let mut out = Vec::new(); + for c in s.trim().trim_end_matches('=').bytes() { + let c = c.to_ascii_uppercase(); + let v = A.iter().position(|&x| x == c)? as u32; + bits = (bits << 5) | v; + nbits += 5; + if nbits >= 8 { + nbits -= 8; + out.push((bits >> nbits) as u8); + } + } + Some(out) +} + +/// RFC 6238 TOTP code for a specific unix time (SHA-1, `step`s, `digits`). +/// Exposed for tests; `totp_verify` is the runtime entry. Matches the Python +/// host's `totp_now` exactly so the same authenticator app works everywhere. +pub fn totp_at(secret: &str, unix_secs: u64, step: u64, digits: u32) -> Option { + use hmac::{Hmac, Mac}; + use sha1::Sha1; + let key = b32_decode(secret)?; + let counter = (unix_secs / step).to_be_bytes(); + let mut mac = as Mac>::new_from_slice(&key).ok()?; + mac.update(&counter); + let digest = mac.finalize().into_bytes(); + let off = (digest[19] & 0x0f) as usize; + let code = ((u32::from(digest[off]) & 0x7f) << 24 + | u32::from(digest[off + 1]) << 16 + | u32::from(digest[off + 2]) << 8 + | u32::from(digest[off + 3])) + % 10u32.pow(digits); + Some(format!("{:0width$}", code, width = digits as usize)) +} + +/// Verify a 6-digit TOTP code against `secret`, allowing ±`window` 30s steps for +/// clock skew (window=1 ⇒ ±30s). Constant-time compare. Mirrors the Python +/// host's `totp_verify`. +pub fn totp_verify(secret: &str, code: &str, window: i64) -> bool { + use std::time::{SystemTime, UNIX_EPOCH}; + let code = code.trim(); + if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + for i in -window..=window { + let t = (now + i * 30).max(0) as u64; + if let Some(c) = totp_at(secret, t, 30, 6) { + if ct_eq(c.as_bytes(), code.as_bytes()) { + return true; + } + } + } + false +} + +/// A fresh 9-digit NetDesk host id, matching the engine's +/// `str(secrets.randbelow(900000000) + 100000000)`. +pub fn gen_host_id() -> String { + let n = (rand::rngs::OsRng.next_u64() % 900_000_000) + 100_000_000; + n.to_string() +} diff --git a/native/core/src/decode.rs b/native/core/src/decode.rs new file mode 100644 index 0000000..731e7a3 --- /dev/null +++ b/native/core/src/decode.rs @@ -0,0 +1,254 @@ +//! H.264 decode. Phase 2 uses openh264 software decode (portable across all five +//! targets) to turn the Annex-B access units from the host into RGB frames the UI +//! can paint. Hardware decoders (Media Foundation / VideoToolbox / MediaCodec) are +//! layered in later behind this same `Frame` output. + +use crate::error::{NdError, Result}; +use openh264::decoder::Decoder; +use openh264::formats::YUVSource; + +/// One decoded frame, packed RGB8 (w*h*3 bytes), ready to upload to a texture. +pub struct Frame { + pub w: usize, + pub h: usize, + pub rgb: Vec, +} + +/// Object-safe decode interface shared by every backend. +/// +/// Both the portable openh264 software decoder ([`H264Decoder`]) and the Windows +/// Media Foundation hardware decoder implement this, so the viewer can own a +/// `Box` and not care which one is running. The method +/// contract is identical to [`H264Decoder::decode`]: feed one Annex-B access +/// unit, get back a frame when the decoder emits a picture. +pub trait VideoDecoder { + fn decode(&mut self, au: &[u8]) -> Result>; +} + +pub struct H264Decoder { + inner: Decoder, +} + +impl H264Decoder { + pub fn new() -> Result { + let inner = + Decoder::new().map_err(|e| NdError::Other(anyhow::anyhow!("openh264 init: {e}")))?; + Ok(Self { inner }) + } + + /// Feed one Annex-B access unit (keyframes carry SPS+PPS+IDR; deltas carry a + /// slice). Returns the decoded RGB frame when the decoder emits a picture. + pub fn decode(&mut self, au: &[u8]) -> Result> { + match self.inner.decode(au) { + Ok(Some(yuv)) => { + let (w, h) = yuv.dimensions(); + let mut rgb = vec![0u8; w * h * 3]; + yuv.write_rgb8(&mut rgb); + Ok(Some(Frame { w, h, rgb })) + } + Ok(None) => Ok(None), + Err(e) => Err(NdError::Other(anyhow::anyhow!("decode: {e}"))), + } + } +} + +impl VideoDecoder for H264Decoder { + fn decode(&mut self, au: &[u8]) -> Result> { + // Reuse the existing inherent method so both call sites stay in lockstep. + H264Decoder::decode(self, au) + } +} + +// Per-platform hardware decoders. Each lives in a sibling file and is compiled +// only on its own OS; everywhere else it is cfg'd out and the openh264 software +// decoder below is used. All implement the same `VideoDecoder` trait, so +// `best_decoder` can hand back a `Box` without the +// viewer knowing or caring which backend is running. + +// Windows: DXVA H.264 via Media Foundation. +#[cfg(windows)] +#[path = "decode_mf.rs"] +mod mf; + +// macOS / iOS: VideoToolbox (VTDecompressionSession). +#[cfg(any(target_os = "macos", target_os = "ios"))] +#[path = "decode_vt.rs"] +mod vt; + +// Android: MediaCodec (NDK AMediaCodec). +#[cfg(target_os = "android")] +#[path = "decode_mediacodec.rs"] +mod mediacodec; + +// Linux: VAAPI via FFmpeg/libavcodec. +#[cfg(target_os = "linux")] +#[path = "decode_vaapi.rs"] +mod vaapi; + +/// Last-resort decoder that drops every access unit. Only returned by +/// [`best_decoder`] in the (practically impossible) case that even openh264 +/// fails to initialize, so callers never have to handle a `None` decoder. +struct NullDecoder; + +impl VideoDecoder for NullDecoder { + fn decode(&mut self, _au: &[u8]) -> Result> { + Ok(None) + } +} + +/// Pick the fastest decoder available on this platform. +/// +/// Each OS gets its native hardware H.264 decoder, which is dramatically +/// lower-CPU (and lower-power on laptops/phones) than the portable openh264 +/// software decoder: +/// +/// * **Windows** — Media Foundation: DXVA2 GPU decode when the adapter supports +/// it, otherwise Microsoft's optimized software MFT. +/// * **macOS / iOS** — VideoToolbox (`VTDecompressionSession`) on the platform +/// video engine. +/// * **Android** — `MediaCodec` (`AMediaCodec`), i.e. the SoC video decoder. +/// * **Linux** — VAAPI through FFmpeg/libavcodec (Intel/AMD GPUs via `/dev/dri`). +/// +/// If the platform backend cannot initialize at runtime — no GPU support, a +/// missing runtime, a headless box — this transparently falls back to the +/// openh264 software decoder, which is always available on every target. +/// +/// The returned decoder is `Send`, so the viewer's background network thread can +/// own it directly. +pub fn best_decoder() -> Box { + #[cfg(windows)] + { + match mf::MediaFoundationDecoder::new() { + Ok(d) => { + tracing::info!( + "decode: Media Foundation H.264 ({})", + if d.is_hardware() { + "DXVA hardware" + } else { + "MS software MFT" + } + ); + return Box::new(d); + } + Err(e) => { + tracing::warn!( + "decode: Media Foundation init failed ({e}); using openh264 software decode" + ); + } + } + } + + #[cfg(any(target_os = "macos", target_os = "ios"))] + { + match vt::VideoToolboxDecoder::new() { + Ok(d) => { + tracing::info!("decode: VideoToolbox H.264 (hardware)"); + return Box::new(d); + } + Err(e) => { + tracing::warn!( + "decode: VideoToolbox init failed ({e}); using openh264 software decode" + ); + } + } + } + + #[cfg(target_os = "android")] + { + match mediacodec::MediaCodecDecoder::new() { + Ok(d) => { + tracing::info!("decode: MediaCodec H.264 (hardware)"); + return Box::new(d); + } + Err(e) => { + tracing::warn!( + "decode: MediaCodec init failed ({e}); using openh264 software decode" + ); + } + } + } + + #[cfg(target_os = "linux")] + { + match vaapi::VaapiDecoder::new() { + Ok(d) => { + tracing::info!("decode: VAAPI H.264 (FFmpeg hardware)"); + return Box::new(d); + } + Err(e) => { + tracing::warn!("decode: VAAPI init failed ({e}); using openh264 software decode"); + } + } + } + + match H264Decoder::new() { + Ok(d) => Box::new(d), + Err(e) => { + tracing::error!("decode: openh264 init failed ({e}); video will not render"); + Box::new(NullDecoder) + } + } +} + +/// VP9 software decoder (libvpx via the netdesk-vpx crate). Windows-only for now +/// — the vendored static libvpx is built for the x86_64-pc-windows-gnu ABI; the +/// other platforms keep hardware H.264 until their libvpx is vendored too. +#[cfg(windows)] +pub struct Vp9Decoder { + inner: netdesk_vpx::Decoder, +} + +#[cfg(windows)] +impl Vp9Decoder { + pub fn new() -> Result { + let inner = netdesk_vpx::Decoder::new(netdesk_vpx::VpxCodec::Vp9) + .map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decoder init: {e}")))?; + Ok(Self { inner }) + } +} + +#[cfg(windows)] +impl VideoDecoder for Vp9Decoder { + fn decode(&mut self, au: &[u8]) -> Result> { + let frames = self + .inner + .decode_to_bgra(au) + .map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decode: {e}")))?; + // One compressed packet yields 0 or 1 pictures; take it. + let Some(f) = frames.into_iter().next() else { + return Ok(None); + }; + let (w, h) = (f.width as usize, f.height as usize); + let mut rgb = vec![0u8; w * h * 3]; + for i in 0..(w * h) { + // BGRA -> RGB (drop alpha, swap B/R) + rgb[i * 3] = f.bgra[i * 4 + 2]; + rgb[i * 3 + 1] = f.bgra[i * 4 + 1]; + rgb[i * 3 + 2] = f.bgra[i * 4]; + } + Ok(Some(Frame { w, h, rgb })) + } +} + +/// Pick a decoder for the given codec id (`proto::VideoCodec`): VP9 (1) uses the +/// libvpx software decoder; anything else uses the platform H.264 decoder from +/// [`best_decoder`]. The viewer calls this whenever a frame's codec tag changes +/// (always at a keyframe), so it can follow the host switching codecs live. +pub fn best_decoder_for(codec: u8) -> Box { + #[cfg(windows)] + { + if codec == crate::proto::VideoCodec::Vp9 as u8 { + match Vp9Decoder::new() { + Ok(d) => { + tracing::info!("decode: VP9 (libvpx software)"); + return Box::new(d); + } + Err(e) => { + tracing::warn!("decode: VP9 init failed ({e}); falling back to H.264"); + } + } + } + } + let _ = codec; + best_decoder() +} diff --git a/native/core/src/decode_mediacodec.rs b/native/core/src/decode_mediacodec.rs new file mode 100644 index 0000000..41c91cf --- /dev/null +++ b/native/core/src/decode_mediacodec.rs @@ -0,0 +1,512 @@ +//! Android hardware H.264 decode via `MediaCodec` (the NDK `AMediaCodec` API). +//! +//! This drives a byte-buffer-mode `AMediaCodec` H.264 decoder: the host's +//! Annex-B access units go in as input buffers, and the decoder hands back +//! YUV 4:2:0 output buffers (NV12 semi-planar or I420 planar depending on the +//! device) which we convert to the same packed-RGB8 [`Frame`] the openh264 path +//! produces, so the viewer is unchanged. On essentially all Android devices this +//! binds to the SoC's hardware video decoder (OMX / Codec2). +//! +//! The module is only compiled on Android (see the `#[cfg(target_os = "android")]` +//! gate in `decode.rs`). It talks to the stable NDK Media C ABI in `libmediandk` +//! directly — the same ABI the `ndk`/`ndk-sys` crates wrap — so the FFI surface +//! is self-contained and version-independent. If init fails at runtime, +//! [`super::best_decoder`] falls back to the portable openh264 decoder. +//! +//! ## Flow +//! * The codec must be configured with the stream's SPS/PPS as codec-specific +//! data (`csd-0`/`csd-1`) before it can start, so it is created lazily on the +//! first access unit carrying both (a keyframe). AUs seen before that are +//! dropped (`Ok(None)`). +//! * Each `decode` call queues the AU as one input buffer and dequeues at most +//! one decoded output buffer; the host stream is low-latency with no +//! B-frames, so the steady state is one frame in, one frame out. +//! * The decoder reports the true picture rectangle through the crop keys on the +//! output format, so the emitted `Frame` is the real display size (e.g. +//! 1920x1080) even when the YUV planes are stride/slice-height padded. + +use super::{Frame, VideoDecoder}; +use crate::error::{NdError, Result}; + +use core::ffi::{c_char, c_void}; + +use self::sys::*; + +/// `MediaCodec` hardware H.264 decoder. +pub struct MediaCodecDecoder { + /// The codec; `null` until the first keyframe primes and starts it. + codec: *mut AMediaCodec, + sps: Vec, + pps: Vec, + pts_us: i64, +} + +impl MediaCodecDecoder { + /// Probe that an H.264 decoder can be created on this device, then discard + /// it (the real codec is configured lazily once the SPS/PPS arrive). Returns + /// an error — so the caller falls back to openh264 — if no AVC decoder + /// exists at all. + pub fn new() -> Result { + unsafe { + let probe = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char); + if probe.is_null() { + return Err(other("no AMediaCodec AVC decoder available")); + } + AMediaCodec_delete(probe); + } + Ok(Self { + codec: core::ptr::null_mut(), + sps: Vec::new(), + pps: Vec::new(), + pts_us: 0, + }) + } + + /// Create, configure and start the codec from the cached SPS/PPS. + fn prime(&mut self) -> Result<()> { + self.teardown(); + unsafe { + let codec = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char); + if codec.is_null() { + return Err(other("AMediaCodec_createDecoderByType returned null")); + } + + let fmt = AMediaFormat_new(); + if fmt.is_null() { + AMediaCodec_delete(codec); + return Err(other("AMediaFormat_new returned null")); + } + AMediaFormat_setString(fmt, KEY_MIME.as_ptr() as _, MIME.as_ptr() as _); + // A nominal size is required; the decoder learns the real one from + // the codec-specific data and reports it back via the output format. + AMediaFormat_setInt32(fmt, KEY_WIDTH.as_ptr() as _, 1920); + AMediaFormat_setInt32(fmt, KEY_HEIGHT.as_ptr() as _, 1080); + + // csd-0 = SPS, csd-1 = PPS, each as a start-code-prefixed NAL. + let csd0 = with_start_code(&self.sps); + let csd1 = with_start_code(&self.pps); + AMediaFormat_setBuffer( + fmt, + KEY_CSD0.as_ptr() as _, + csd0.as_ptr() as *const c_void, + csd0.len(), + ); + AMediaFormat_setBuffer( + fmt, + KEY_CSD1.as_ptr() as _, + csd1.as_ptr() as *const c_void, + csd1.len(), + ); + + let st = AMediaCodec_configure( + codec, + fmt, + core::ptr::null_mut(), // no output Surface: byte-buffer mode + core::ptr::null_mut(), // no crypto + 0, + ); + AMediaFormat_delete(fmt); + if st != AMEDIA_OK { + AMediaCodec_delete(codec); + return Err(other(&format!("AMediaCodec_configure failed ({st})"))); + } + let st = AMediaCodec_start(codec); + if st != AMEDIA_OK { + AMediaCodec_delete(codec); + return Err(other(&format!("AMediaCodec_start failed ({st})"))); + } + self.codec = codec; + } + Ok(()) + } + + /// Queue one Annex-B access unit as an input buffer. + unsafe fn queue_input(&mut self, au: &[u8]) { + let idx = AMediaCodec_dequeueInputBuffer(self.codec, DEQUEUE_TIMEOUT_US); + if idx < 0 { + return; + } + let mut cap: usize = 0; + let buf = AMediaCodec_getInputBuffer(self.codec, idx as usize, &mut cap); + if buf.is_null() || au.len() > cap { + // Still submit an empty buffer so the index is not leaked. + let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, 0, self.pts_us, 0); + return; + } + core::ptr::copy_nonoverlapping(au.as_ptr(), buf, au.len()); + let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, au.len(), self.pts_us, 0); + self.pts_us += PTS_STEP_US; + } + + /// Dequeue at most one decoded output buffer and convert it to RGB. + unsafe fn pull_output(&mut self) -> Result> { + let mut info = AMediaCodecBufferInfo { + offset: 0, + size: 0, + presentationTimeUs: 0, + flags: 0, + }; + let idx = AMediaCodec_dequeueOutputBuffer(self.codec, &mut info, DEQUEUE_TIMEOUT_US); + if idx < 0 { + // INFO_TRY_AGAIN_LATER / OUTPUT_FORMAT_CHANGED / BUFFERS_CHANGED: all + // mean "no frame this time"; the next call picks the format up. + return Ok(None); + } + + let mut cap: usize = 0; + let buf = AMediaCodec_getOutputBuffer(self.codec, idx as usize, &mut cap); + let frame = if !buf.is_null() && info.size > 0 { + let fmt = AMediaCodec_getOutputFormat(self.codec); + let geom = read_geometry(fmt); + if !fmt.is_null() { + AMediaFormat_delete(fmt); + } + let end = (info.offset as usize) + .saturating_add(info.size as usize) + .min(cap); + let start = (info.offset as usize).min(end); + let data = core::slice::from_raw_parts(buf.add(start), end - start); + yuv420_to_rgb(data, &geom) + } else { + None + }; + let _ = AMediaCodec_releaseOutputBuffer(self.codec, idx as usize, false); + Ok(frame) + } + + fn teardown(&mut self) { + unsafe { + if !self.codec.is_null() { + let _ = AMediaCodec_stop(self.codec); + AMediaCodec_delete(self.codec); + self.codec = core::ptr::null_mut(); + } + } + } +} + +impl VideoDecoder for MediaCodecDecoder { + fn decode(&mut self, au: &[u8]) -> Result> { + if au.is_empty() { + return Ok(None); + } + + // Cache parameter sets from keyframes; (re)prime when they change. + let mut new_sps: Option> = None; + let mut new_pps: Option> = None; + for_each_nal(au, |nal| match nal.first().map(|b| b & 0x1F) { + Some(7) => new_sps = Some(nal.to_vec()), + Some(8) => new_pps = Some(nal.to_vec()), + _ => {} + }); + if let (Some(sps), Some(pps)) = (new_sps, new_pps) { + if sps != self.sps || pps != self.pps || self.codec.is_null() { + self.sps = sps; + self.pps = pps; + self.prime()?; + } + } + if self.codec.is_null() { + return Ok(None); // still waiting for the first keyframe + } + + unsafe { + // Feed the whole Annex-B AU (start codes included — MediaCodec wants + // the elementary stream byte format in byte-buffer mode). + self.queue_input(au); + self.pull_output() + } + } +} + +impl Drop for MediaCodecDecoder { + fn drop(&mut self) { + self.teardown(); + } +} + +// SAFETY: the decoder is created on, and used only from, the single background +// decode thread that owns it. `AMediaCodec` handles are not shared between +// threads here (no `Sync`); moving ownership to that thread and calling it there +// is sound. +unsafe impl Send for MediaCodecDecoder {} + +/// ~29.97 fps in microseconds; only needs to be monotonic. +const PTS_STEP_US: i64 = 33_367; +/// 10 ms dequeue timeout: long enough to pace one frame, short enough to keep +/// the decode loop responsive. +const DEQUEUE_TIMEOUT_US: i64 = 10_000; + +// NDK format keys and MIME, as NUL-terminated byte strings (the `AMEDIAFORMAT_*` +// macros expand to exactly these literals). +const MIME: &[u8] = b"video/avc\0"; +const KEY_MIME: &[u8] = b"mime\0"; +const KEY_WIDTH: &[u8] = b"width\0"; +const KEY_HEIGHT: &[u8] = b"height\0"; +const KEY_STRIDE: &[u8] = b"stride\0"; +const KEY_SLICE_HEIGHT: &[u8] = b"slice-height\0"; +const KEY_COLOR_FORMAT: &[u8] = b"color-format\0"; +const KEY_CROP_LEFT: &[u8] = b"crop-left\0"; +const KEY_CROP_TOP: &[u8] = b"crop-top\0"; +const KEY_CROP_RIGHT: &[u8] = b"crop-right\0"; +const KEY_CROP_BOTTOM: &[u8] = b"crop-bottom\0"; +const KEY_CSD0: &[u8] = b"csd-0\0"; +const KEY_CSD1: &[u8] = b"csd-1\0"; + +// Android `MediaCodecInfo.CodecCapabilities` colour formats we handle. +const COLOR_FORMAT_YUV420_PLANAR: i32 = 19; // I420 (Y, U, V planes) +const COLOR_FORMAT_YUV420_SEMIPLANAR: i32 = 21; // NV12 (Y, interleaved UV) + +/// Decoded output-buffer geometry pulled from the codec's output `AMediaFormat`. +struct Geometry { + color_format: i32, + stride: usize, + slice_height: usize, + /// Visible picture rectangle (display size + top-left offset). + crop_x: usize, + crop_y: usize, + disp_w: usize, + disp_h: usize, +} + +/// Read the stride/slice-height/colour-format and crop rectangle from the output +/// format, filling in sane fallbacks when a key is absent. +unsafe fn read_geometry(fmt: *mut AMediaFormat) -> Geometry { + let mut width = 0i32; + let mut height = 0i32; + let mut stride = 0i32; + let mut slice_h = 0i32; + let mut color = COLOR_FORMAT_YUV420_SEMIPLANAR; + let (mut cl, mut ct, mut cr, mut cb) = (0i32, 0i32, -1i32, -1i32); + if !fmt.is_null() { + get_i32(fmt, KEY_WIDTH, &mut width); + get_i32(fmt, KEY_HEIGHT, &mut height); + get_i32(fmt, KEY_STRIDE, &mut stride); + get_i32(fmt, KEY_SLICE_HEIGHT, &mut slice_h); + get_i32(fmt, KEY_COLOR_FORMAT, &mut color); + get_i32(fmt, KEY_CROP_LEFT, &mut cl); + get_i32(fmt, KEY_CROP_TOP, &mut ct); + get_i32(fmt, KEY_CROP_RIGHT, &mut cr); + get_i32(fmt, KEY_CROP_BOTTOM, &mut cb); + } + if stride <= 0 { + stride = width; + } + if slice_h <= 0 { + slice_h = height; + } + // Crop rect is inclusive; fall back to the full coded size when absent. + let (disp_w, disp_h, crop_x, crop_y) = if cr >= cl && cb >= ct && cr >= 0 && cb >= 0 { + ( + (cr - cl + 1) as usize, + (cb - ct + 1) as usize, + cl.max(0) as usize, + ct.max(0) as usize, + ) + } else { + (width.max(0) as usize, height.max(0) as usize, 0, 0) + }; + Geometry { + color_format: color, + stride: stride.max(0) as usize, + slice_height: slice_h.max(0) as usize, + crop_x, + crop_y, + disp_w, + disp_h, + } +} + +/// Convert an I420 / NV12 output buffer to packed RGB8, cropping to the visible +/// rectangle described by `g` (BT.601 limited range, matching the other paths). +fn yuv420_to_rgb(data: &[u8], g: &Geometry) -> Option { + let (w, h, stride, plane_h) = (g.disp_w, g.disp_h, g.stride, g.slice_height); + if w == 0 || h == 0 || stride == 0 || plane_h == 0 { + return None; + } + let y_plane = stride * plane_h; + let semiplanar = g.color_format != COLOR_FORMAT_YUV420_PLANAR; + let mut rgb = vec![0u8; w * h * 3]; + for row in 0..h { + let sy = g.crop_y + row; + let y_off = sy * stride; + let out_off = row * w * 3; + let cy = sy / 2; + for col in 0..w { + let sx = g.crop_x + col; + let y = *data.get(y_off + sx).unwrap_or(&16) as i32; + let (u, v) = if semiplanar { + // NV12: interleaved UV plane after Y, half-height, full stride. + // The chroma pair for luma column `sx` sits at the even offset + // `sx & !1` within the UV row (same stride as the Y plane). + let uv_off = y_plane + cy * stride; + let base = uv_off + (sx & !1); + ( + *data.get(base).unwrap_or(&128) as i32, + *data.get(base + 1).unwrap_or(&128) as i32, + ) + } else { + // I420: U plane then V plane, each half-width / half-height. + let c_stride = stride / 2; + let c_plane = (stride / 2) * (plane_h / 2); + let cx = sx / 2; + let u_off = y_plane + cy * c_stride + cx; + let v_off = y_plane + c_plane + cy * c_stride + cx; + ( + *data.get(u_off).unwrap_or(&128) as i32, + *data.get(v_off).unwrap_or(&128) as i32, + ) + }; + let c = y - 16; + let d = u - 128; + let e = v - 128; + let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8; + let gg = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8; + let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8; + let o = out_off + col * 3; + rgb[o] = r; + rgb[o + 1] = gg; + rgb[o + 2] = b; + } + } + Some(Frame { w, h, rgb }) +} + +/// Prepend a 4-byte Annex-B start code to a raw NAL for use as csd. +fn with_start_code(nal: &[u8]) -> Vec { + let mut v = Vec::with_capacity(nal.len() + 4); + v.extend_from_slice(&[0, 0, 0, 1]); + v.extend_from_slice(nal); + v +} + +/// `AMediaFormat_getInt32` wrapper that leaves `out` untouched when the key is +/// absent (so the caller's fallback stands). +unsafe fn get_i32(fmt: *mut AMediaFormat, key: &[u8], out: *mut i32) -> bool { + AMediaFormat_getInt32(fmt, key.as_ptr() as *const c_char, out) +} + +/// Iterate the Annex-B NAL units in `au`, start codes excluded and trailing zero +/// bytes trimmed. +fn for_each_nal(au: &[u8], mut f: F) { + let n = au.len(); + let mut starts: Vec = Vec::new(); + let mut i = 0usize; + while i + 2 < n { + if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 { + starts.push(i + 3); + i += 3; + } else { + i += 1; + } + } + for k in 0..starts.len() { + let begin = starts[k]; + let end = if k + 1 < starts.len() { + starts[k + 1] - 3 + } else { + n + }; + if begin >= end { + continue; + } + let mut nal = &au[begin..end]; + while let [rest @ .., 0] = nal { + nal = rest; + } + if !nal.is_empty() { + f(nal); + } + } +} + +fn other(msg: &str) -> NdError { + NdError::Other(anyhow::anyhow!(msg.to_string())) +} + +/// Self-contained FFI to the NDK Media C ABI (`libmediandk`). These signatures +/// match `` / `` and are stable +/// since Android API 21 (`AMediaFormat_getRect`/crop keys since API 21 as well +/// for the common decoders). The `ndk`/`ndk-sys` crates wrap the same library. +#[allow(non_snake_case, non_camel_case_types)] +mod sys { + use core::ffi::{c_char, c_void}; + + pub type AMediaCodec = c_void; + pub type AMediaFormat = c_void; + pub type media_status_t = i32; + pub const AMEDIA_OK: media_status_t = 0; + + #[repr(C)] + pub struct AMediaCodecBufferInfo { + pub offset: i32, + pub size: i32, + pub presentationTimeUs: i64, + pub flags: u32, + } + + #[link(name = "mediandk")] + extern "C" { + pub fn AMediaCodec_createDecoderByType(mime_type: *const c_char) -> *mut AMediaCodec; + pub fn AMediaCodec_configure( + codec: *mut AMediaCodec, + format: *mut AMediaFormat, + surface: *mut c_void, + crypto: *mut c_void, + flags: u32, + ) -> media_status_t; + pub fn AMediaCodec_start(codec: *mut AMediaCodec) -> media_status_t; + pub fn AMediaCodec_stop(codec: *mut AMediaCodec) -> media_status_t; + pub fn AMediaCodec_delete(codec: *mut AMediaCodec) -> media_status_t; + pub fn AMediaCodec_dequeueInputBuffer(codec: *mut AMediaCodec, timeout_us: i64) -> isize; + pub fn AMediaCodec_getInputBuffer( + codec: *mut AMediaCodec, + idx: usize, + out_size: *mut usize, + ) -> *mut u8; + pub fn AMediaCodec_queueInputBuffer( + codec: *mut AMediaCodec, + idx: usize, + offset: i64, + size: usize, + time_us: i64, + flags: u32, + ) -> media_status_t; + pub fn AMediaCodec_dequeueOutputBuffer( + codec: *mut AMediaCodec, + info: *mut AMediaCodecBufferInfo, + timeout_us: i64, + ) -> isize; + pub fn AMediaCodec_getOutputBuffer( + codec: *mut AMediaCodec, + idx: usize, + out_size: *mut usize, + ) -> *mut u8; + pub fn AMediaCodec_getOutputFormat(codec: *mut AMediaCodec) -> *mut AMediaFormat; + pub fn AMediaCodec_releaseOutputBuffer( + codec: *mut AMediaCodec, + idx: usize, + render: bool, + ) -> media_status_t; + + pub fn AMediaFormat_new() -> *mut AMediaFormat; + pub fn AMediaFormat_delete(fmt: *mut AMediaFormat) -> media_status_t; + pub fn AMediaFormat_setString( + fmt: *mut AMediaFormat, + name: *const c_char, + value: *const c_char, + ); + pub fn AMediaFormat_setInt32(fmt: *mut AMediaFormat, name: *const c_char, value: i32); + pub fn AMediaFormat_setBuffer( + fmt: *mut AMediaFormat, + name: *const c_char, + data: *const c_void, + size: usize, + ); + pub fn AMediaFormat_getInt32( + fmt: *mut AMediaFormat, + name: *const c_char, + out: *mut i32, + ) -> bool; + } +} diff --git a/native/core/src/decode_mf.rs b/native/core/src/decode_mf.rs new file mode 100644 index 0000000..b58bddc --- /dev/null +++ b/native/core/src/decode_mf.rs @@ -0,0 +1,646 @@ +//! Windows hardware H.264 decode via Media Foundation. +//! +//! This feeds the host's Annex-B access units into the Microsoft H.264 decoder +//! MFT (`CLSID_MSH264DecoderMFT`). When a Direct3D 11 device can be attached the +//! decode runs on the GPU video engine (DXVA2) and the decoded NV12 surface is +//! copied back to system memory; otherwise the same MFT runs as Microsoft's +//! (still heavily optimized) software decoder. Either way the output is the same +//! packed-RGB8 [`Frame`] the openh264 path produces, so the viewer is unchanged. +//! +//! The whole module is only compiled on Windows (see the `#[cfg(windows)]` gate +//! in `decode.rs`). If anything here fails at runtime, [`super::best_decoder`] +//! falls back to the portable openh264 software decoder. + +use super::{Frame, VideoDecoder}; +use crate::error::{NdError, Result}; + +use windows::core::Interface; +use windows::Win32::Foundation::{HMODULE, TRUE}; +use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D, + D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, + D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, + D3D11_USAGE_STAGING, +}; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_SAMPLE_DESC}; +use windows::Win32::Media::MediaFoundation::*; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, +}; + +/// ~29.97 fps in 100-ns units. The host stream is low-latency with no B-frames, +/// so exact timing is unimportant — the decoder only needs monotonic stamps. +const HNS_PER_FRAME: i64 = 333_667; + +/// Hardware (or MS-software) Media Foundation H.264 decoder. +pub struct MediaFoundationDecoder { + transform: IMFTransform, + // Direct3D plumbing kept alive for the lifetime of the decoder (hw path only). + device: Option, + context: Option, + _manager: Option, + staging: Option, + staging_dims: (u32, u32), + /// True when DXVA hardware decode is active (output is a D3D texture). + hw: bool, + /// True when the MFT allocates its own output samples. + provides_samples: bool, + output_size: usize, + stride: usize, + /// Coded (padded, usually 16-aligned) frame size from MF_MT_FRAME_SIZE. + /// This is the NV12 *plane* geometry we read out of memory, not the picture. + coded_w: usize, + coded_h: usize, + /// True visible picture size (from the display aperture). This is what the + /// emitted `Frame.w`/`Frame.h` report, e.g. 1920x1080 even when the decoder + /// pads the coded frame to 1920x1088. + width: usize, + height: usize, + /// Top-left offset of the visible region inside the coded frame (usually 0). + crop_x: usize, + crop_y: usize, + started: bool, + pts: i64, + /// A frame pulled out while draining the MFT during a busy ProcessInput. + pending: Option, +} + +impl MediaFoundationDecoder { + /// Initialize Media Foundation and the H.264 decoder MFT. Returns an error + /// (so the caller can fall back to openh264) if MF is unavailable. + pub fn new() -> Result { + unsafe { + // COM (MTA) + MF startup. CoInitializeEx returns an HRESULT that is + // fine to ignore (S_FALSE / RPC_E_CHANGED_MODE just mean the thread + // already has an apartment). MFStartup is refcounted against the + // MFShutdown in Drop. + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(mferr("MFStartup"))?; + + let transform: IMFTransform = + CoCreateInstance(&CLSID_MSH264DecoderMFT, None, CLSCTX_INPROC_SERVER) + .map_err(mferr("CoCreateInstance(MSH264DecoderMFT)"))?; + + // Low-latency mode: emit each frame as soon as it is decoded. + if let Ok(attrs) = transform.GetAttributes() { + let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); + } + + // Best-effort DXVA hardware acceleration. + let (device, context, manager, hw) = match init_d3d(&transform) { + Ok((d, c, m)) => (Some(d), Some(c), Some(m), true), + Err(e) => { + tracing::debug!("decode(mf): DXVA unavailable ({e}); MS software MFT"); + (None, None, None, false) + } + }; + + // Input media type: H.264 Annex-B elementary stream. + let input = MFCreateMediaType().map_err(mferr("MFCreateMediaType(input)"))?; + input + .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video) + .map_err(mferr("set input major type"))?; + input + .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264) + .map_err(mferr("set input subtype H264"))?; + transform + .SetInputType(0, &input, 0) + .map_err(mferr("SetInputType"))?; + + let mut me = Self { + transform, + device, + context, + _manager: manager, + staging: None, + staging_dims: (0, 0), + hw, + provides_samples: false, + output_size: 0, + stride: 0, + coded_w: 0, + coded_h: 0, + width: 0, + height: 0, + crop_x: 0, + crop_y: 0, + started: false, + pts: 0, + pending: None, + }; + + me.configure_output()?; + + me.transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0) + .map_err(mferr("NOTIFY_BEGIN_STREAMING"))?; + me.transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0) + .map_err(mferr("NOTIFY_START_OF_STREAM"))?; + me.started = true; + + Ok(me) + } + } + + /// Whether DXVA GPU decode is active (vs. the MS software MFT). + pub fn is_hardware(&self) -> bool { + self.hw + } + + /// Select an NV12 output type and read back its geometry + allocation needs. + unsafe fn configure_output(&mut self) -> Result<()> { + let mut i = 0u32; + loop { + match self.transform.GetOutputAvailableType(0, i) { + Ok(t) => { + let sub = t.GetGUID(&MF_MT_SUBTYPE).unwrap_or_default(); + if sub == MFVideoFormat_NV12 { + self.transform + .SetOutputType(0, &t, 0) + .map_err(mferr("SetOutputType(NV12)"))?; + self.read_output_geometry(&t); + break; + } + i += 1; + } + Err(e) if e.code() == MF_E_NO_MORE_TYPES => { + // Nothing offered yet (common before the first keyframe sets + // the frame size) — ask for NV12 explicitly. + let t = MFCreateMediaType().map_err(mferr("MFCreateMediaType(output)"))?; + let _ = t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video); + let _ = t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12); + self.transform + .SetOutputType(0, &t, 0) + .map_err(mferr("SetOutputType(NV12 manual)"))?; + if let Ok(cur) = self.transform.GetOutputCurrentType(0) { + self.read_output_geometry(&cur); + } + break; + } + Err(e) => return Err(mferr("GetOutputAvailableType")(e)), + } + } + + let info = self + .transform + .GetOutputStreamInfo(0) + .map_err(mferr("GetOutputStreamInfo"))?; + let provides = + (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32; + self.provides_samples = (info.dwFlags & provides) != 0; + self.output_size = info.cbSize as usize; + Ok(()) + } + + /// Pull the coded frame size, the true display rectangle, and the stride out + /// of a configured output media type. + /// + /// The MS H.264 decoder reports the *coded* size in `MF_MT_FRAME_SIZE` — that + /// is padded up to the codec's alignment (e.g. 1920x1088 for 1080p content, + /// and larger for other resolutions), which is what produced the wrong + /// 1920x18xx figure in the stats. The real picture rectangle is carried in a + /// video-aperture blob, so we read that separately and keep the coded size + /// only for the in-memory plane layout. + unsafe fn read_output_geometry(&mut self, t: &IMFMediaType) { + // Coded (padded) plane geometry — how the NV12 bytes are laid out. + if let Ok(fs) = t.GetUINT64(&MF_MT_FRAME_SIZE) { + self.coded_w = (fs >> 32) as usize; + self.coded_h = (fs & 0xffff_ffff) as usize; + } + + // True visible picture region. Prefer the minimum display aperture + // (what a correct renderer must show), then the geometric aperture, then + // the pan/scan aperture if pan/scan is actually enabled. When none is + // present the whole coded frame is the picture. + let aperture = read_aperture(t, &MF_MT_MINIMUM_DISPLAY_APERTURE) + .or_else(|| read_aperture(t, &MF_MT_GEOMETRIC_APERTURE)) + .or_else(|| { + if t.GetUINT32(&MF_MT_PAN_SCAN_ENABLED).unwrap_or(0) != 0 { + read_aperture(t, &MF_MT_PAN_SCAN_APERTURE) + } else { + None + } + }); + match aperture { + Some((ox, oy, aw, ah)) => { + self.crop_x = ox; + self.crop_y = oy; + // Clamp the aperture inside the coded frame so a malformed blob + // can never make the cropper read past the decoded plane. + let max_w = self.coded_w.saturating_sub(ox); + let max_h = self.coded_h.saturating_sub(oy); + self.width = if max_w > 0 { aw.min(max_w) } else { aw }; + self.height = if max_h > 0 { ah.min(max_h) } else { ah }; + } + None => { + self.crop_x = 0; + self.crop_y = 0; + self.width = self.coded_w; + self.height = self.coded_h; + } + } + + self.stride = match t.GetUINT32(&MF_MT_DEFAULT_STRIDE) { + Ok(s) => (s as i32).unsigned_abs() as usize, + Err(_) => self.coded_w, + }; + if self.stride == 0 { + self.stride = self.coded_w; + } + } + + /// Wrap one Annex-B access unit in an input IMFSample. + unsafe fn make_input_sample(&mut self, au: &[u8]) -> Result { + let sample = MFCreateSample().map_err(mferr("MFCreateSample"))?; + let buffer = + MFCreateMemoryBuffer(au.len() as u32).map_err(mferr("MFCreateMemoryBuffer"))?; + let mut ptr: *mut u8 = std::ptr::null_mut(); + buffer + .Lock(&mut ptr, None, None) + .map_err(mferr("Lock(input)"))?; + std::ptr::copy_nonoverlapping(au.as_ptr(), ptr, au.len()); + buffer.Unlock().map_err(mferr("Unlock(input)"))?; + buffer + .SetCurrentLength(au.len() as u32) + .map_err(mferr("SetCurrentLength"))?; + sample.AddBuffer(&buffer).map_err(mferr("AddBuffer"))?; + let _ = sample.SetSampleTime(self.pts); + let _ = sample.SetSampleDuration(HNS_PER_FRAME); + self.pts += HNS_PER_FRAME; + Ok(sample) + } + + /// Run ProcessOutput once, materializing a frame if one is ready. + unsafe fn pull_output(&mut self) -> Result> { + loop { + let sample_slot: Option = if self.provides_samples { + None + } else { + let s = MFCreateSample().map_err(mferr("MFCreateSample(output)"))?; + let size = self + .output_size + .max(self.stride * self.coded_h * 3 / 2) + .max(1); + let b = MFCreateMemoryBuffer(size as u32) + .map_err(mferr("MFCreateMemoryBuffer(output)"))?; + s.AddBuffer(&b).map_err(mferr("AddBuffer(output)"))?; + Some(s) + }; + + let mut buffers = [MFT_OUTPUT_DATA_BUFFER { + dwStreamID: 0, + pSample: std::mem::ManuallyDrop::new(sample_slot), + dwStatus: 0, + pEvents: std::mem::ManuallyDrop::new(None), + }]; + let mut status = 0u32; + let res = self.transform.ProcessOutput(0, &mut buffers, &mut status); + + // Reclaim ownership of whatever is in the slots so they drop cleanly. + let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample); + let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents); + + match res { + Ok(()) => { + let s = out_sample.ok_or_else(|| other("ProcessOutput produced no sample"))?; + return Ok(Some(self.sample_to_frame(&s)?)); + } + Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None), + Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { + // The decoder learned the real resolution — renegotiate NV12. + self.configure_output()?; + continue; + } + Err(e) => return Err(mferr("ProcessOutput")(e)), + } + } + } + + /// Convert a decoded output sample (D3D texture or system memory) to RGB. + unsafe fn sample_to_frame(&mut self, sample: &IMFSample) -> Result { + let buffer = sample + .GetBufferByIndex(0) + .map_err(mferr("GetBufferByIndex"))?; + + if self.hw { + if let Ok(dxgi) = buffer.cast::() { + return self.dxgi_to_frame(&dxgi); + } + } + + // System-memory NV12. + let mut ptr: *mut u8 = std::ptr::null_mut(); + let mut cur: u32 = 0; + buffer + .Lock(&mut ptr, None, Some(&mut cur)) + .map_err(mferr("Lock(output)"))?; + let stride = if self.stride == 0 { + self.coded_w + } else { + self.stride + }; + // Derive the (possibly 16-aligned) plane height from the real byte count, + // never below the coded height the decoder reported. + let plane_h = if stride > 0 { + ((cur as usize) * 2) / (stride * 3) + } else { + self.coded_h + } + .max(self.coded_h); + let data = std::slice::from_raw_parts(ptr, cur as usize); + // Crop the visible picture (width/height at crop_x/crop_y) out of the + // padded NV12 plane so the Frame is the true display size. + let frame = nv12_to_rgb( + data, + stride, + plane_h, + self.crop_x, + self.crop_y, + self.width, + self.height, + ); + let _ = buffer.Unlock(); + frame.ok_or_else(|| other("NV12→RGB conversion failed")) + } + + /// Copy a decoded GPU NV12 texture to a staging texture, map it, convert. + unsafe fn dxgi_to_frame(&mut self, dxgi: &IMFDXGIBuffer) -> Result { + let device = self.device.clone().ok_or_else(|| other("no D3D device"))?; + let context = self + .context + .clone() + .ok_or_else(|| other("no D3D context"))?; + + let mut tex_ptr: *mut core::ffi::c_void = std::ptr::null_mut(); + dxgi.GetResource(&ID3D11Texture2D::IID, &mut tex_ptr) + .map_err(mferr("IMFDXGIBuffer::GetResource"))?; + if tex_ptr.is_null() { + return Err(other("decoded texture was null")); + } + let src = ID3D11Texture2D::from_raw(tex_ptr); + let sub = dxgi.GetSubresourceIndex().unwrap_or(0); + + let mut desc: D3D11_TEXTURE2D_DESC = std::mem::zeroed(); + src.GetDesc(&mut desc); + + self.ensure_staging(&device, desc.Width, desc.Height, desc.Format)?; + let staging = self + .staging + .as_ref() + .ok_or_else(|| other("no staging texture"))? + .clone(); + + // Copy the one decoded subresource out of the decoder's texture array. + context.CopySubresourceRegion(&staging, 0, 0, 0, 0, &src, sub, None); + + let mut mapped: D3D11_MAPPED_SUBRESOURCE = std::mem::zeroed(); + context + .Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) + .map_err(mferr("Map(staging)"))?; + let row_pitch = mapped.RowPitch as usize; + // The staging texture height is the coded (padded) plane height. + let plane_h = desc.Height as usize; + let total = row_pitch * plane_h * 3 / 2; + let data = std::slice::from_raw_parts(mapped.pData as *const u8, total); + // Crop to the true visible picture out of the padded GPU NV12 surface. + let frame = nv12_to_rgb( + data, + row_pitch, + plane_h, + self.crop_x, + self.crop_y, + self.width, + self.height, + ); + context.Unmap(&staging, 0); + + frame.ok_or_else(|| other("NV12→RGB conversion failed")) + } + + /// (Re)create the CPU-readable staging texture when the size changes. + unsafe fn ensure_staging( + &mut self, + device: &ID3D11Device, + w: u32, + h: u32, + fmt: DXGI_FORMAT, + ) -> Result<()> { + if self.staging.is_some() && self.staging_dims == (w, h) { + return Ok(()); + } + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: fmt, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut tex: Option = None; + device + .CreateTexture2D(&desc, None, Some(&mut tex)) + .map_err(mferr("CreateTexture2D(staging)"))?; + self.staging = tex; + self.staging_dims = (w, h); + Ok(()) + } +} + +impl VideoDecoder for MediaFoundationDecoder { + fn decode(&mut self, au: &[u8]) -> Result> { + if au.is_empty() { + return Ok(None); + } + unsafe { + let sample = self.make_input_sample(au)?; + + // Feed the AU. If the MFT is momentarily full, drain its output and + // retry (keeping the most recent frame for this call). + let mut guard = 0; + loop { + match self.transform.ProcessInput(0, &sample, 0) { + Ok(()) => break, + Err(e) if e.code() == MF_E_NOTACCEPTING => { + if let Some(f) = self.pull_output()? { + self.pending = Some(f); + } + guard += 1; + if guard > 16 { + return Err(other("MFT refused input repeatedly")); + } + } + Err(e) => return Err(mferr("ProcessInput")(e)), + } + } + + // Pull the decoded picture(s). With zero-latency/no-B-frame input the + // common case is exactly one frame per AU. + let mut out = self.pending.take(); + while let Some(f) = self.pull_output()? { + out = Some(f); + } + Ok(out) + } + } +} + +impl Drop for MediaFoundationDecoder { + fn drop(&mut self) { + unsafe { + if self.started { + let _ = self + .transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0); + let _ = self + .transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + } + // Balances the MFStartup in `new` (both are refcounted). + let _ = MFShutdown(); + } + } +} + +// SAFETY: a MediaFoundationDecoder is created on, and used only from, the single +// background network thread that owns it (the viewer's decode loop). COM is +// initialized multithreaded (MTA) so the MFT is agile, and the D3D11 device is +// marked multithread-protected, so moving the decoder to that thread and calling +// it there is sound. It is never shared between threads (no `Sync`). +unsafe impl Send for MediaFoundationDecoder {} + +/// Create a D3D11 device + DXGI device manager and hand it to the decoder to +/// enable DXVA2 hardware decode. Any failure leaves the MFT in software mode. +unsafe fn init_d3d( + transform: &IMFTransform, +) -> Result<(ID3D11Device, ID3D11DeviceContext, IMFDXGIDeviceManager)> { + let mut device: Option = None; + let mut context: Option = None; + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE(std::ptr::null_mut()), + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + .map_err(mferr("D3D11CreateDevice"))?; + let device = device.ok_or_else(|| other("D3D11CreateDevice gave no device"))?; + let context = context.ok_or_else(|| other("D3D11CreateDevice gave no context"))?; + + // MF calls the device from its own worker threads. + if let Ok(mt) = device.cast::() { + let _ = mt.SetMultithreadProtected(TRUE); + } + + let mut token = 0u32; + let mut manager: Option = None; + MFCreateDXGIDeviceManager(&mut token, &mut manager) + .map_err(mferr("MFCreateDXGIDeviceManager"))?; + let manager = manager.ok_or_else(|| other("no DXGI device manager"))?; + manager + .ResetDevice(&device, token) + .map_err(mferr("IMFDXGIDeviceManager::ResetDevice"))?; + + // The ulParam for SET_D3D_MANAGER is the IUnknown* of the device manager. + let param = manager.as_raw() as usize; + transform + .ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER, param) + .map_err(mferr("MFT_MESSAGE_SET_D3D_MANAGER"))?; + + Ok((device, context, manager)) +} + +/// NV12 → packed RGB8 (BT.601 limited range, matching the openh264 path), +/// cropping the visible `w`x`h` picture at offset (`off_x`, `off_y`) out of the +/// (possibly padded) coded plane described by `stride` x `plane_h`. +/// +/// `plane_h` is the coded/aligned luma height: the interleaved UV plane starts +/// at `stride * plane_h`, so it must be the padded height, not the visible one. +#[allow(clippy::too_many_arguments)] +fn nv12_to_rgb( + data: &[u8], + stride: usize, + plane_h: usize, + off_x: usize, + off_y: usize, + w: usize, + h: usize, +) -> Option { + if w == 0 || h == 0 || stride == 0 { + return None; + } + let uv_base = stride * plane_h; + let mut rgb = vec![0u8; w * h * 3]; + for row in 0..h { + let sy = off_y + row; // source luma row inside the coded plane + let y_off = sy * stride; + let uv_off = uv_base + (sy / 2) * stride; + let out_off = row * w * 3; + for col in 0..w { + let sx = off_x + col; // source luma column inside the coded plane + let y = *data.get(y_off + sx).unwrap_or(&16) as i32; + let uvx = sx & !1; // chroma pair aligned to the even luma column + let u = *data.get(uv_off + uvx).unwrap_or(&128) as i32; + let v = *data.get(uv_off + uvx + 1).unwrap_or(&128) as i32; + let c = y - 16; + let d = u - 128; + let e = v - 128; + let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8; + let g = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8; + let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8; + let o = out_off + col * 3; + rgb[o] = r; + rgb[o + 1] = g; + rgb[o + 2] = b; + } + } + Some(Frame { w, h, rgb }) +} + +/// Read an `MFVideoArea` aperture blob (display / geometric / pan-scan) off a +/// media type and return `(offset_x, offset_y, width, height)` in whole pixels, +/// or `None` when the attribute is absent or degenerate. +/// +/// `MFVideoArea` is a fixed 16-byte POD (`MFOffset` x,y + `SIZE`), so we read it +/// straight into the struct. The `MFOffset` fractional part is ignored: H.264 +/// display apertures are always integer-pixel aligned. +unsafe fn read_aperture( + t: &IMFMediaType, + key: *const windows::core::GUID, +) -> Option<(usize, usize, usize, usize)> { + let mut area = MFVideoArea::default(); + let buf = std::slice::from_raw_parts_mut( + (&mut area as *mut MFVideoArea).cast::(), + std::mem::size_of::(), + ); + t.GetBlob(key, buf, None).ok()?; + let w = area.Area.cx; + let h = area.Area.cy; + if w <= 0 || h <= 0 { + return None; + } + let ox = area.OffsetX.value.max(0) as usize; + let oy = area.OffsetY.value.max(0) as usize; + Some((ox, oy, w as usize, h as usize)) +} + +/// Build a context-tagged error mapper for `windows::core::Error`. +fn mferr(ctx: &'static str) -> impl Fn(windows::core::Error) -> NdError { + move |e| NdError::Other(anyhow::anyhow!("MediaFoundation {ctx}: {e}")) +} + +fn other(msg: &str) -> NdError { + NdError::Other(anyhow::anyhow!(msg.to_string())) +} diff --git a/native/core/src/decode_vaapi.rs b/native/core/src/decode_vaapi.rs new file mode 100644 index 0000000..c8794ac --- /dev/null +++ b/native/core/src/decode_vaapi.rs @@ -0,0 +1,287 @@ +//! Linux hardware H.264 decode via VAAPI, driven through FFmpeg/libavcodec. +//! +//! Pure libva decoding would mean parsing the H.264 bitstream down to slice +//! headers and hand-building `VAPictureParameterBufferH264` / reference lists; +//! libavcodec already does all of that and exposes VAAPI as a hardware +//! accelerator, so this backend opens the libavcodec H.264 decoder with a VAAPI +//! `hw_device_ctx`. Decode runs on the GPU (Intel/AMD/`iHD`/`radeonsi` via +//! `/dev/dri`), the decoded surface is transferred to system memory (NV12), and +//! swscale converts it to the same packed-RGB8 [`Frame`] the openh264 path +//! produces, so the viewer is unchanged. +//! +//! The module is only compiled on Linux (see the `#[cfg(target_os = "linux")]` +//! gate in `decode.rs`). If VAAPI is unavailable at runtime — +//! `av_hwdevice_ctx_create` fails with no usable DRI device — `new` returns an +//! error and [`super::best_decoder`] falls back to the portable openh264 +//! decoder, so headless / GPU-less Linux boxes still work. + +use super::{Frame, VideoDecoder}; +use crate::error::{NdError, Result}; + +use core::ffi::{c_int, c_void}; +use ffmpeg_next::ffi::*; + +/// FFmpeg/VAAPI hardware H.264 decoder. +pub struct VaapiDecoder { + ctx: *mut AVCodecContext, + hw_device: *mut AVBufferRef, + packet: *mut AVPacket, + hw_frame: *mut AVFrame, + sw_frame: *mut AVFrame, + sws: *mut SwsContext, + sws_dims: (i32, i32, i32), // (w, h, src pixel format) the cached scaler is for + pts: i64, +} + +impl VaapiDecoder { + pub fn new() -> Result { + unsafe { + // Safe to call repeatedly; wires up codec registration + logging. + let _ = ffmpeg_next::init(); + + let codec = avcodec_find_decoder(AVCodecID::AV_CODEC_ID_H264); + if codec.is_null() { + return Err(other("libavcodec has no H.264 decoder")); + } + let ctx = avcodec_alloc_context3(codec); + if ctx.is_null() { + return Err(other("avcodec_alloc_context3 failed")); + } + + // Create the VAAPI device (default DRI render node) and attach it. + let mut hw_device: *mut AVBufferRef = core::ptr::null_mut(); + let rc = av_hwdevice_ctx_create( + &mut hw_device, + AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, + core::ptr::null(), + core::ptr::null_mut(), + 0, + ); + let mut ctx = ctx; + if rc < 0 || hw_device.is_null() { + avcodec_free_context(&mut ctx); + return Err(other(&format!( + "av_hwdevice_ctx_create(VAAPI) failed ({rc})" + ))); + } + (*ctx).hw_device_ctx = av_buffer_ref(hw_device); + (*ctx).get_format = Some(get_hw_format); + + let rc = avcodec_open2(ctx, codec, core::ptr::null_mut()); + if rc < 0 { + av_buffer_unref(&mut hw_device); + avcodec_free_context(&mut ctx); + return Err(other(&format!("avcodec_open2 failed ({rc})"))); + } + + let mut packet = av_packet_alloc(); + let mut hw_frame = av_frame_alloc(); + let mut sw_frame = av_frame_alloc(); + if packet.is_null() || hw_frame.is_null() || sw_frame.is_null() { + if !packet.is_null() { + av_packet_free(&mut packet); + } + if !hw_frame.is_null() { + av_frame_free(&mut hw_frame); + } + if !sw_frame.is_null() { + av_frame_free(&mut sw_frame); + } + av_buffer_unref(&mut hw_device); + avcodec_free_context(&mut ctx); + return Err(other("av_frame_alloc / av_packet_alloc failed")); + } + + Ok(Self { + ctx, + hw_device, + packet, + hw_frame, + sw_frame, + sws: core::ptr::null_mut(), + sws_dims: (0, 0, -1), + pts: 0, + }) + } + } + + /// Transfer a decoded frame to system memory (if it is a GPU surface), crop + /// it to the display rectangle, and swscale it to packed RGB8. + unsafe fn frame_to_rgb(&mut self) -> Result> { + // A VAAPI surface must be pulled down to a software NV12 frame first; + // a software frame (fallback path) is used as-is. + let src: *mut AVFrame = + if (*self.hw_frame).format == AVPixelFormat::AV_PIX_FMT_VAAPI as c_int { + av_frame_unref(self.sw_frame); + let rc = av_hwframe_transfer_data(self.sw_frame, self.hw_frame, 0); + if rc < 0 { + return Err(other(&format!("av_hwframe_transfer_data failed ({rc})"))); + } + // Carry crop/colour metadata across from the hardware frame. + av_frame_copy_props(self.sw_frame, self.hw_frame); + self.sw_frame + } else { + self.hw_frame + }; + + // Apply the H.264 display crop so width/height are the true picture size + // (e.g. 1920x1080) rather than the 16-aligned coded size. + let _ = av_frame_apply_cropping(src, 1 /* AV_FRAME_CROP_UNALIGNED */); + + let w = (*src).width; + let h = (*src).height; + let src_fmt = (*src).format; + if w <= 0 || h <= 0 { + return Ok(None); + } + + // (Re)create the scaler when the geometry or source format changes. + if self.sws.is_null() || self.sws_dims != (w, h, src_fmt) { + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + self.sws = sws_getContext( + w, + h, + core::mem::transmute::(src_fmt), + w, + h, + AVPixelFormat::AV_PIX_FMT_RGB24, + SWS_BILINEAR as c_int, + core::ptr::null_mut(), // srcFilter: *mut SwsFilter + core::ptr::null_mut(), // dstFilter: *mut SwsFilter + core::ptr::null(), // param: *const c_double + ); + if self.sws.is_null() { + return Err(other("sws_getContext failed")); + } + self.sws_dims = (w, h, src_fmt); + } + + let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3]; + let dst_data: [*mut u8; 4] = [ + rgb.as_mut_ptr(), + core::ptr::null_mut(), + core::ptr::null_mut(), + core::ptr::null_mut(), + ]; + let dst_linesize: [c_int; 4] = [w * 3, 0, 0, 0]; + let scaled = sws_scale( + self.sws, + (*src).data.as_ptr() as *const *const u8, + (*src).linesize.as_ptr(), + 0, + h, + dst_data.as_ptr(), + dst_linesize.as_ptr(), + ); + if scaled <= 0 { + return Ok(None); + } + Ok(Some(Frame { + w: w as usize, + h: h as usize, + rgb, + })) + } +} + +impl VideoDecoder for VaapiDecoder { + fn decode(&mut self, au: &[u8]) -> Result> { + if au.is_empty() { + return Ok(None); + } + unsafe { + // Point the reusable packet at this AU. The data is not reference + // counted (no `buf`), so libavcodec copies what it needs during + // `avcodec_send_packet`; the slice only has to outlive this call. + av_packet_unref(self.packet); + (*self.packet).data = au.as_ptr() as *mut u8; + (*self.packet).size = au.len() as c_int; + (*self.packet).pts = self.pts; + (*self.packet).dts = self.pts; + self.pts += 1; + + let mut out: Option = None; + + let mut sret = avcodec_send_packet(self.ctx, self.packet); + if sret < 0 { + // Decoder is full: drain a frame, keep it, then resend. + av_frame_unref(self.hw_frame); + if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 { + out = self.frame_to_rgb()?; + } + sret = avcodec_send_packet(self.ctx, self.packet); + } + // Detach our borrowed buffer from the packet before it is reused. + (*self.packet).data = core::ptr::null_mut(); + (*self.packet).size = 0; + if sret < 0 && out.is_none() { + // EAGAIN / not-yet-ready before the first keyframe: no frame. + return Ok(None); + } + + av_frame_unref(self.hw_frame); + if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 { + out = self.frame_to_rgb()?; + } + Ok(out) + } + } +} + +impl Drop for VaapiDecoder { + fn drop(&mut self) { + unsafe { + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + if !self.sw_frame.is_null() { + av_frame_free(&mut self.sw_frame); + } + if !self.hw_frame.is_null() { + av_frame_free(&mut self.hw_frame); + } + if !self.packet.is_null() { + av_packet_free(&mut self.packet); + } + if !self.hw_device.is_null() { + av_buffer_unref(&mut self.hw_device); + } + if !self.ctx.is_null() { + avcodec_free_context(&mut self.ctx); + } + } + } +} + +// SAFETY: the decoder is created on, and used only from, the single background +// decode thread that owns it. The libavcodec context and its frames/packet are +// never shared across threads here (no `Sync`); moving ownership to that thread +// and calling it there is sound. +unsafe impl Send for VaapiDecoder {} + +/// `get_format` callback: prefer the VAAPI hardware pixel format when the +/// decoder offers it, otherwise fall back to the first (software) format so a +/// GPU-less box still decodes. +unsafe extern "C" fn get_hw_format( + _ctx: *mut AVCodecContext, + mut fmts: *const AVPixelFormat, +) -> AVPixelFormat { + let first = if fmts.is_null() { + AVPixelFormat::AV_PIX_FMT_NONE + } else { + *fmts + }; + while !fmts.is_null() && *fmts != AVPixelFormat::AV_PIX_FMT_NONE { + if *fmts == AVPixelFormat::AV_PIX_FMT_VAAPI { + return AVPixelFormat::AV_PIX_FMT_VAAPI; + } + fmts = fmts.add(1); + } + first +} + +fn other(msg: &str) -> NdError { + NdError::Other(anyhow::anyhow!(msg.to_string())) +} diff --git a/native/core/src/decode_vt.rs b/native/core/src/decode_vt.rs new file mode 100644 index 0000000..3bd8a16 --- /dev/null +++ b/native/core/src/decode_vt.rs @@ -0,0 +1,539 @@ +//! Apple hardware H.264 decode via VideoToolbox (`VTDecompressionSession`). +//! +//! On macOS and iOS this feeds the host's Annex-B access units into a +//! `VTDecompressionSession`, which runs on the platform video decode hardware +//! (the same engine `AVFoundation` uses). The decoder emits `CVPixelBuffer`s in +//! 32-bit BGRA — VideoToolbox does the YUV→RGB colour conversion on the GPU for +//! us — which we repack into the same packed-RGB8 [`Frame`] the openh264 path +//! produces, so the viewer is unchanged. +//! +//! This whole module is only compiled on Apple targets (see the +//! `#[cfg(any(target_os = "macos", target_os = "ios"))]` gate in `decode.rs`). +//! If anything here fails at runtime, [`super::best_decoder`] falls back to the +//! portable openh264 software decoder. +//! +//! ## Design +//! * VideoToolbox needs a `CMVideoFormatDescription` built from the stream's SPS +//! and PPS before a session can exist, so the session is created lazily on the +//! first access unit that carries both (a keyframe). AUs seen before that are +//! dropped (`Ok(None)`), exactly like the other backends while they prime. +//! * The host sends Annex-B (start-code delimited) NAL units; VideoToolbox wants +//! AVCC (4-byte length-prefixed) NALs in the `CMSampleBuffer`, so each AU is +//! repacked. Parameter-set NALs go into the format description, not the sample. +//! * Decode is driven synchronously: we submit the frame and immediately call +//! `VTDecompressionSessionWaitForAsynchronousFrames`, so the output callback +//! has run (and stored the frame in our heap-pinned [`Shared`] slot) by the +//! time `decode` returns. +//! * `CVPixelBufferGetWidth`/`Height` already return the *display* size (the +//! decoder applies the SPS crop), so there is no padded-dimension problem to +//! correct here the way there is on the Media Foundation path. + +use super::{Frame, VideoDecoder}; +use crate::error::{NdError, Result}; + +use core::ffi::c_void; + +use core_foundation::base::TCFType; +use core_foundation::dictionary::CFDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; + +use self::sys::*; + +/// VideoToolbox hardware H.264 decoder. +pub struct VideoToolboxDecoder { + /// Decompression session; `null` until the first keyframe primes it. + session: VTDecompressionSessionRef, + /// Format description built from the current SPS/PPS; owned (+1 ref). + format: CMFormatDescriptionRef, + /// Cached parameter sets, so we only rebuild the session when they change. + sps: Vec, + pps: Vec, + /// Heap-pinned output slot the C callback writes the decoded frame into. + /// Boxed so its address is stable for the `refcon` handed to the session. + shared: Box, + pts: i64, +} + +/// Output slot shared with the decompression callback via a raw `refcon`. +struct Shared { + frame: Option, +} + +impl VideoToolboxDecoder { + /// Prepare an (empty) decoder. The real session is built lazily once the + /// first SPS+PPS arrive, so this never fails on its own. + pub fn new() -> Result { + Ok(Self { + session: core::ptr::null_mut(), + format: core::ptr::null_mut(), + sps: Vec::new(), + pps: Vec::new(), + shared: Box::new(Shared { frame: None }), + pts: 0, + }) + } + + /// (Re)build the format description and decompression session from the + /// current `self.sps`/`self.pps`. + fn rebuild_session(&mut self) -> Result<()> { + self.teardown(); + + // CMVideoFormatDescription from the two parameter sets (raw NAL bytes, + // no start codes), with a 4-byte NAL length prefix for the AVCC samples. + let ptrs: [*const u8; 2] = [self.sps.as_ptr(), self.pps.as_ptr()]; + let sizes: [usize; 2] = [self.sps.len(), self.pps.len()]; + let mut format: CMFormatDescriptionRef = core::ptr::null_mut(); + let status = unsafe { + CMVideoFormatDescriptionCreateFromH264ParameterSets( + core::ptr::null(), + 2, + ptrs.as_ptr(), + sizes.as_ptr(), + 4, + &mut format, + ) + }; + if status != 0 || format.is_null() { + return Err(other(&format!( + "CMVideoFormatDescriptionCreateFromH264ParameterSets failed ({status})" + ))); + } + self.format = format; + + // Ask VideoToolbox to hand us ready-to-blit 32-bit BGRA pixel buffers. + let key = unsafe { CFString::wrap_under_get_rule(kCVPixelBufferPixelFormatTypeKey) }; + let val = CFNumber::from(kCVPixelFormatType_32BGRA); + let attrs = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), val.as_CFType())]); + + let mut record = VTDecompressionOutputCallbackRecord { + decompressionOutputCallback: Some(decompression_callback), + decompressionOutputRefCon: (&mut *self.shared) as *mut Shared as *mut c_void, + }; + let mut session: VTDecompressionSessionRef = core::ptr::null_mut(); + let status = unsafe { + VTDecompressionSessionCreate( + core::ptr::null(), + self.format, + core::ptr::null(), // default decoder (hardware when available) + attrs.as_concrete_TypeRef() as _, // destination buffer attributes + &record, + &mut session, + ) + }; + // `attrs`/`record` are retained/copied by the call above; drop them now. + let _ = record.decompressionOutputRefCon; + if status != 0 || session.is_null() { + return Err(other(&format!( + "VTDecompressionSessionCreate failed ({status})" + ))); + } + self.session = session; + Ok(()) + } + + /// Decode one AVCC-packed sample through the session, returning its frame. + fn decode_sample(&mut self, avcc: &[u8]) -> Result> { + if self.session.is_null() || avcc.is_empty() { + return Ok(None); + } + + // Wrap the AVCC bytes in a CMBlockBuffer (which owns a private copy). + let mut block: CMBlockBufferRef = core::ptr::null_mut(); + let status = unsafe { + CMBlockBufferCreateWithMemoryBlock( + core::ptr::null(), + core::ptr::null_mut(), + avcc.len(), + core::ptr::null(), // default block allocator + core::ptr::null(), + 0, + avcc.len(), + kCMBlockBufferAssureMemoryNowFlag, + &mut block, + ) + }; + if status != 0 || block.is_null() { + return Err(other(&format!( + "CMBlockBufferCreateWithMemoryBlock ({status})" + ))); + } + let status = unsafe { + CMBlockBufferReplaceDataBytes(avcc.as_ptr() as *const c_void, block, 0, avcc.len()) + }; + if status != 0 { + unsafe { CFRelease(block as _) }; + return Err(other(&format!("CMBlockBufferReplaceDataBytes ({status})"))); + } + + // Build the sample buffer with timing and size. + let timing = CMSampleTimingInfo { + duration: CMTime { + value: HNS_PER_FRAME, + timescale: 10_000_000, + flags: K_CMTIME_FLAGS_VALID, + epoch: 0, + }, + presentationTimeStamp: CMTime { + value: self.pts, + timescale: 10_000_000, + flags: K_CMTIME_FLAGS_VALID, + epoch: 0, + }, + decodeTimeStamp: CMTime { + value: 0, + timescale: 0, + flags: 0, + epoch: 0, + }, + }; + self.pts += HNS_PER_FRAME; + let sizes = [avcc.len()]; + let mut sample: CMSampleBufferRef = core::ptr::null_mut(); + let status = unsafe { + CMSampleBufferCreateReady( + core::ptr::null(), + block, + self.format, + 1, + 1, + &timing, + 1, + sizes.as_ptr(), + &mut sample, + ) + }; + unsafe { CFRelease(block as _) }; + if status != 0 || sample.is_null() { + return Err(other(&format!("CMSampleBufferCreateReady ({status})"))); + } + + // Submit synchronously: the callback runs before Wait returns. + self.shared.frame = None; + let mut info_flags: u32 = 0; + let status = unsafe { + VTDecompressionSessionDecodeFrame( + self.session, + sample, + 0, + core::ptr::null_mut(), + &mut info_flags, + ) + }; + if status == 0 { + unsafe { VTDecompressionSessionWaitForAsynchronousFrames(self.session) }; + } + unsafe { CFRelease(sample as _) }; + if status != 0 { + return Err(other(&format!( + "VTDecompressionSessionDecodeFrame ({status})" + ))); + } + + Ok(self.shared.frame.take()) + } + + fn teardown(&mut self) { + unsafe { + if !self.session.is_null() { + VTDecompressionSessionInvalidate(self.session); + CFRelease(self.session as _); + self.session = core::ptr::null_mut(); + } + if !self.format.is_null() { + CFRelease(self.format as _); + self.format = core::ptr::null_mut(); + } + } + } +} + +impl VideoDecoder for VideoToolboxDecoder { + fn decode(&mut self, au: &[u8]) -> Result> { + if au.is_empty() { + return Ok(None); + } + + // Split the Annex-B AU, pulling out parameter sets and repacking the + // decodable NALs as AVCC (4-byte big-endian length prefixes). + let mut new_sps: Option> = None; + let mut new_pps: Option> = None; + let mut avcc: Vec = Vec::with_capacity(au.len() + 16); + for_each_nal(au, |nal| { + match nal.first().map(|b| b & 0x1F) { + Some(7) => new_sps = Some(nal.to_vec()), // SPS + Some(8) => new_pps = Some(nal.to_vec()), // PPS + Some(9) => {} // AUD: drop + Some(_) => { + avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes()); + avcc.extend_from_slice(nal); + } + None => {} + } + }); + + // A keyframe carries fresh parameter sets; rebuild the session when they + // change (first keyframe, or a resolution/profile switch mid-stream). + if let (Some(sps), Some(pps)) = (new_sps, new_pps) { + if sps != self.sps || pps != self.pps || self.session.is_null() { + self.sps = sps; + self.pps = pps; + self.rebuild_session()?; + } + } + + self.decode_sample(&avcc) + } +} + +impl Drop for VideoToolboxDecoder { + fn drop(&mut self) { + self.teardown(); + } +} + +// SAFETY: the decoder is created on, and only ever touched from, the single +// background decode thread that owns it (the viewer's network loop). The +// VideoToolbox session is used synchronously and never shared across threads +// (no `Sync`), and the `Shared` slot the callback writes is reached only while +// that same thread is blocked inside `WaitForAsynchronousFrames`. +unsafe impl Send for VideoToolboxDecoder {} + +/// ~29.97 fps in 100-ns units; only needs to be monotonic for this stream. +const HNS_PER_FRAME: i64 = 333_667; + +/// Iterate the Annex-B NAL units in `au`, trimming trailing zero bytes (which +/// belong to the next start code / are RBSP padding) so each slice is the raw +/// NAL, start-code excluded. +fn for_each_nal(au: &[u8], mut f: F) { + let n = au.len(); + // Payload start offset of every `00 00 01` start code (a 4-byte start code + // `00 00 00 01` is matched on its trailing three bytes, the extra leading + // zero falling into the previous NAL's trailing-zero trim). + let mut starts: Vec = Vec::new(); + let mut i = 0usize; + while i + 2 < n { + if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 { + starts.push(i + 3); + i += 3; + } else { + i += 1; + } + } + for k in 0..starts.len() { + let begin = starts[k]; + let end = if k + 1 < starts.len() { + starts[k + 1] - 3 + } else { + n + }; + if begin >= end { + continue; + } + let mut nal = &au[begin..end]; + while let [rest @ .., 0] = nal { + nal = rest; + } + if !nal.is_empty() { + f(nal); + } + } +} + +/// VideoToolbox output callback: convert the decoded BGRA pixel buffer to a +/// packed-RGB8 [`Frame`] and stash it in the shared slot pointed to by `refcon`. +extern "C" fn decompression_callback( + refcon: *mut c_void, + _source_frame_refcon: *mut c_void, + status: i32, + _info_flags: u32, + image_buffer: CVImageBufferRef, + _pts: CMTime, + _duration: CMTime, +) { + if refcon.is_null() || status != 0 || image_buffer.is_null() { + return; + } + let shared = unsafe { &mut *(refcon as *mut Shared) }; + let pb = image_buffer; // CVImageBufferRef and CVPixelBufferRef are the same handle + unsafe { + if CVPixelBufferLockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY) != 0 { + return; + } + let w = CVPixelBufferGetWidth(pb); + let h = CVPixelBufferGetHeight(pb); + let stride = CVPixelBufferGetBytesPerRow(pb); + let base = CVPixelBufferGetBaseAddress(pb) as *const u8; + if !base.is_null() && w > 0 && h > 0 && stride >= w * 4 { + let src = core::slice::from_raw_parts(base, stride * h); + let mut rgb = vec![0u8; w * h * 3]; + for row in 0..h { + let srow = row * stride; + let drow = row * w * 3; + for col in 0..w { + let s = srow + col * 4; // BGRA + let d = drow + col * 3; + rgb[d] = src[s + 2]; // R + rgb[d + 1] = src[s + 1]; // G + rgb[d + 2] = src[s]; // B + } + } + shared.frame = Some(Frame { w, h, rgb }); + } + CVPixelBufferUnlockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY); + } +} + +fn other(msg: &str) -> NdError { + NdError::Other(anyhow::anyhow!(msg.to_string())) +} + +/// Minimal, self-contained FFI surface for the CoreMedia / CoreVideo / +/// VideoToolbox C APIs this decoder uses. These ABIs are stable across macOS and +/// iOS releases; the `CFDictionaryRef`/`CFStringRef` types are shared with the +/// `core-foundation` crate so the attribute dictionary bridges cleanly. +#[allow(non_snake_case, non_upper_case_globals)] +mod sys { + use core::ffi::c_void; + use core_foundation_sys::base::{CFAllocatorRef, CFRelease as cf_release, CFTypeRef}; + use core_foundation_sys::dictionary::CFDictionaryRef; + use core_foundation_sys::string::CFStringRef; + + pub type OSStatus = i32; + pub type CMFormatDescriptionRef = *mut c_void; + pub type CMVideoFormatDescriptionRef = CMFormatDescriptionRef; + pub type CMBlockBufferRef = *mut c_void; + pub type CMSampleBufferRef = *mut c_void; + pub type VTDecompressionSessionRef = *mut c_void; + pub type CVImageBufferRef = *mut c_void; + pub type CVPixelBufferRef = *mut c_void; + + /// `kCVPixelFormatType_32BGRA` ('BGRA'). + pub const kCVPixelFormatType_32BGRA: i32 = 0x4247_5241; + /// `kCVPixelBufferLock_ReadOnly`. + pub const K_CVPIXELBUFFER_LOCK_READ_ONLY: u64 = 0x0000_0001; + /// `kCMTimeFlags_Valid`. + pub const K_CMTIME_FLAGS_VALID: u32 = 0x0000_0001; + /// `kCMBlockBufferAssureMemoryNowFlag`. + pub const kCMBlockBufferAssureMemoryNowFlag: u32 = 0x0000_0001; + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct CMTime { + pub value: i64, + pub timescale: i32, + pub flags: u32, + pub epoch: i64, + } + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct CMSampleTimingInfo { + pub duration: CMTime, + pub presentationTimeStamp: CMTime, + pub decodeTimeStamp: CMTime, + } + + pub type VTDecompressionOutputCallback = extern "C" fn( + refcon: *mut c_void, + source_frame_refcon: *mut c_void, + status: OSStatus, + info_flags: u32, + image_buffer: CVImageBufferRef, + pts: CMTime, + duration: CMTime, + ); + + #[repr(C)] + pub struct VTDecompressionOutputCallbackRecord { + pub decompressionOutputCallback: Option, + pub decompressionOutputRefCon: *mut c_void, + } + + /// Re-export CoreFoundation's `CFRelease` under the name this module uses. + #[inline] + pub unsafe fn CFRelease(obj: CFTypeRef) { + cf_release(obj) + } + + #[link(name = "CoreMedia", kind = "framework")] + extern "C" { + pub fn CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: CFAllocatorRef, + parameter_set_count: usize, + parameter_set_pointers: *const *const u8, + parameter_set_sizes: *const usize, + nal_unit_header_length: i32, + format_description_out: *mut CMFormatDescriptionRef, + ) -> OSStatus; + + pub fn CMBlockBufferCreateWithMemoryBlock( + structure_allocator: CFAllocatorRef, + memory_block: *mut c_void, + block_length: usize, + block_allocator: CFAllocatorRef, + custom_block_source: *const c_void, + offset_to_data: usize, + data_length: usize, + flags: u32, + block_buffer_out: *mut CMBlockBufferRef, + ) -> OSStatus; + + pub fn CMBlockBufferReplaceDataBytes( + source_bytes: *const c_void, + destination_buffer: CMBlockBufferRef, + offset_into_destination: usize, + data_length: usize, + ) -> OSStatus; + + pub fn CMSampleBufferCreateReady( + allocator: CFAllocatorRef, + data_buffer: CMBlockBufferRef, + format_description: CMFormatDescriptionRef, + num_samples: isize, + num_sample_timing_entries: isize, + sample_timing_array: *const CMSampleTimingInfo, + num_sample_size_entries: isize, + sample_size_array: *const usize, + sample_buffer_out: *mut CMSampleBufferRef, + ) -> OSStatus; + } + + #[link(name = "VideoToolbox", kind = "framework")] + extern "C" { + pub fn VTDecompressionSessionCreate( + allocator: CFAllocatorRef, + video_format_description: CMVideoFormatDescriptionRef, + video_decoder_specification: CFDictionaryRef, + destination_image_buffer_attributes: CFDictionaryRef, + output_callback: *const VTDecompressionOutputCallbackRecord, + decompression_session_out: *mut VTDecompressionSessionRef, + ) -> OSStatus; + + pub fn VTDecompressionSessionDecodeFrame( + session: VTDecompressionSessionRef, + sample_buffer: CMSampleBufferRef, + decode_flags: u32, + source_frame_refcon: *mut c_void, + info_flags_out: *mut u32, + ) -> OSStatus; + + pub fn VTDecompressionSessionWaitForAsynchronousFrames( + session: VTDecompressionSessionRef, + ) -> OSStatus; + + pub fn VTDecompressionSessionInvalidate(session: VTDecompressionSessionRef); + } + + #[link(name = "CoreVideo", kind = "framework")] + extern "C" { + pub static kCVPixelBufferPixelFormatTypeKey: CFStringRef; + + pub fn CVPixelBufferLockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32; + pub fn CVPixelBufferUnlockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32; + pub fn CVPixelBufferGetBaseAddress(pb: CVPixelBufferRef) -> *mut c_void; + pub fn CVPixelBufferGetBytesPerRow(pb: CVPixelBufferRef) -> usize; + pub fn CVPixelBufferGetWidth(pb: CVPixelBufferRef) -> usize; + pub fn CVPixelBufferGetHeight(pb: CVPixelBufferRef) -> usize; + } +} diff --git a/native/core/src/error.rs b/native/core/src/error.rs new file mode 100644 index 0000000..44757d8 --- /dev/null +++ b/native/core/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum NdError { + #[error("connection failed: {0}")] + Connect(String), + #[error("protocol error: {0}")] + Protocol(String), + #[error("authentication failed: {0}")] + Auth(String), + #[error("session denied: {0}")] + Denied(String), + #[error("crypto error: {0}")] + Crypto(String), + #[error("websocket closed")] + Closed, + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +pub type Result = std::result::Result; diff --git a/native/core/src/host.rs b/native/core/src/host.rs new file mode 100644 index 0000000..838cff2 --- /dev/null +++ b/native/core/src/host.rs @@ -0,0 +1,1049 @@ +//! HOST side of the NetDesk protocol — lets a viewer connect to and control +//! THIS device. Built for the Android host (screen via MediaProjection + +//! MediaCodec, input via the accessibility service), but transport-agnostic: +//! it takes encoded H.264 access units on a channel and emits the viewer's +//! input as events for the platform layer to inject. +//! +//! It mirrors [`crate::signaling::connect`] (the viewer) in reverse: +//! reg.host -> session.begin -> session.challenge -> session.auth +//! -> session.accept -> stream `CHAN_VIDEO` frames (E2E-sealed) +//! Auth + E2E reuse [`crate::crypto`] (`verify_proof`, [`HostE2e`], +//! `encrypt_frame`), proven to interoperate with the web/desktop/native viewers. + +use crate::crypto::{self, HostE2e}; +use crate::error::{NdError, Result}; +use crate::proto::{CHAN_AUDIO, CHAN_FILE, CHAN_FILE_END, CHAN_VIDEO, CHAN_VOICE}; +use aes_gcm::Aes256Gcm; +use rand::RngCore; +use serde_json::{json, Value}; +use tokio::sync::mpsc; + +fn install_crypto_provider() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Per-session permission switches the host ENFORCES (events for a disabled +/// channel are never emitted to the platform layer, so a modified viewer can't +/// bypass them). Advertised in `session.accept` `info.perms` with the same +/// full-name keys the Python engine uses, so the web viewer's existing +/// `info.perms.clipboard === false` checks light up unchanged. +#[derive(Clone, Copy, Debug)] +pub struct HostPerms { + pub input: bool, + pub clipboard: bool, + pub files: bool, + pub audio: bool, +} + +impl Default for HostPerms { + fn default() -> Self { + Self { input: true, clipboard: true, files: true, audio: true } + } +} + +/// Host identity + unattended credentials (the fields the Python engine keeps in +/// config.json). Generate fresh ones with [`crate::crypto::new_password_record`] +/// + [`crate::crypto::gen_host_id`]. +#[derive(Clone)] +pub struct HostConfig { + pub ws_url: String, + pub host_id: String, + pub name: String, + /// base64 of the RAW salt bytes (sent in the challenge). + pub salt_b64: String, + pub iters: u32, + /// PBKDF2 verifier (hex) — checked against the viewer's proof. + pub verifier_hex: String, + /// Current capture resolution advertised to the viewer. + pub width: u32, + pub height: u32, + /// Whether this host streams system audio (Opus over CHAN_AUDIO). Advertised + /// in session.accept so the viewer knows to expect audio. + pub audio: bool, + /// All display outputs as (index, width, height); `index` is what the viewer + /// echoes in `monitor.switch{mon}`. Empty falls back to a single monitor. + pub monitors: Vec<(u32, u32, u32)>, + /// The currently-captured monitor index (advertised as `info.mon`). + pub monitor: u32, + /// Host-enforced per-session permissions (see [`HostPerms`]). Defaults to + /// everything allowed — exactly the pre-perms behavior. + pub perms: HostPerms, + /// Optional TOTP (RFC 6238) 2FA secret (base32). When set, the host + /// advertises `totp:true` in the challenge and requires a valid code in + /// `session.auth` before accepting. Empty disables 2FA. + pub totp_secret: String, + /// Approve-before-connect: when true, a viewer that passes password auth is + /// HELD until the platform answers [`HostEvent::ApproveRequest`] with + /// [`HostControl::ApproveReply`]. No answer within ~20s denies (the relay + /// frees a pre-accept session slot at 25s, so the window must beat that). + pub approve: bool, +} + +/// One encoded video access unit from the platform encoder. Clone so one +/// capture+encode can fan out to multiple simultaneous viewer sessions (each +/// seals it with its own E2E key). +#[derive(Clone)] +pub struct HostFrame { + pub data: Vec, // codec bitstream (H.264 Annex-B, or VP9) + pub keyframe: bool, + /// Codec id for this frame (see `proto::VideoCodec`): 0 = H.264, 1 = VP9. + /// Packed into the frame's flags byte so the viewer selects the decoder. + pub codec: u8, +} + +/// Outbound control the platform layer pushes to the connected viewer (sent as +/// plaintext JSON over the relay, like the rest of the control channel). The +/// video stream stays E2E-encrypted; these mirror the existing protocol. +#[derive(Clone, Debug)] +pub enum HostControl { + /// Resolution / stream info changed (e.g. the quality scale lever) — the + /// viewer trusts `session.info` for the authoritative frame size. + Info { w: u32, h: u32 }, + /// The host's OS clipboard changed; push it to the viewer (origin "host"). + Clipboard { text: String }, + /// The host's OS clipboard now holds an IMAGE; push it to the viewer as PNG + /// bytes (origin "host"). Sent as `clip.image` with base64 PNG. + ClipboardImage { png: Vec }, + /// A ready-made control JSON object to send verbatim (e.g. a `sys.res` + /// reply the platform built, already carrying its `sid`). + Raw { json: String }, + /// Remote cursor position in frame-pixel coordinates (`hidden` = not shown). + Cursor { id: u64, x: i32, y: i32, hidden: bool }, + /// Remote cursor shape: a CSS name for a standard system cursor, and/or a + /// PNG bitmap (+ size/hotspot) for an app-custom cursor so it renders on the + /// viewer instead of falling back to the default pointer. `png` empty = none. + CursorShape { + id: u64, + css: Option, + png: Vec, + w: u32, + h: u32, + hx: i32, + hy: i32, + }, + /// One download data chunk (host→viewer, 0xf1 frame): fid + byte offset + + /// bytes. Sent E2E-sealed exactly like video. + FileData { fid: u32, offset: u64, data: Vec }, + /// One Opus audio packet (system-audio capture): sent as `[CHAN_AUDIO][opus]`, + /// E2E-sealed like video, over the reliable Ctl lane. + AudioFrame(Vec), + /// Raw TCP-tunnel bytes from the host's local socket to relay to the tunnel + /// peer: sent as `[0xF3][tid u32 LE][data]`, NOT E2E-sealed (the server + /// relays it verbatim to the portal-authed tunnel client). + TunnelData { tid: u32, data: Vec }, + /// The host's local TCP socket for `tid` closed: drop tracking + tell the + /// server (`tunnel.closed`) so it can close the client socket. + TunnelClosed { tid: u32 }, + /// Terminal `tid` produced output — send it to the viewer (`term.data`). + TermData { tid: u32, data: String }, + /// Terminal `tid`'s shell exited — tell the viewer (`term.exit`). + TermExit { tid: u32 }, + /// The platform's answer to [`HostEvent::ApproveRequest`]: accept finalizes + /// the held session (session.accept + ViewerConnected); reject denies it. + /// A reply whose `sid` no longer matches the held session is ignored (e.g. + /// the engine's own 20s timeout already denied it). + ApproveReply { sid: String, accept: bool }, +} + +/// Events the platform layer reacts to (input to inject, keyframe requests, …). +#[derive(Clone, Debug)] +pub enum HostEvent { + /// Registered with the server under `host_id`; now reachable. + Registered, + /// A viewer authenticated and the session is live. `vp9` is the viewer's + /// advertised VP9 decode capability (from `session.begin`); the host only + /// sends VP9 frames when it is true. + ViewerConnected { e2e: bool, vp9: bool, i444: bool }, + /// The viewer updated its capabilities post-connect (e.g. VP9 decode), sent + /// as a `viewer.caps` control message since the server strips the handshake + /// advertisement. Updates the live codec gate. + ViewerCaps { vp9: bool, i444: bool }, + /// The viewer ended the session (host stays registered for the next one). + ViewerLeft, + /// Pointer in the captured image's pixel space (buttons = bitmask, wheel = rows). + Mouse { x: i32, y: i32, buttons: u32, wheel: f32 }, + /// Relative pointer motion (game / pointer-lock mode): dx/dy deltas. + MouseRel { dx: i32, dy: i32, buttons: u32, wheel: f32 }, + /// Typed text for translate / international-keyboard mode (Unicode). + Text { text: String }, + /// Key: DOM-style code (e.g. "KeyA", "Enter", "Backspace") + down/up. + Key { code: String, down: bool }, + /// The viewer pushed its local clipboard text; apply it to the host OS + /// clipboard (RustDesk-style clipboard sync, viewer -> host). + Clipboard { text: String }, + /// The viewer pushed an IMAGE to apply to the host clipboard (PNG bytes). + ClipboardImage { png: Vec }, + /// A `sys.cmd` control (process list/kill, block-input, wallpaper, power, + /// run, …). The raw JSON is handed to the platform, which acts and replies. + Sys { json: String }, + /// Collaborative annotation: a viewer stroke/clear for the host overlay. + Annot { json: String }, + /// A `file.*` control (roots/list/get/put). The platform enumerates or + /// starts the transfer and replies via [`HostControl`]. + File { json: String }, + /// Open an interactive terminal (ConPTY) `tid`; stream its output back via + /// [`HostControl::TermData`] and signal exit with [`HostControl::TermExit`]. + TermOpen { tid: u32 }, + /// Keystrokes / command bytes for terminal `tid` (write to its ConPTY input). + TermInput { tid: u32, data: String }, + /// Resize terminal `tid` to cols×rows (ResizePseudoConsole). + TermResize { tid: u32, cols: u32, rows: u32 }, + /// Close terminal `tid` (kill its shell). + TermClose { tid: u32 }, + /// One decrypted upload data chunk (viewer→host, 0xf2 frame) to write. + FileChunk { fid: u32, offset: u64, data: Vec }, + /// One Opus packet of the viewer's microphone (voice call, `[CHAN_VOICE][opus]`). + /// The platform decodes it and plays it on the host's default render device. + Voice { opus: Vec }, + /// The viewer requested a fresh keyframe — feed one next. + KeyframeRequested, + /// The viewer asked to capture a different monitor (`monitor.switch{mon}`). + SwitchMonitor { mon: u32 }, + /// The viewer changed quality targets (manual lever — the adaptive ceiling). + Quality { fps: u32, bitrate: u32, scale: f32 }, + /// A network telemetry sample measured by the host (round-trip time to the + /// viewer + packet loss), driving the adaptive QoS controller. + NetStat { rtt_ms: u32, loss: f32 }, + /// The viewer selected the host audio source: 0 = off, 1 = system (what you + /// hear), 2 = microphone. + AudioSource { src: u8 }, + /// The viewer pinned the video codec for this session (e.g. while recording, + /// which muxes H.264 only): -1 = auto (release the pin), 0 = H.264, 1 = VP9. + /// The capture loop forces this codec instead of auto-selecting by link. + PinCodec { codec: i32 }, + /// Approve-before-connect (`cfg.approve`): a viewer passed password auth and + /// is HELD pending the platform's Accept/Reject decision — show a prompt and + /// answer with [`HostControl::ApproveReply`]. `peer`/`user` are what the + /// relay reported in `session.begin` (viewer IP + account), for display. + ApproveRequest { sid: String, peer: String, user: String }, + /// The viewer toggled host-enforced view-only for this session: while on, + /// the engine drops all mouse/key input before it reaches the platform. + /// Platforms should release any held buttons when it turns on. + ViewOnly { on: bool }, + /// The server (portal-authed tunnel client) asks this host to open a TCP + /// tunnel to `thost:port`. Independent of any viewer session. The platform + /// opens the socket and replies `tunnel.ok`/`tunnel.err` via HostControl::Raw. + TunnelOpen { tid: u32, thost: String, port: u16 }, + /// Close the TCP tunnel `tid` (the tunnel client disconnected). + TunnelClose { tid: u32 }, + /// Raw bytes from the tunnel peer to write to the host's local TCP socket. + TunnelData { tid: u32, data: Vec }, + /// The host link ended (socket closed / error). The caller should reconnect. + Ended { reason: String }, +} + +fn gen_nonce_hex() -> String { + let mut b = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut b); + hex::encode(b) // 32 hex chars, matching secrets.token_hex(16) +} + +/// Build the `session.accept` info object: resolution/codec hints, the REAL +/// enforced permission set (full-name keys, matching the Python engine so the +/// web viewer's `info.perms.*` checks just work), and capability flags. +fn build_info(cfg: &HostConfig, e2e: bool) -> Value { + let monitors: Vec = if cfg.monitors.is_empty() { + vec![json!({"i": cfg.monitor, "w": cfg.width, "h": cfg.height})] + } else { + cfg.monitors + .iter() + .map(|(i, w, h)| { + // u32::MAX is the all-monitors sentinel; advertise it as -1 + // (MonitorInfo.i is i32, so u32::MAX could not deserialize). + let iv: i64 = if *i == u32::MAX { -1 } else { *i as i64 }; + json!({"i": iv, "w": w, "h": h}) + }) + .collect() + }; + let mut info = json!({ + "mon": cfg.monitor, + "monitors": monitors, + "w": cfg.width, "h": cfg.height, + "fps": 30, "bitrate": 6000, + "e2e": e2e, "audio": cfg.audio && cfg.perms.audio, + "host_name": cfg.name, "os": "windows", + // The ENFORCED per-session permissions — not a suggestion: the engine + // never emits events for a disabled channel (see the handler gates). + "perms": { + "input": cfg.perms.input, + "clipboard": cfg.perms.clipboard, + "files": cfg.perms.files, + "audio": cfg.perms.audio, + }, + "version": "native-host", + // Privacy mode is off at connect; live changes are reported via + // sys.res {kind:"privacy", on:} when the viewer toggles it. + "privacy_active": false, + // File-range capability: this host supports ranged file.get{offset,len} + // + file.ack flow control, so viewers may download in parallel segments + // (IDM-style) with resume. + "fr": true, + }); + // Advertise native single-channel P2P so a native viewer only upgrades + // against a native host; web/Python hosts omit this and the viewer stays on + // the relay (safe default-on). NETDESK_P2P_FORCE_WEB suppresses it so a + // native host presents as a web host — used to e2e the web answerer path. + if std::env::var("NETDESK_P2P_FORCE_WEB").is_err() { + info["p2p"] = json!("native"); + } + info +} + +/// Run the host until `frames` closes or the socket drops. Serves one viewer at +/// a time (a second `session.begin` while busy is denied). Returns when the link +/// ends; the caller reconnects for resilience. +pub async fn run_host( + cfg: HostConfig, + frames: mpsc::UnboundedReceiver, + events: mpsc::UnboundedSender, + control: mpsc::UnboundedReceiver, +) -> Result<()> { + install_crypto_provider(); + let transport = crate::transport::WsRelay::connect(&cfg.ws_url).await?; + // Host answers a viewer-initiated P2P upgrade when NETDESK_P2P is on; the + // multiplexer is a no-op passthrough to the relay otherwise. + if crate::p2p::p2p_enabled() { + let api = std::sync::Arc::new( + crate::rtc::build_api().map_err(|e| NdError::Connect(e.to_string()))?, + ); + let mux = + crate::p2p::upgrade_relay(Box::new(transport), crate::p2p::P2pRole::Answerer, api); + run_host_with(cfg, frames, events, control, Box::new(mux)).await + } else { + run_host_with(cfg, frames, events, control, Box::new(transport)).await + } +} + +/// Run the host over any [`crate::transport::Transport`]. `run_host` is the thin +/// WSS wrapper over this. +pub async fn run_host_with( + cfg: HostConfig, + mut frames: mpsc::UnboundedReceiver, + events: mpsc::UnboundedSender, + mut control: mpsc::UnboundedReceiver, + mut transport: Box, +) -> Result<()> { + use crate::transport::TransportMsg; + + // Register as the host for `host_id` (takeover reclaims an idle stale holder; + // the server never displaces an ACTIVE session). + let reg = json!({ + "t": "reg.host", "id": cfg.host_id, "name": cfg.name, + "ver": "native-host", "key": "", "token": "", "takeover": true, + }); + transport.send_text(reg.to_string()) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + let _ = events.send(HostEvent::Registered); + + // Handshake + session state. + let mut pending: Option<(String, String, HostE2e)> = None; // (sid, nonce, ecdh) + let mut cipher: Option = None; + let mut viewer_vp9 = false; // viewer's advertised VP9 decode capability + let mut viewer_i444 = false; // viewer can decode VP9 profile-1 4:4:4 (crisp text) + let mut active_sid: Option = None; + // Approve-before-connect: a password-authenticated viewer HELD for the + // platform's Accept/Reject (sid, derived session cipher, deadline). + let mut approving: Option<(String, Option, std::time::Instant)> = None; + // Viewer identity the relay attached to session.begin (IP + account), shown + // in the approve prompt. + let mut peer_info: (String, String) = (String::new(), String::new()); + // Host-enforced view-only for the CURRENT session (viewer-requested via the + // `view.only` control message; input is dropped here, not just in the UI). + let mut view_only = false; + // One log line per session when input is dropped, not one per event. + let mut warned_input = false; + // Open TCP-tunnel ids (server-relayed, portal-authed, independent of a video + // session). Tracked so a raw `[0xF3][tid]` frame is only routed to the tunnel + // when that tid is actually open — otherwise an E2E session frame whose random + // nonce starts with 0xF3 could be misread as tunnel data. + let mut tunnel_tids: std::collections::HashSet = std::collections::HashSet::new(); + // Outbound control channel stays polled until its sender is dropped. + let mut control_open = true; + + // Network RTT probe: once a second, while a viewer is connected, send a + // net.ping; the viewer echoes net.pong and we measure the round trip. This + // drives the adaptive QoS controller. The ctl path shares the P2P SCTP + // association / the relay socket, so it rises under congestion. + let mut ping_tick = tokio::time::interval(std::time::Duration::from_millis(1000)); + ping_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ping_seq: u64 = 0; + let mut ping_inflight: Option<(u64, std::time::Instant)> = None; + // Adaptive-QoS telemetry: a rolling-baseline RTT (delay above the link's + // inherent floor, not absolute RTT) + a real packet-loss ratio from + // ping/pong hit-miss. The viewer's frame-arrival jitter rides in net.pong. + let mut rttcalc = crate::netqos::RttCalc::new(); + let mut losscalc = crate::netqos::LossCalc::new(); + + loop { + tokio::select! { + _ = ping_tick.tick() => { + if let Some(sid) = active_sid.as_deref() { + // A probe still in flight from the previous tick never got a + // pong -> count it as lost and let the loss drive the QoS. + if ping_inflight.is_some() { + let loss = losscalc.record(true); + let _ = events.send(HostEvent::NetStat { rtt_ms: 0, loss }); + } + // Only one probe in flight; a dropped pong just re-probes next tick. + ping_seq = ping_seq.wrapping_add(1); + ping_inflight = Some((ping_seq, std::time::Instant::now())); + let _ = transport.send_text( + json!({"t":"net.ping","sid":sid,"id":ping_seq}).to_string()).await; + } + // Approve-before-connect timeout: nobody answered the prompt in + // time — deny so the viewer gets a clear reason (and the relay's + // own 25s pre-accept reaper never has to fire). + if let Some((sid, _, deadline)) = &approving { + if std::time::Instant::now() >= *deadline { + let sid = sid.clone(); + approving = None; + log::info!("approve: timed out — denying sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; + } + } + } + inbound = transport.recv() => { + let msg = match inbound { + Some(Ok(m)) => m, + Some(Err(e)) => { let _ = events.send(HostEvent::Ended { reason: e.to_string() }); break; } + None => { let _ = events.send(HostEvent::Ended { reason: "socket closed".into() }); break; } + }; + match msg { + TransportMsg::Text(txt) => { + let v: Value = match serde_json::from_str(&txt) { Ok(v) => v, Err(_) => continue }; + match v.get("t").and_then(|x| x.as_str()).unwrap_or("") { + "session.begin" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let sid = if sid.is_empty() { gen_nonce_hex() } else { sid }; + // Remember whether this viewer can decode VP9 (+ 4:4:4). + viewer_vp9 = v.get("vp9").and_then(|x| x.as_bool()).unwrap_or(false); + viewer_i444 = v.get("i444").and_then(|x| x.as_bool()).unwrap_or(false); + // Viewer identity the relay attached (IP + account) — shown + // in the approve prompt. + peer_info = ( + v.get("peer").and_then(|x| x.as_str()).unwrap_or("").to_string(), + v.get("user").and_then(|x| x.as_str()).unwrap_or("").to_string(), + ); + if active_sid.is_some() || approving.is_some() { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"host busy"}).to_string()).await; + } else { + let he = HostE2e::new(); + let nonce = gen_nonce_hex(); + let ch = json!({ + "t": "session.challenge", "sid": sid, + "salt": cfg.salt_b64, "iters": cfg.iters, + "nonce": nonce, "e2e": true, + "totp": !cfg.totp_secret.is_empty(), + "pub": he.our_pub_b64, + }); + pending = Some((sid.clone(), nonce, he)); + let _ = transport.send_text(ch.to_string()).await; + } + } + "session.auth" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let proof = v.get("proof").and_then(|x| x.as_str()).unwrap_or(""); + match pending.take() { + Some((psid, nonce, he)) if psid == sid => { + // Channel-bound proof (MITM hardening): if the viewer + // sent `proof_bind`, it must verify against OUR ephemeral + // pubkey — a relay that swapped the pub in the challenge + // can't satisfy this, so a key swap is detected and denied. + // When absent (legacy viewer / non-E2E), fall back to the + // plain proof so nothing regresses. + let bind_ok = match v.get("proof_bind").and_then(|x| x.as_str()) { + Some(pb) => { + let ok = crypto::verify_proof_bound( + &nonce, &cfg.verifier_hex, &he.our_pub_b64, pb); + if !ok { + log::warn!("auth: channel-bound proof MISMATCH (possible relay key-swap) sid={sid}"); + } + ok + } + None => { + log::debug!("auth: no proof_bind (legacy/non-E2E viewer) sid={sid}"); + true + } + }; + if !bind_ok { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"key verification failed"}).to_string()).await; + } else if !crypto::verify_proof(&nonce, &cfg.verifier_hex, proof) { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"wrong password"}).to_string()).await; + } else if !cfg.totp_secret.is_empty() + && !crypto::totp_verify( + &cfg.totp_secret, + v.get("totp").and_then(|x| x.as_str()).unwrap_or(""), + 1, + ) + { + log::info!("auth: bad 2FA code sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"bad 2FA code"}).to_string()).await; + } else { + let derived = v.get("pub").and_then(|x| x.as_str()) + .and_then(|vp| he.derive(vp).ok()); + // VP9 capability rides session.auth (the server + // strips session.begin's custom fields but relays + // auth). Auth's value wins if present. + if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { + viewer_vp9 = b; + } + if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { + viewer_i444 = b; + } + if cfg.approve { + // Hold the session for the platform's Accept/ + // Reject prompt. 20s beats the relay's 25s + // pre-accept reaper, so the deny is always ours. + log::info!( + "approve: viewer {} {} passed auth — awaiting accept (sid={sid})", + peer_info.0, peer_info.1 + ); + approving = Some(( + sid.clone(), + derived, + std::time::Instant::now() + + std::time::Duration::from_secs(20), + )); + let _ = events.send(HostEvent::ApproveRequest { + sid, + peer: peer_info.0.clone(), + user: peer_info.1.clone(), + }); + } else { + cipher = derived; + active_sid = Some(sid.clone()); + view_only = false; + warned_input = false; + let info = build_info(&cfg, cipher.is_some()); + let _ = transport.send_text( + json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; + let _ = events.send(HostEvent::ViewerConnected { e2e: cipher.is_some(), vp9: viewer_vp9, i444: viewer_i444 }); + } + } + } + _ => { + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"no pending session"}).to_string()).await; + } + } + } + "i.mouse" if active_sid.is_some() => { + // HOST-ENFORCED gate: a disabled input perm (or + // host-enforced view-only) drops the event here, + // so a modified viewer can't inject anyway. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let _ = events.send(HostEvent::Mouse { + x: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + y: v.get("y").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + }); + } + "i.mouse.rel" if active_sid.is_some() => { + // Same HOST-ENFORCED input gate as i.mouse. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let _ = events.send(HostEvent::MouseRel { + dx: v.get("dx").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + dy: v.get("dy").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + }); + } + "i.text" if active_sid.is_some() => { + // Same HOST-ENFORCED input gate as i.mouse / i.key. + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + if let Some(t) = v.get("text").and_then(|x| x.as_str()) { + if !t.is_empty() { + let _ = events.send(HostEvent::Text { text: t.to_string() }); + } + } + } + "i.key" if active_sid.is_some() => { + if !cfg.perms.input || view_only { + if !warned_input { + log::info!( + "perm: input blocked (perms.input={}, view_only={view_only})", + cfg.perms.input + ); + warned_input = true; + } + continue; + } + let code = v.get("c").and_then(|x| x.as_str()).unwrap_or("").to_string(); + if !code.is_empty() { + let _ = events.send(HostEvent::Key { + code, + down: v.get("d").and_then(|x| x.as_bool()).unwrap_or(false), + }); + } + } + // Host-enforced view-only toggle from the viewer: input + // is dropped in the gates above while on (defense in + // depth under the viewer's own local toggle). + "view.only" if active_sid.is_some() => { + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(false); + if view_only != on { + view_only = on; + warned_input = false; + log::info!("view-only (host-enforced): {}", if on { "ON" } else { "off" }); + let _ = events.send(HostEvent::ViewOnly { on }); + } + } + "clip.set" if active_sid.is_some() => { + // Only apply viewer-originated clips (the host's + // own clipboard is pushed the other way). + if !cfg.perms.clipboard { + log::info!("perm: clipboard push from viewer blocked"); + continue; + } + if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { + if let Some(text) = v.get("text").and_then(|x| x.as_str()) { + let _ = events.send(HostEvent::Clipboard { + text: text.to_string(), + }); + } + } + } + "clip.image" if active_sid.is_some() => { + if !cfg.perms.clipboard { + log::info!("perm: clipboard image from viewer blocked"); + continue; + } + if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { + if let Some(b64) = v.get("png").and_then(|x| x.as_str()) { + use base64::Engine as _; + if let Ok(png) = + base64::engine::general_purpose::STANDARD.decode(b64) + { + let _ = events.send(HostEvent::ClipboardImage { png }); + } + } + } + } + "annot" if active_sid.is_some() => { + // Drawing on the host screen is an interaction, + // so it rides the same input/view-only gate. + if !cfg.perms.input || view_only { + continue; + } + let _ = events.send(HostEvent::Annot { json: txt.clone() }); + } + "sys.cmd" if active_sid.is_some() => { + let _ = events.send(HostEvent::Sys { json: txt.clone() }); + } + "term.open" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = events.send(HostEvent::TermOpen { tid }); + } + "term.data" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let data = v.get("d").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = events.send(HostEvent::TermInput { tid, data }); + } + "term.resize" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let cols = v.get("cols").and_then(|x| x.as_u64()).unwrap_or(80) as u32; + let rows = v.get("rows").and_then(|x| x.as_u64()).unwrap_or(25) as u32; + let _ = events.send(HostEvent::TermResize { tid, cols, rows }); + } + "term.close" if active_sid.is_some() => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = events.send(HostEvent::TermClose { tid }); + } + "file.roots" | "file.list" | "file.walk" | "file.get" | "file.put" + if active_sid.is_some() => + { + if !cfg.perms.files { + log::info!("perm: file request blocked"); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or(""); + let _ = transport.send_text(json!({ + "t":"file.err","sid":sid, + "msg":"file transfer is disabled on this host" + }).to_string()).await; + continue; + } + let _ = events.send(HostEvent::File { json: txt.clone() }); + } + "net.pong" => { + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + if let Some((pid, sent)) = ping_inflight { + if pid == id { + let rtt_ms = sent.elapsed().as_millis().min(60_000) as u32; + ping_inflight = None; + // Feed QoS the delay ABOVE the rolling baseline + // (not the absolute RTT), folded with the + // viewer's reported frame-arrival jitter, plus + // the real loss ratio. + let delay = rttcalc.add(rtt_ms); + let jit = v.get("jit").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let loss = losscalc.record(false); + log::debug!( + "netstat: rtt={rtt_ms}ms baseline_delay={delay}ms jit={jit}ms loss={loss:.2} -> qos_delay={}ms", + delay.max(jit) + ); + let _ = events.send(HostEvent::NetStat { + rtt_ms: delay.max(jit), + loss, + }); + } + } + } + // Post-connect capability advertisement. The server + // strips custom handshake fields, so the viewer sends + // its VP9 decode capability here (relayed by sid like + // any input message). Enables auto-VP9 on slow links. + "viewer.caps" => { + let mut changed = false; + if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { + viewer_vp9 = b; + changed = true; + } + if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { + viewer_i444 = b; + changed = true; + } + if changed { + let _ = events.send(HostEvent::ViewerCaps { vp9: viewer_vp9, i444: viewer_i444 }); + } + } + "keyframe" => { let _ = events.send(HostEvent::KeyframeRequested); } + "audio.set" if active_sid.is_some() => { + let src = v.get("src").and_then(|x| x.as_u64()).unwrap_or(0) as u8; + // "off" is always allowed; starting capture needs the perm. + if src != 0 && !cfg.perms.audio { + log::info!("perm: audio blocked"); + continue; + } + let _ = events.send(HostEvent::AudioSource { src }); + } + "set.codec" if active_sid.is_some() => { + // Viewer pins the codec (e.g. H.264 while recording). + let codec = v.get("codec").and_then(|x| x.as_i64()).unwrap_or(-1) as i32; + let _ = events.send(HostEvent::PinCodec { codec }); + } + "monitor.switch" if active_sid.is_some() => { + if let Some(mon) = v.get("mon").and_then(|x| x.as_i64()) { + // -1 (any negative) selects the all-monitors virtual + // desktop (capture::MON_ALL == u32::MAX on the host). + let mon = if mon < 0 { u32::MAX } else { mon as u32 }; + let _ = events.send(HostEvent::SwitchMonitor { mon }); + } + } + "set.quality" => { + let _ = events.send(HostEvent::Quality { + fps: v.get("fps").and_then(|x| x.as_u64()).unwrap_or(30) as u32, + bitrate: v.get("bitrate").and_then(|x| x.as_u64()).unwrap_or(6000) as u32, + scale: v.get("scale").and_then(|x| x.as_f64()).unwrap_or(1.0) as f32, + }); + } + // TCP tunnels live OUTSIDE the video session (server + // portal-gated), so they are handled without an active_sid. + "tunnel.open" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let thost = v.get("thost").and_then(|x| x.as_str()) + .unwrap_or("127.0.0.1").to_string(); + let port = v.get("port").and_then(|x| x.as_u64()).unwrap_or(0) as u16; + tunnel_tids.insert(tid); + let _ = events.send(HostEvent::TunnelOpen { tid, thost, port }); + } + "tunnel.close" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + tunnel_tids.remove(&tid); + let _ = events.send(HostEvent::TunnelClose { tid }); + } + "session.end" => { + active_sid = None; + cipher = None; + pending = None; + approving = None; + view_only = false; + let _ = events.send(HostEvent::ViewerLeft); + } + _ => {} + } + } + TransportMsg::Binary(buf) => { + // Raw TCP-tunnel frame [0xF3][tid u32 LE][payload] — server- + // relayed, NOT E2E-sealed. Routed only when tid is an open + // tunnel, so it can't be confused with an E2E session frame + // whose random nonce happens to start with 0xF3. + if buf.len() >= 5 && buf[0] == 0xF3 { + let tid = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]); + if tunnel_tids.contains(&tid) { + let _ = events.send(HostEvent::TunnelData { + tid, + data: buf[5..].to_vec(), + }); + continue; + } + } + // Viewer->host binary is an upload data frame, E2E-sealed + // like video: decrypt, then [0xf2][fid u32][offset u64][data]. + if active_sid.is_some() { + let plain = match &cipher { + Some(c) => match crypto::decrypt_frame(c, &buf) { + Ok(p) => p, + Err(_) => continue, + }, + None => buf, + }; + if plain.len() >= 13 + && (plain[0] == CHAN_FILE_END || plain[0] == CHAN_FILE) + { + if !cfg.perms.files { + log::debug!("perm: upload chunk dropped"); + continue; + } + let fid = u32::from_le_bytes( + [plain[1], plain[2], plain[3], plain[4]]); + let offset = u64::from_le_bytes([ + plain[5], plain[6], plain[7], plain[8], + plain[9], plain[10], plain[11], plain[12], + ]); + let _ = events.send(HostEvent::FileChunk { + fid, + offset, + data: plain[13..].to_vec(), + }); + } else if plain.len() >= 2 && plain[0] == CHAN_VOICE { + // Viewer microphone (voice call): [CHAN_VOICE][opus]. + // Voice rides the audio permission. + if !cfg.perms.audio { + log::debug!("perm: voice packet dropped"); + continue; + } + let _ = events.send(HostEvent::Voice { + opus: plain[1..].to_vec(), + }); + } + } + } + } + } + frame = frames.recv() => { + match frame { + Some(f) => { + if active_sid.is_some() { + // plaintext = CHAN_VIDEO || flags || bitstream, then + // the WHOLE thing is E2E-sealed (viewer decrypts, reads + // plain[0]=channel, plain[1]=flags, plain[2..]=bitstream). + // flags: bit0 = keyframe, bits1-3 = codec id (0=H.264, + // 1=VP9) — the viewer picks the matching decoder. + let mut plain = Vec::with_capacity(2 + f.data.len()); + plain.push(CHAN_VIDEO); + plain.push(u8::from(f.keyframe) | (f.codec << 1)); + plain.extend_from_slice(&f.data); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { Ok(w) => w, Err(_) => continue }, + None => plain, + }; + // Video -> Media lane (drop-tolerant: a late frame is + // worse than a dropped one). + if transport.send_binary_lane(wire, crate::transport::Lane::Media).await.is_err() { break; } + } + } + None => break, // encoder stopped -> end the host run + } + } + ctl = control.recv(), if control_open => { + match ctl { + // The relay routes host->viewer control by `sid`; messages + // without the active sid are dropped, so always include it. + Some(HostControl::Info { w, h }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"session.info","sid":sid,"info":{"w":w,"h":h}}).to_string()).await; + } + } + Some(HostControl::Clipboard { text }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"clip.set","sid":sid,"text":text,"origin":"host"}).to_string()).await; + } + } + Some(HostControl::ClipboardImage { png }) => { + if let Some(sid) = active_sid.as_deref() { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&png); + let _ = transport.send_text( + json!({"t":"clip.image","sid":sid,"png":b64,"origin":"host"}).to_string()).await; + } + } + Some(HostControl::Raw { json }) => { + // Already a complete control object (carries its own sid). + let _ = transport.send_text(json).await; + } + Some(HostControl::TunnelData { tid, data }) => { + // Raw tunnel bytes to the server: [0xF3][tid LE][data], NOT + // E2E-sealed (the server relays it verbatim). Over the relay + // (reliable), never the P2P media lane. + let mut frame = Vec::with_capacity(5 + data.len()); + frame.push(0xF3); + frame.extend_from_slice(&tid.to_le_bytes()); + frame.extend_from_slice(&data); + if transport.send_binary(frame).await.is_err() { + break; + } + } + Some(HostControl::TunnelClosed { tid }) => { + tunnel_tids.remove(&tid); + let _ = transport.send_text( + json!({"t":"tunnel.closed","tid":tid}).to_string()).await; + } + Some(HostControl::TermData { tid, data }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"term.data","sid":sid,"tid":tid,"d":data}).to_string()).await; + } + } + Some(HostControl::TermExit { tid }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"term.exit","sid":sid,"tid":tid}).to_string()).await; + } + } + Some(HostControl::ApproveReply { sid, accept }) => { + // Finalize (or deny) the session held for approve-before- + // connect. A stale reply (sid mismatch — e.g. the 20s + // timeout already denied it) is ignored. + let matches = approving.as_ref().map(|(s, _, _)| s == &sid).unwrap_or(false); + if matches { + let (sid, derived, _) = approving.take().unwrap(); + if accept { + log::info!("approve: ACCEPTED sid={sid}"); + cipher = derived; + active_sid = Some(sid.clone()); + view_only = false; + warned_input = false; + let info = build_info(&cfg, cipher.is_some()); + let _ = transport.send_text( + json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; + let _ = events.send(HostEvent::ViewerConnected { + e2e: cipher.is_some(), + vp9: viewer_vp9, + i444: viewer_i444, + }); + } else { + log::info!("approve: REJECTED sid={sid}"); + let _ = transport.send_text( + json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; + } + } + } + Some(HostControl::Cursor { id, x, y, hidden }) => { + if let Some(sid) = active_sid.as_deref() { + let _ = transport.send_text( + json!({"t":"cursor","sid":sid,"id":id,"x":x,"y":y,"hidden":hidden}).to_string()).await; + } + } + Some(HostControl::CursorShape { id, css, png, w, h, hx, hy }) => { + if let Some(sid) = active_sid.as_deref() { + use base64::Engine as _; + let mut msg = json!({"t":"cursor.shape","sid":sid,"id":id}); + if let Some(css) = css { + msg["css"] = json!(css); + } + if !png.is_empty() { + msg["png"] = json!(base64::engine::general_purpose::STANDARD.encode(&png)); + msg["w"] = json!(w); + msg["h"] = json!(h); + msg["hx"] = json!(hx); + msg["hy"] = json!(hy); + } + let _ = transport.send_text(msg.to_string()).await; + } + } + Some(HostControl::FileData { fid, offset, data }) => { + // Download chunk host->viewer: [0xf1][fid][offset][data], + // E2E-sealed like video. + if active_sid.is_some() { + let mut plain = Vec::with_capacity(13 + data.len()); + plain.push(CHAN_FILE); + plain.extend_from_slice(&fid.to_le_bytes()); + plain.extend_from_slice(&offset.to_le_bytes()); + plain.extend_from_slice(&data); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { + Ok(w) => w, + Err(_) => continue, + }, + None => plain, + }; + // File download chunk -> Ctl lane (must be reliable). + if transport + .send_binary_lane(wire, crate::transport::Lane::Ctl) + .await + .is_err() + { + break; + } + } + } + Some(HostControl::AudioFrame(opus)) => { + // [CHAN_AUDIO][opus], E2E-sealed like video. Reliable Ctl + // lane: audio dropouts are more jarring than a late video + // frame, and packets are tiny (~50 B at 50/s). + if active_sid.is_some() { + let mut plain = Vec::with_capacity(1 + opus.len()); + plain.push(CHAN_AUDIO); + plain.extend_from_slice(&opus); + let wire = match &cipher { + Some(c) => match crypto::encrypt_frame(c, &plain) { + Ok(w) => w, + Err(_) => continue, + }, + None => plain, + }; + if transport + .send_binary_lane(wire, crate::transport::Lane::Ctl) + .await + .is_err() + { + break; + } + } + } + None => { control_open = false; } // sender dropped; stop polling this arm + } + } + } + } + Ok(()) +} diff --git a/native/core/src/lan.rs b/native/core/src/lan.rs new file mode 100644 index 0000000..6d6aabb --- /dev/null +++ b/native/core/src/lan.rs @@ -0,0 +1,205 @@ +//! LAN peer discovery over UDP broadcast. +//! +//! Mirrors RustDesk's `src/lan.rs` query/reply model: a viewer broadcasts a +//! small query to the LAN; every reachable host replies with its id, name and +//! the port it accepts a direct connection on. The viewer learns each host's IP +//! from the reply's source address — so two machines on the same subnet can find +//! each other with no rendezvous/signaling server involved (works even when the +//! internet is down). +//! +//! This module is the discovery half; establishing the session over the +//! discovered `ip:port` (a direct transport, bypassing the relay) is the wiring +//! step that builds on it. Packets are small JSON for debuggability. +//! +//! Developed by Akronlogic. + +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +/// Default UDP port for NetDesk LAN discovery. +pub const LAN_PORT: u16 = 28118; + +/// A host discovered on the LAN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LanHost { + pub id: String, + pub name: String, + /// Where to reach the host directly: its LAN IP (from the reply source) and + /// the direct-connect port it advertised. + pub addr: SocketAddr, +} + +#[derive(Serialize, Deserialize)] +struct Query<'a> { + t: &'a str, // "netdesk.lan.query" +} + +#[derive(Serialize, Deserialize)] +struct Announce { + t: String, // "netdesk.lan.host" + id: String, + name: String, + port: u16, // direct-connect port +} + +const QUERY_T: &str = "netdesk.lan.query"; +const HOST_T: &str = "netdesk.lan.host"; + +/// The targets a production scan queries: the global broadcast address plus +/// loopback (so a host on this same machine is found even where broadcast to +/// self is unreliable). A caller may add subnet-directed broadcasts too. +pub fn broadcast_targets() -> Vec { + vec![ + SocketAddr::from(([255, 255, 255, 255], LAN_PORT)), + SocketAddr::from(([127, 0, 0, 1], LAN_PORT)), + ] +} + +/// Host side: answer discovery queries until `stop` is set. Binds UDP +/// `0.0.0.0:bind_port` and replies to each query with this host's id/name and +/// `direct_port`. Best-effort: returns on a fatal bind error (discovery just +/// won't work); transient recv errors are ignored. +pub fn respond_to_queries( + bind_port: u16, + host_id: String, + name: String, + direct_port: u16, + stop: Arc, +) -> std::io::Result<()> { + let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], bind_port)))?; + socket.set_read_timeout(Some(Duration::from_millis(300)))?; + let reply = serde_json::to_vec(&Announce { + t: HOST_T.to_string(), + id: host_id, + name, + port: direct_port, + }) + .unwrap_or_default(); + + let mut buf = [0u8; 2048]; + while !stop.load(Ordering::Relaxed) { + match socket.recv_from(&mut buf) { + Ok((len, from)) => { + if is_query(&buf[..len]) { + let _ = socket.send_to(&reply, from); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(_) => continue, + } + } + Ok(()) +} + +fn is_query(buf: &[u8]) -> bool { + serde_json::from_slice::(buf) + .map(|q| q.t == QUERY_T) + .unwrap_or(false) +} + +/// Viewer side: broadcast a discovery query to each `target` and collect host +/// replies for `timeout`. Production passes the LAN broadcast address +/// (`255.255.255.255:LAN_PORT`); tests pass a specific loopback address. +/// Deduplicates by host id (first reply wins). +pub fn scan(targets: &[SocketAddr], timeout: Duration) -> std::io::Result> { + let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0)))?; + socket.set_broadcast(true).ok(); // needed for 255.255.255.255; harmless otherwise + socket.set_read_timeout(Some(Duration::from_millis(200)))?; + + let query = serde_json::to_vec(&Query { t: QUERY_T }).unwrap_or_default(); + let send_all = || { + for t in targets { + let _ = socket.send_to(&query, t); + } + }; + send_all(); + + let mut hosts: Vec = Vec::new(); + let deadline = Instant::now() + timeout; + let mut next_resend = Instant::now() + Duration::from_millis(150); + let mut buf = [0u8; 2048]; + while Instant::now() < deadline { + // Re-broadcast periodically: a single UDP query can be lost, and the + // responder may not have bound yet when we started. + if Instant::now() >= next_resend { + send_all(); + next_resend = Instant::now() + Duration::from_millis(150); + } + match socket.recv_from(&mut buf) { + Ok((len, from)) => { + if let Ok(a) = serde_json::from_slice::(&buf[..len]) { + if a.t == HOST_T && !hosts.iter().any(|h| h.id == a.id) { + hosts.push(LanHost { + id: a.id, + name: a.name, + addr: SocketAddr::new(from.ip(), a.port), + }); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(_) => break, + } + } + Ok(hosts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn broadcast_targets_are_global_and_loopback() { + let t = broadcast_targets(); + assert!(t.iter().any(|a| a.ip().to_string() == "255.255.255.255" && a.port() == LAN_PORT)); + assert!(t.iter().any(|a| a.ip().to_string() == "127.0.0.1" && a.port() == LAN_PORT)); + } + + #[test] + fn scan_finds_a_responding_host() { + // A unique port so parallel tests don't collide. + let port = 29871u16; + let stop = Arc::new(AtomicBool::new(false)); + let s2 = stop.clone(); + let host = std::thread::spawn(move || { + let _ = respond_to_queries( + port, + "lan-host-123".to_string(), + "Test Host".to_string(), + 41000, + s2, + ); + }); + // Give the responder a moment to bind. + std::thread::sleep(Duration::from_millis(150)); + + // Query loopback directly (broadcast is unreliable on loopback in CI). + let targets = [SocketAddr::from(([127, 0, 0, 1], port))]; + let found = scan(&targets, Duration::from_millis(600)).unwrap(); + + stop.store(true, Ordering::Relaxed); + let _ = host.join(); + + let h = found + .iter() + .find(|h| h.id == "lan-host-123") + .expect("host not discovered on the LAN"); + assert_eq!(h.name, "Test Host"); + assert_eq!(h.addr.port(), 41000, "advertised direct-connect port"); + assert!(h.addr.ip().is_loopback(), "host IP learned from reply source"); + println!("LAN_DISCOVERY_OK found {} host(s): {:?}", found.len(), h); + } + + #[test] + fn non_query_packets_are_ignored() { + assert!(is_query(br#"{"t":"netdesk.lan.query"}"#)); + assert!(!is_query(br#"{"t":"something.else"}"#)); + assert!(!is_query(b"garbage")); + } +} diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs new file mode 100644 index 0000000..574bc51 --- /dev/null +++ b/native/core/src/lib.rs @@ -0,0 +1,34 @@ +//! NetDesk native core — the compiled Rust engine behind the cross-platform +//! client (Windows / macOS / Linux / iOS / Android). Developed by Akronlogic. +//! +//! This crate owns everything that must be fast and portable: signaling, +//! end-to-end crypto, the media transport, and (in later phases) H.264 decode +//! and input injection. The Flutter UI calls in over the C ABI in `ffi`. + +pub mod brand; +pub mod cidr; +pub mod crypto; +pub mod decode; +pub mod error; +pub mod host; +pub mod lan; +pub mod netqos; +pub mod p2p; +pub mod proto; +pub mod recorder; +pub mod rtc; +pub mod webm; +pub mod signaling; +pub mod transport; +pub mod update; + +pub use error::{NdError, Result}; +pub use signaling::{connect, ConnectConfig, Session, SessionEvent}; +pub use p2p::{upgrade_relay, P2pControl, P2pRelay, P2pRole}; +pub use transport::{Lane, Transport, TransportMsg, WebRtcChannel, WsRelay}; +pub use rtc::{advertise_max_message_size, answer_offer_web, build_api_web, create_web_channels, upgrade_offerer_web, WebChannels, WebDetached}; + +/// Core/build version, surfaced in the UI "about" box. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const BRAND: &str = "NetDesk"; +pub const VENDOR: &str = "Akronlogic"; diff --git a/native/core/src/netqos.rs b/native/core/src/netqos.rs new file mode 100644 index 0000000..7cb1faf --- /dev/null +++ b/native/core/src/netqos.rs @@ -0,0 +1,223 @@ +//! Network-quality estimators for the adaptive QoS controller — pure + +//! deterministic (feed samples, read values), so they unit-test without a live +//! link. RustDesk-style signals that replace the old "absolute RTT proxy": +//! +//! - [`RttCalc`] — a rolling-baseline RTT: the minimum RTT over the recent +//! window is the link's inherent latency, and the reported delay is how far +//! the latest sample sits ABOVE it. A stable high-latency link reads ~0 +//! delay; only latency SPIKES (queue buildup) read high. (RustDesk's +//! `RttCalculator`.) +//! - [`LossCalc`] — packet-loss ratio from ping/pong hit/miss over a window. +//! - [`Jitter`] — the viewer measures its frame-arrival jitter (the mean +//! absolute deviation of inter-arrival intervals). Smooth delivery ~0; +//! bursty/stalled delivery (the decode pipeline falling behind) grows. This +//! is a congestion signal RTT alone misses, and it is reported to the host in +//! `net.pong` so `Qos::on_sample` gets a real end-to-end delay. + +use std::collections::VecDeque; + +/// Rolling-baseline RTT delay. `add` returns the delay of the new sample above +/// the window minimum (the baseline). +#[derive(Debug)] +pub struct RttCalc { + win: VecDeque, + cap: usize, + last: u32, +} + +impl Default for RttCalc { + fn default() -> Self { + Self::new() + } +} + +impl RttCalc { + /// ~30 samples (≈30 s at one probe/second) of history for the baseline. + pub fn new() -> Self { + Self { + win: VecDeque::new(), + cap: 30, + last: 0, + } + } + + /// Record an RTT sample (ms); returns the delay above the rolling baseline. + pub fn add(&mut self, rtt_ms: u32) -> u32 { + self.win.push_back(rtt_ms); + while self.win.len() > self.cap { + self.win.pop_front(); + } + let base = *self.win.iter().min().unwrap_or(&rtt_ms); + self.last = rtt_ms.saturating_sub(base); + self.last + } + + /// The most recent delay-above-baseline. + pub fn delay(&self) -> u32 { + self.last + } +} + +/// Packet-loss ratio over a rolling window of ping/pong outcomes. +#[derive(Debug)] +pub struct LossCalc { + win: VecDeque, + cap: usize, +} + +impl Default for LossCalc { + fn default() -> Self { + Self::new() + } +} + +impl LossCalc { + pub fn new() -> Self { + Self { + win: VecDeque::new(), + cap: 20, + } + } + + /// Record one outcome (`true` = the pong was lost); returns the loss ratio. + pub fn record(&mut self, lost: bool) -> f32 { + self.win.push_back(lost); + while self.win.len() > self.cap { + self.win.pop_front(); + } + let n = self.win.len().max(1); + self.win.iter().filter(|&&l| l).count() as f32 / n as f32 + } +} + +/// Viewer-side frame-arrival jitter: the mean absolute deviation of recent +/// inter-arrival intervals (ms). fps-independent (it measures spread, not the +/// absolute cadence), so lowering the frame rate does not read as congestion — +/// only bursty / stalled delivery does. +#[derive(Debug)] +pub struct Jitter { + intervals: VecDeque, + cap: usize, + last_ms: Option, +} + +impl Default for Jitter { + fn default() -> Self { + Self::new() + } +} + +impl Jitter { + pub fn new() -> Self { + Self { + intervals: VecDeque::new(), + cap: 30, + last_ms: None, + } + } + + /// Record a frame arrival at monotonic `now_ms`; returns the current jitter. + pub fn frame(&mut self, now_ms: u64) -> u32 { + if let Some(prev) = self.last_ms { + // Clamp absurd gaps (a static screen sends nothing for a while) so a + // long idle doesn't poison the jitter once frames resume. + let dt = now_ms.saturating_sub(prev).min(2_000) as u32; + self.intervals.push_back(dt); + while self.intervals.len() > self.cap { + self.intervals.pop_front(); + } + } + self.last_ms = Some(now_ms); + self.jitter() + } + + /// Current jitter (mean absolute deviation of intervals, ms). + pub fn jitter(&self) -> u32 { + let n = self.intervals.len(); + if n < 2 { + return 0; + } + let mean = self.intervals.iter().sum::() as f64 / n as f64; + let mad = self + .intervals + .iter() + .map(|&x| (x as f64 - mean).abs()) + .sum::() + / n as f64; + mad.round() as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rtt_baseline_reports_delay_above_min() { + let mut r = RttCalc::new(); + assert_eq!(r.add(200), 0, "first sample is its own baseline"); + assert_eq!(r.add(200), 0); + assert_eq!(r.add(205), 5, "5 ms above the 200 ms floor"); + assert_eq!(r.add(350), 150, "a spike reads 150 ms above baseline"); + assert_eq!(r.add(200), 0, "back at the floor -> no delay"); + assert_eq!(r.delay(), 0); + } + + #[test] + fn rtt_baseline_rises_when_the_floor_rises() { + let mut r = RttCalc::new(); + // Fill the window so old low samples roll out. + for _ in 0..30 { + r.add(100); + } + // A sustained worse link: once the 100s roll out, 300 becomes the floor. + for _ in 0..30 { + r.add(300); + } + assert_eq!(r.add(300), 0, "a sustained new floor reads ~0 delay again"); + assert_eq!(r.add(450), 150, "spike above the new 300 ms floor"); + } + + #[test] + fn loss_ratio_tracks_misses() { + let mut l = LossCalc::new(); + for _ in 0..10 { + l.record(false); + } + assert_eq!(l.record(false), 0.0, "all delivered -> 0 loss"); + // 2 misses in a 13-sample window. + l.record(true); + let loss = l.record(true); + assert!((loss - 2.0 / 13.0).abs() < 1e-6, "2/13 lost, got {loss}"); + } + + #[test] + fn jitter_is_low_for_steady_delivery_and_high_for_bursts() { + let mut steady = Jitter::new(); + let mut t = 0u64; + for _ in 0..20 { + t += 33; // a clean ~30 fps cadence + steady.frame(t); + } + assert!(steady.jitter() <= 1, "steady cadence -> ~0 jitter, got {}", steady.jitter()); + + let mut bursty = Jitter::new(); + let mut t = 0u64; + for i in 0..20 { + t += if i % 2 == 0 { 5 } else { 70 }; // frames bunch then gap + bursty.frame(t); + } + assert!(bursty.jitter() > 20, "bursty delivery -> high jitter, got {}", bursty.jitter()); + } + + #[test] + fn jitter_ignores_a_long_idle_gap() { + let mut j = Jitter::new(); + j.frame(0); + j.frame(33); + j.frame(66); + // Static screen: a 10 s gap is clamped, not counted as 10 s of jitter. + j.frame(10_066); + assert!(j.jitter() <= 2_000, "idle gap clamped"); + } +} diff --git a/native/core/src/p2p.rs b/native/core/src/p2p.rs new file mode 100644 index 0000000..fa9b5b7 --- /dev/null +++ b/native/core/src/p2p.rs @@ -0,0 +1,831 @@ +//! Relay-first P2P multiplexer with seamless fallback. +//! +//! [`upgrade_relay`] wraps any [`Transport`] (the WSS relay) and returns another +//! `Transport` that the session loop ([`crate::signaling::connect_with`] / +//! [`crate::host::run_host_with`]) uses completely unchanged. All the P2P logic +//! runs in a background engine task that owns both the relay and, once +//! negotiated, the WebRTC DataChannel: +//! +//! * The relay is LIVE from the first frame and is NEVER torn down while P2P is +//! up (warm standby), so the first frame never waits on ICE and a P2P drop is +//! invisible to the session. +//! * The engine SNIFFS the session `sid` out of the handshake messages flowing +//! through it (so neither loop has to hand it over), then drives the +//! relay-brokered `rtc.*` handshake ([`crate::rtc::upgrade_offerer`] / +//! [`crate::rtc::answer_offer`]). +//! * UP edge: when the DataChannel opens AND the nominated ICE pair is DIRECT +//! (non-TURN — [`crate::rtc::selected_pair_is_direct`]), media + the +//! latency-critical input/keyframe control are routed over the channel; a +//! TURN-relayed or unknown pair keeps media on the relay. +//! * DOWN edge: if the peer connection fails/closes, media reroutes to the +//! still-open relay with no `Ended` and no frame loss. +//! +//! `rtc.*` and `session.*` ALWAYS ride the relay; only `CHAN_VIDEO` / file +//! binary and `i.mouse` / `i.key` / `keyframe` move to P2P when it is direct. + +use std::sync::Arc; + +use bytes::Bytes; + +use serde_json::Value; +use tokio::sync::mpsc; +use webrtc::api::API; +use webrtc::data_channel::RTCDataChannel; +use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; +use webrtc::peer_connection::RTCPeerConnection; +use webrtc::stats::StatsReportType; + +use crate::error::{NdError, Result}; +use crate::rtc; +use crate::rtc::{RtcIceServerJson, WebDetached}; +use crate::transport::{install_reassembler, send_fragmented, Lane, Transport, TransportMsg}; + +/// A negotiated P2P connection: the native single reliable channel (16 KiB +/// app-fragmented) or the web 3-channel topology (media/ctl/move, detached, +/// whole frames) used to interop with the browser / Python host. +enum P2pConn { + Single(Arc), + Web(Arc), +} + +/// High-frequency, latency-critical control that rides the web `ctl` channel +/// (the web viewer's send override routes exactly these; other control stays on +/// the relay). Cheap prefix checks, no JSON parse on the hot path. +fn is_fast_path_text(t: &str) -> bool { + t.contains("\"t\":\"i.mouse\"") + || t.contains("\"t\":\"i.key\"") + || t.contains("\"t\":\"keyframe\"") +} + +/// Whether the direct-P2P fast path is enabled for this process. DEFAULT-ON: +/// the native-peer capability gate keeps P2P to native<->native pairs and every +/// other pairing stays on the relay, so default-on cannot break the existing +/// web/Python peers. Opt OUT with `NETDESK_P2P=0` (also `false`/`off`/`no`). +pub fn p2p_enabled() -> bool { + !matches!( + std::env::var("NETDESK_P2P").as_deref(), + Ok("0") | Ok("false") | Ok("off") | Ok("no") | Ok("OFF") | Ok("FALSE") | Ok("NO") + ) +} + +/// Performance-based path health for the active DIRECT P2P pair. +/// +/// The relay-first state machine already prefers a direct pair and falls back on +/// hard failure. This adds the other half of the hybrid: abandon a direct pair +/// that is measurably WORSE than a relay hop would be — not just one that +/// dropped. It is deliberately conservative (only a *pathologically* high, +/// *sustained* RTT trips it; a VPS relay hop is almost always below that), so it +/// never fights the normal "direct is faster" case. Feed it the nominated +/// candidate pair's RTT (ms) every ~[`HEALTH_INTERVAL`]; `on_rtt` returns true +/// once the path has been bad long enough to downgrade. +#[derive(Default)] +pub struct P2pHealth { + bad_streak: u32, +} + +/// How often the engine samples the active pair's RTT. +pub const HEALTH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); +/// A direct-pair RTT at/above this (ms) is "bad" — far worse than a relay hop. +pub const P2P_RTT_BAD_MS: u32 = 500; +/// Consecutive bad samples before downgrading (~10 s at [`HEALTH_INTERVAL`]). +pub const P2P_RTT_CONFIRM: u32 = 5; + +impl P2pHealth { + /// Record one RTT sample (ms). `0` means "unknown" (not yet measured) and is + /// ignored. Returns true when a downgrade to the relay is warranted. + pub fn on_rtt(&mut self, rtt_ms: u32) -> bool { + if rtt_ms == 0 { + return false; + } + if rtt_ms >= P2P_RTT_BAD_MS { + self.bad_streak += 1; + } else { + self.bad_streak = 0; + } + self.bad_streak >= P2P_RTT_CONFIRM + } +} + +/// The nominated ICE candidate-pair RTT in ms, or 0 if unknown/not yet measured. +async fn p2p_pair_rtt_ms(pc: &Arc) -> u32 { + let report = pc.get_stats().await; + for v in report.reports.values() { + if let StatsReportType::CandidatePair(cp) = v { + if cp.nominated && cp.current_round_trip_time > 0.0 { + return (cp.current_round_trip_time * 1000.0).round() as u32; + } + } + } + 0 +} + +/// Which side of the upgrade handshake this peer drives. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum P2pRole { + /// Viewer — sends `rtc.config.req` + the offer. + Offerer, + /// Host — answers an inbound `rtc.offer`. + Answerer, +} + +/// SCTP send-queue depth (bytes) past which a fresh MEDIA (video) frame is +/// DROPPED rather than queued — fresh beats late. Only the drop-tolerant Media +/// lane drops; Ctl (file bytes / control) and Move are always sent reliably. +/// Matches the web host's 256 KiB `_send_bin` threshold. +const P2P_MEDIA_CAP: usize = 262144; + +/// A handle to force the session back onto the relay (the plan's "force-relay +/// fast path", and the test hook for the DOWN edge). Cloneable; outlives the +/// transport move into the session loop. +#[derive(Clone)] +pub struct P2pControl { + down: mpsc::UnboundedSender<()>, +} + +impl P2pControl { + /// Drop any active P2P path and route all media back over the warm relay. + pub fn force_relay(&self) { + let _ = self.down.send(()); + } +} + +/// The app-facing transport the session loop uses unchanged — just the mailbox +/// into the engine task. +pub struct P2pRelay { + out: mpsc::UnboundedSender<(TransportMsg, Lane)>, + inn: mpsc::UnboundedReceiver>, + down: mpsc::UnboundedSender<()>, + _engine: tokio::task::JoinHandle<()>, +} + +impl P2pRelay { + /// A control handle to force media back onto the relay. + pub fn control(&self) -> P2pControl { + P2pControl { + down: self.down.clone(), + } + } +} + +#[async_trait::async_trait] +impl Transport for P2pRelay { + async fn send_binary(&mut self, data: Vec) -> Result<()> { + // No lane hint -> treat as reliable Ctl (never dropped). Callers that + // know it is video use send_binary_lane(.., Media). + self.out + .send((TransportMsg::Binary(data), Lane::Ctl)) + .map_err(|_| NdError::Connect("p2p engine stopped".into())) + } + async fn send_binary_lane(&mut self, data: Vec, lane: Lane) -> Result<()> { + self.out + .send((TransportMsg::Binary(data), lane)) + .map_err(|_| NdError::Connect("p2p engine stopped".into())) + } + async fn send_text(&mut self, text: String) -> Result<()> { + self.out + .send((TransportMsg::Text(text), Lane::Ctl)) + .map_err(|_| NdError::Connect("p2p engine stopped".into())) + } + async fn recv(&mut self) -> Option> { + self.inn.recv().await + } +} + +/// Wrap `relay` with transparent P2P upgrade + fallback. `api` builds the peer +/// connection(s). The returned transport is a drop-in for the relay. +pub fn upgrade_relay(relay: Box, role: P2pRole, api: Arc) -> P2pRelay { + let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>(); + let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::>(); + let (down_tx, down_rx) = mpsc::unbounded_channel::<()>(); + let engine = tokio::spawn(engine( + relay, + role, + api, + app_out_rx, + app_in_tx, + down_tx.clone(), + down_rx, + )); + P2pRelay { + out: app_out_tx, + inn: app_in_rx, + down: down_tx, + _engine: engine, + } +} + +/// Pull `sid` out of a control-text message, if present and non-empty. +fn sniff_sid_text(t: &str) -> Option { + serde_json::from_str::(t) + .ok() + .and_then(|v| v.get("sid").and_then(|x| x.as_str()).map(|s| s.to_string())) + .filter(|s| !s.is_empty()) +} + +/// Whether this control text rides the P2P channel when it is up. Everything +/// does EXCEPT `session.*` (session lifecycle) and `rtc.*` (upgrade signaling), +/// which stay on the relay. Keeping ALL app traffic — input, keyframe requests +/// AND file.* control — on the one ordered channel alongside the binary data is +/// essential: splitting data (P2P) from its `file.done` (relay) lets the text +/// race ahead of the slower-fragmented data and truncate the download. Cheap +/// substring checks avoid a JSON parse on the hot path (serde emits `t` first). +fn route_text_p2p(t: &str) -> bool { + !(t.contains("\"t\":\"session.") || t.contains("\"t\":\"rtc.")) +} + +type DoneTx = mpsc::UnboundedSender<(Arc, P2pConn)>; + +fn spawn_offerer( + api: Arc, + sid: String, + rtc_out: mpsc::UnboundedSender, + mut rtc_in: mpsc::UnboundedReceiver, + done: DoneTx, +) { + tokio::spawn(async move { + let send = { + let tx = rtc_out.clone(); + move |s: String| { + let _ = tx.send(s); + } + }; + match rtc::upgrade_offerer(&api, &sid, None, "netdesk", &send, &mut rtc_in).await { + Ok((pc, dc)) => { + let _ = done.send((pc, P2pConn::Single(dc))); + } + Err(e) => tracing::debug!("P2P offerer upgrade abandoned: {e}"), + } + }); +} + +/// WEB-topology offerer (interop with the browser / Python host). Builds its own +/// detach-enabled API, negotiates media/ctl/move, and reports a `P2pConn::Web`. +fn spawn_offerer_web( + sid: String, + rtc_out: mpsc::UnboundedSender, + mut rtc_in: mpsc::UnboundedReceiver, + done: DoneTx, +) { + tokio::spawn(async move { + let api = match rtc::build_api_web() { + Ok(a) => a, + Err(e) => { + tracing::debug!("build_api_web: {e}"); + return; + } + }; + let send = { + let tx = rtc_out.clone(); + move |s: String| { + let _ = tx.send(s); + } + }; + match rtc::upgrade_offerer_web(&api, &sid, None, &send, &mut rtc_in).await { + Ok((pc, web)) => { + let _ = done.send((pc, P2pConn::Web(Arc::new(web)))); + } + Err(e) => tracing::debug!("P2P web offerer abandoned: {e}"), + } + }); +} + +fn spawn_answerer( + api: Arc, + sid: String, + offer_sdp: String, + ice: Vec, + rtc_out: mpsc::UnboundedSender, + done: DoneTx, +) { + tokio::spawn(async move { + let send = { + let tx = rtc_out.clone(); + move |s: String| { + let _ = tx.send(s); + } + }; + match rtc::answer_offer(&api, &sid, &offer_sdp, ice, &send).await { + Ok((pc, dc)) => { + let _ = done.send((pc, P2pConn::Single(dc))); + } + Err(e) => tracing::debug!("P2P answerer abandoned: {e}"), + } + }); +} + +/// WEB-topology answerer (a browser / web viewer connects to this native host). +/// Builds its own detach-enabled API and reports a `P2pConn::Web`. +fn spawn_answerer_web( + sid: String, + offer_sdp: String, + ice: Vec, + rtc_out: mpsc::UnboundedSender, + done: DoneTx, +) { + tokio::spawn(async move { + let api = match rtc::build_api_web() { + Ok(a) => a, + Err(e) => { + tracing::debug!("build_api_web: {e}"); + return; + } + }; + let send = { + let tx = rtc_out.clone(); + move |s: String| { + let _ = tx.send(s); + } + }; + match rtc::answer_offer_web(&api, &sid, &offer_sdp, ice, &send).await { + Ok((pc, web)) => { + let _ = done.send((pc, P2pConn::Web(Arc::new(web)))); + } + Err(e) => tracing::debug!("P2P web answerer abandoned: {e}"), + } + }); +} + +/// Start one offerer upgrade attempt (the initial one, or a re-upgrade after a +/// drop). Creates a fresh rtc_in channel, spawns the Single or Web offerer, and +/// returns the sender the relay loop forwards inbound rtc.* to. +fn start_offerer_attempt( + web: bool, + api: Arc, + sid: String, + rtc_out: mpsc::UnboundedSender, + done: DoneTx, +) -> mpsc::UnboundedSender { + let (tx, rx) = mpsc::unbounded_channel::(); + if web { + spawn_offerer_web(sid, rtc_out, rx, done); + } else { + spawn_offerer(api, sid, rtc_out, rx, done); + } + tx +} + +#[allow(clippy::too_many_arguments)] +async fn engine( + mut relay: Box, + role: P2pRole, + api: Arc, + mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>, + app_in_tx: mpsc::UnboundedSender>, + down_tx: mpsc::UnboundedSender<()>, + mut down_rx: mpsc::UnboundedReceiver<()>, +) { + let mut sid: Option = None; + let mut media_on_p2p = false; + let mut p2p_send: Option = None; + let mut _pc: Option> = None; + let mut next_frame_id: u32 = 0; + let mut offerer_started = false; + let mut answerer_started = false; + // Performance health of the active direct pair, and a latch: once a direct + // pair is abandoned for being too slow, stay on the relay for the rest of + // the session (don't re-upgrade into the same bad path and flap). + let mut health = P2pHealth::default(); + let mut health_down = false; + + // Inbound P2P frames (reassembled) land here; held open for the engine's + // lifetime so re-use across the session is clean. Fed by install_reassembler + // once a channel is established. + let (p2p_in_tx, mut p2p_in_rx) = mpsc::unbounded_channel::(); + // rtc.* the upgrade task wants put on the relay. + let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::(); + // Inbound rtc.* forwarded to the CURRENT offerer upgrade attempt. Re-created + // per attempt so a re-upgrade after a drop gets a fresh channel. + let mut rtc_in_tx: Option> = None; + // Negotiated (pc, connection). + let (done_tx, mut done_rx) = + mpsc::unbounded_channel::<(Arc, P2pConn)>(); + // Re-upgrade (hysteresis): after a P2P drop, retry the upgrade once after a + // backoff so a transient drop recovers to P2P instead of staying on the + // relay. Default 15s; shorter in tests via NETDESK_P2P_REUP_MS. + let reup_ms: u64 = std::env::var("NETDESK_P2P_REUP_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(15000); + let (reup_tx, mut reup_rx) = mpsc::unbounded_channel::<()>(); + // Offerer topology once decided: Some(false)=native single, Some(true)=web, + // None=relay-only. Remembered so a re-upgrade uses the same topology. + let mut offerer_mode: Option = None; + // Periodic RTT health probe of the active direct pair. + let mut health_tick = tokio::time::interval(HEALTH_INTERVAL); + health_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + enum Ev { + AppOut(Option<(TransportMsg, Lane)>), + RelayIn(Option>), + P2pIn(Option), + RtcOut(Option), + Done(Option<(Arc, P2pConn)>), + ReUpgrade, + Down, + Health, + } + + loop { + let ev = tokio::select! { + m = app_out_rx.recv() => Ev::AppOut(m), + m = relay.recv() => Ev::RelayIn(m), + m = p2p_in_rx.recv() => Ev::P2pIn(m), + s = rtc_out_rx.recv() => Ev::RtcOut(s), + d = done_rx.recv() => Ev::Done(d), + _ = reup_rx.recv() => Ev::ReUpgrade, + _ = down_rx.recv() => Ev::Down, + _ = health_tick.tick() => Ev::Health, + }; + + match ev { + // ---- outbound from the session loop ---- + Ev::AppOut(None) => break, // the session dropped the transport + Ev::AppOut(Some((msg, lane))) => { + if sid.is_none() { + if let TransportMsg::Text(t) = &msg { + if let Some(s) = sniff_sid_text(t) { + sid = Some(s); + } + } + } + match msg { + TransportMsg::Binary(b) => match (media_on_p2p, p2p_send.as_ref()) { + // native single channel: 16 KiB app-fragments; Media is + // drop-tolerant past the queue cap, Ctl/Move reliable. + (true, Some(P2pConn::Single(dc))) => { + let dc = dc.clone(); + if lane == Lane::Media && dc.buffered_amount().await > P2P_MEDIA_CAP { + // drop (fresh beats late) + } else { + let fid = next_frame_id; + next_frame_id = next_frame_id.wrapping_add(1); + if send_fragmented(&dc, fid, &b).await.is_err() { + let _ = relay.send_binary_lane(b, lane).await; + let _ = down_tx.send(()); + } + } + } + // web topology: video (Media) rides the media channel whole; + // everything else (e.g. file upload) stays on the relay, like + // the web viewer. Media is drop-tolerant past the cap. + (true, Some(P2pConn::Web(web))) => { + if lane == Lane::Media { + if web.media.buffered_amount() <= P2P_MEDIA_CAP + && web + .media + .write_data_channel(&Bytes::from(b.clone()), false) + .await + .is_err() + { + let _ = relay.send_binary_lane(b, lane).await; + let _ = down_tx.send(()); + } + } else { + let _ = relay.send_binary_lane(b, lane).await; + } + } + _ => { + let _ = relay.send_binary_lane(b, lane).await; + } + }, + TransportMsg::Text(t) => match (media_on_p2p, p2p_send.as_ref()) { + (true, Some(P2pConn::Single(dc))) if route_text_p2p(&t) => { + let dc = dc.clone(); + if dc.send_text(t.clone()).await.is_err() { + let _ = relay.send_text(t).await; + let _ = down_tx.send(()); + } + } + // web: only input / keyframe rides ctl; other control stays + // on the relay (the web viewer's send policy). + (true, Some(P2pConn::Web(web))) if is_fast_path_text(&t) => { + if web + .ctl + .write_data_channel(&Bytes::from(t.clone().into_bytes()), true) + .await + .is_err() + { + let _ = relay.send_text(t).await; + let _ = down_tx.send(()); + } + } + _ => { + let _ = relay.send_text(t).await; + } + }, + } + } + + // ---- inbound from the relay ---- + Ev::RelayIn(None) => break, // relay closed -> session ends (app recv None) + Ev::RelayIn(Some(Err(e))) => { + let _ = app_in_tx.send(Err(e)); + break; + } + Ev::RelayIn(Some(Ok(m))) => { + let mut forward = true; + if let TransportMsg::Text(t) = &m { + if let Ok(v) = serde_json::from_str::(t) { + let tt = v.get("t").and_then(|x| x.as_str()).unwrap_or("").to_string(); + if sid.is_none() { + if let Some(s) = v.get("sid").and_then(|x| x.as_str()) { + if !s.is_empty() { + sid = Some(s.to_string()); + } + } + } + if tt.starts_with("rtc.") { + forward = false; // rtc.* is ours, never the app's + match role { + P2pRole::Offerer => { + if let Some(tx) = &rtc_in_tx { + let _ = tx.send(v); + } + } + P2pRole::Answerer => { + if tt == "rtc.offer" && !answerer_started { + answerer_started = true; + let sid_s = sid.clone().unwrap_or_default(); + let is_native = + v.get("native").and_then(|x| x.as_bool()) == Some(true); + let sdp = v + .get("sdp") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let ice: Vec = v + .get("ice") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + if is_native { + // native single-channel offerer. + spawn_answerer( + api.clone(), + sid_s, + sdp, + ice, + rtc_out_tx.clone(), + done_tx.clone(), + ); + } else if std::env::var("NETDESK_P2P_WEB").is_ok() { + // web/3-channel offerer (a browser) — answer in + // web topology when opted in. + spawn_answerer_web( + sid_s, + sdp, + ice, + rtc_out_tx.clone(), + done_tx.clone(), + ); + } else { + // not opted into web interop: decline so the + // pairing stays on the relay. + let _ = rtc_out_tx.send( + format!(r#"{{"t":"rtc.unavailable","sid":"{sid_s}"}}"#), + ); + } + } + } + } + } else if role == P2pRole::Offerer + && !offerer_started + && tt == "session.accept" + { + // A NATIVE host (info.p2p == "native") uses the native + // single-channel upgrade. A non-native host (browser / + // Python) uses the WEB 3-channel topology, but only when + // NETDESK_P2P_WEB is opted in — so the default stays the + // safe native-only behavior until the live cross-matrix + // is confirmed. + let native_host = v + .get("info") + .and_then(|i| i.get("p2p")) + .and_then(|p| p.as_str()) + == Some("native"); + let web_ok = std::env::var("NETDESK_P2P_WEB").is_ok(); + if native_host { + offerer_started = true; + offerer_mode = Some(false); + rtc_in_tx = Some(start_offerer_attempt( + false, + api.clone(), + sid.clone().unwrap_or_default(), + rtc_out_tx.clone(), + done_tx.clone(), + )); + } else if web_ok { + offerer_started = true; + offerer_mode = Some(true); + rtc_in_tx = Some(start_offerer_attempt( + true, + api.clone(), + sid.clone().unwrap_or_default(), + rtc_out_tx.clone(), + done_tx.clone(), + )); + } + } + } + } + if forward { + let _ = app_in_tx.send(Ok(m)); + } + } + + // ---- inbound from the P2P channel ---- + Ev::P2pIn(Some(m)) => { + let _ = app_in_tx.send(Ok(m)); + } + Ev::P2pIn(None) => {} // we hold a sender; this does not fire in practice + + // ---- rtc.* the upgrade task wants on the relay ---- + Ev::RtcOut(Some(s)) => { + let _ = relay.send_text(s).await; + } + Ev::RtcOut(None) => {} + + // ---- UP edge: a channel was negotiated ---- + Ev::Done(Some((new_pc, conn))) => { + let direct = rtc::selected_pair_is_direct(&new_pc).await; + match &conn { + P2pConn::Single(dc) => { + // native: 16 KiB app-fragments reassembled by install_reassembler. + install_reassembler(dc, p2p_in_tx.clone()); + } + P2pConn::Web(web) => { + // web: whole frames — spawn a detached read-loop per inbound + // channel (media = video, ctl = control). Each read is one + // complete message (is_string -> Text, else Binary). + for raw in [web.media.clone(), web.ctl.clone()] { + let tx = p2p_in_tx.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match raw.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, is_string)) => { + let msg = if is_string { + match String::from_utf8(buf[..n].to_vec()) { + Ok(s) => TransportMsg::Text(s), + Err(_) => continue, + } + } else { + TransportMsg::Binary(buf[..n].to_vec()) + }; + let _ = tx.send(msg); + } + Err(_) => break, + } + } + }); + } + } + } + let dtx = down_tx.clone(); + new_pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + let dtx = dtx.clone(); + Box::pin(async move { + if matches!( + s, + RTCPeerConnectionState::Failed + | RTCPeerConnectionState::Disconnected + | RTCPeerConnectionState::Closed + ) { + let _ = dtx.send(()); + } + }) + })); + p2p_send = Some(conn); + _pc = Some(new_pc); + // Only move media off the relay for a DIRECT pair; a TURN-relayed + // hop is no better than our own relay. + media_on_p2p = direct; + tracing::info!(direct, "P2P DataChannel open"); + if role == P2pRole::Offerer { + let _ = app_in_tx.send(Ok(TransportMsg::Text(format!( + r#"{{"t":"p2p.state","active":{media_on_p2p},"direct":{direct}}}"# + )))); + } + } + Ev::Done(None) => {} + + // ---- periodic health probe: abandon a pathologically slow direct + // pair for the relay (the "pick the faster path" half of hybrid). ---- + Ev::Health => { + if media_on_p2p && !health_down { + if let Some(pc) = &_pc { + let rtt = p2p_pair_rtt_ms(pc).await; + if health.on_rtt(rtt) { + tracing::warn!( + rtt_ms = rtt, + "P2P direct pair too slow — downgrading to the relay for the session" + ); + health_down = true; + let _ = down_tx.send(()); + } + } + } + } + + // ---- DOWN edge: pc failed/closed -> fall back to the warm relay ---- + Ev::Down => { + let was_up = media_on_p2p || p2p_send.is_some(); + if was_up { + tracing::info!("P2P dropped — continuing over the relay"); + } + media_on_p2p = false; + p2p_send = None; + _pc = None; + health = P2pHealth::default(); // reset the RTT streak for the next pair + if was_up && role == P2pRole::Offerer { + let _ = app_in_tx.send(Ok(TransportMsg::Text( + r#"{"t":"p2p.state","active":false,"direct":false}"#.to_string(), + ))); + } + // Re-upgrade hysteresis: allow a fresh attempt. The answerer + // just re-answers the viewer's next rtc.offer; the offerer + // schedules one retry after a backoff. + if was_up { + answerer_started = false; + offerer_started = false; + rtc_in_tx = None; + // Suppress re-upgrade after a health downgrade: the direct + // path proved too slow, so stay on the relay (no flapping). + if role == P2pRole::Offerer && offerer_mode.is_some() && !health_down { + let reup_tx = reup_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(reup_ms)).await; + let _ = reup_tx.send(()); + }); + } + } + } + + // ---- re-upgrade timer fired: retry the offerer upgrade once ---- + Ev::ReUpgrade => { + if role == P2pRole::Offerer + && !offerer_started + && p2p_send.is_none() + && sid.is_some() + { + if let Some(web) = offerer_mode { + offerer_started = true; + rtc_in_tx = Some(start_offerer_attempt( + web, + api.clone(), + sid.clone().unwrap_or_default(), + rtc_out_tx.clone(), + done_tx.clone(), + )); + tracing::info!("P2P re-upgrade attempt"); + } + } + } + } + } +} + +#[cfg(test)] +mod health_tests { + use super::{P2pHealth, P2P_RTT_CONFIRM}; + + #[test] + fn good_rtt_never_downgrades() { + let mut h = P2pHealth::default(); + for _ in 0..50 { + assert!(!h.on_rtt(30), "a healthy direct pair must stay on P2P"); + } + } + + #[test] + fn unknown_rtt_is_ignored() { + let mut h = P2pHealth::default(); + for _ in 0..50 { + assert!(!h.on_rtt(0), "0 = not-yet-measured, never a downgrade"); + } + } + + #[test] + fn sustained_bad_rtt_downgrades_after_confirm() { + let mut h = P2pHealth::default(); + // Must take the full confirm streak — not a single spike. + for i in 1..P2P_RTT_CONFIRM { + assert!(!h.on_rtt(800), "should not trip on sample {i} (< confirm)"); + } + assert!(h.on_rtt(800), "should downgrade once the streak reaches confirm"); + } + + #[test] + fn a_good_sample_resets_the_streak() { + let mut h = P2pHealth::default(); + for _ in 0..(P2P_RTT_CONFIRM - 1) { + assert!(!h.on_rtt(900)); + } + assert!(!h.on_rtt(40), "a healthy sample clears the bad streak"); + // Now it must take another full streak to trip. + for i in 1..P2P_RTT_CONFIRM { + assert!(!h.on_rtt(900), "streak restarted; sample {i} must not trip"); + } + assert!(h.on_rtt(900)); + } +} diff --git a/native/core/src/proto.rs b/native/core/src/proto.rs new file mode 100644 index 0000000..83268ca --- /dev/null +++ b/native/core/src/proto.rs @@ -0,0 +1,184 @@ +//! Wire protocol for the NetDesk signaling server (`/ws`). Mirrors the messages +//! the web viewer exchanges, so the native client is a drop-in peer: +//! viewer -> session.begin{id} +//! host -> session.challenge{sid,salt,nonce,iters,e2e,pub,totp} +//! viewer -> session.auth{proof,pub,totp} +//! host -> session.accept{sid,info{host_name,e2e,w,h,...}} (then binary frames) +//! host -> session.deny{reason} +//! Binary frames (after optional AES-GCM): [0]=channel, [1]=flags, [2..]=codec +//! bitstream. For CHAN_VIDEO, `flags` packs bit0=keyframe and bits1-3=codec id +//! (see [`VideoCodec`] / [`video_flags`]): H.264 Annex-B (0) or VP9 (1). + +use serde::{Deserialize, Serialize}; + +pub const CHAN_VIDEO: u8 = 0x01; +pub const CHAN_AUDIO: u8 = 0xa1; +/// Viewer->host microphone for a two-way voice call: `[CHAN_VOICE][opus]`, +/// E2E-sealed like the other channels. The host decodes it and plays it on its +/// default render endpoint. +pub const CHAN_VOICE: u8 = 0xa2; +pub const CHAN_FILE: u8 = 0xf1; +pub const CHAN_FILE_END: u8 = 0xf2; + +/// Video codec carried in the high bits of a CHAN_VIDEO frame's flags byte. +/// +/// The host tags every frame; the viewer builds the matching decoder and can +/// switch mid-session at any keyframe (the adaptive path moves hardware H.264 +/// <-> software VP9 by link quality — VP9 is far better per-bitrate on slow +/// links, H.264 is near-free on fast ones). Values are forward-compatible: +/// bits1-3 leave room for VP8/AV1 later. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum VideoCodec { + H264 = 0, + Vp9 = 1, +} + +impl VideoCodec { + /// Decode the codec id from a CHAN_VIDEO flags byte (unknown -> H264). + #[inline] + pub fn from_bits(flags: u8) -> Self { + match (flags >> 1) & 0x7 { + 1 => VideoCodec::Vp9, + _ => VideoCodec::H264, + } + } + #[inline] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Pack a CHAN_VIDEO flags byte: bit0 = keyframe, bits1-3 = codec id. +#[inline] +pub fn video_flags(keyframe: bool, codec: VideoCodec) -> u8 { + (keyframe as u8) | ((codec as u8) << 1) +} + +/// Outbound: begin a session against a host id. +#[derive(Serialize)] +pub struct SessionBegin<'a> { + pub t: &'a str, // "session.begin" + pub id: &'a str, + pub direct: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub takeover: Option, + /// Viewer can decode VP9 — the host only sends VP9 frames when this is set + /// (a non-VP9 viewer, e.g. a non-Windows native build or the web client, + /// stays on H.264). Omitted when false so older hosts ignore it. + #[serde(skip_serializing_if = "Option::is_none")] + pub vp9: Option, +} + +/// Inbound: the host's auth challenge (relayed through the server). +#[derive(Deserialize, Debug, Clone)] +pub struct Challenge { + pub sid: String, + pub salt: String, // base64 + pub nonce: String, // opaque string, concatenated before hashing + pub iters: u32, + #[serde(default)] + pub e2e: bool, + #[serde(default, rename = "pub")] + pub pub_: Option, // host ECDH P-256 public key, base64 raw (65 bytes) + #[serde(default)] + pub totp: bool, +} + +/// Outbound: proof of the password + our ECDH public key. +#[derive(Serialize)] +pub struct SessionAuth<'a> { + pub t: &'a str, // "session.auth" + pub proof: String, + #[serde(skip_serializing_if = "Option::is_none", rename = "pub")] + pub pub_: Option, + /// Channel-bound proof (MITM hardening): `hex(SHA256(nonce||verifier||host_pub))`. + /// Sent alongside `proof` whenever E2E is active, so the host can detect a + /// relay that swapped its ephemeral key. Omitted on non-E2E sessions; older + /// hosts ignore the extra field. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_bind: Option, + pub totp: String, + pub sid: String, + /// Viewer can decode VP9. Carried in `session.auth` (not `session.begin`, + /// whose custom fields the signaling server strips — it does relay auth, + /// since the host reads our `pub` here for E2E). The host only sends VP9 + /// when this is set. Omitted when false so older hosts ignore it. + #[serde(skip_serializing_if = "Option::is_none")] + pub vp9: Option, +} + +/// Inbound: accepted. `info` carries host name, dims, e2e flag, etc. +#[derive(Deserialize, Debug, Clone, Default)] +pub struct AcceptInfo { + #[serde(default)] + pub host_name: Option, + #[serde(default)] + pub e2e: bool, + #[serde(default)] + pub w: Option, + #[serde(default)] + pub h: Option, +} + +/// A keyboard/pointer input event sent viewer -> host. +#[derive(Serialize)] +pub struct MouseInput<'a> { + pub t: &'a str, // "i.mouse" + pub x: i32, + pub y: i32, + pub b: u32, + pub w: f32, + pub hw: f32, + pub sid: String, +} + +#[derive(Serialize)] +pub struct KeyInput<'a> { + pub t: &'a str, // "i.key" + // The host (netdesk_host.py) and the web viewer both use the short wire + // names `c` (DOM code) and `d` (down). The native viewer MUST match them or + // the host reads an empty code with down=false and injects nothing — i.e. + // the keyboard is silently dead ("connects but it's just a view"). + #[serde(rename = "c")] + pub code: String, + #[serde(rename = "d")] + pub down: bool, + pub sid: String, +} + +/// Split an H.264 Annex-B buffer into NAL units (strips 00 00 01 / 00 00 00 01 start codes). +pub fn split_nals(buf: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut i = 0usize; + let n = buf.len(); + let mut start: Option = None; + while i + 3 <= n { + let sc3 = buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1; + let sc4 = i + 4 <= n && buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 0 && buf[i + 3] == 1; + if sc3 || sc4 { + if let Some(s) = start { + out.push(&buf[s..i]); + } + i += if sc4 { 4 } else { 3 }; + start = Some(i); + } else { + i += 1; + } + } + if let Some(s) = start { + if s < n { + out.push(&buf[s..n]); + } + } + out +} + +/// NAL unit type (lower 5 bits of the first byte). 7=SPS, 8=PPS, 5=IDR, 1=non-IDR. +pub fn nal_type(nal: &[u8]) -> u8 { + if nal.is_empty() { + 0 + } else { + nal[0] & 0x1f + } +} diff --git a/native/core/src/recorder.rs b/native/core/src/recorder.rs new file mode 100644 index 0000000..d4ab40e --- /dev/null +++ b/native/core/src/recorder.rs @@ -0,0 +1,182 @@ +//! Session recording — repackage the received H.264 Annex-B access units into a +//! playable `.mp4` with **no re-encode** (just remuxing). The viewer tees the +//! same access units it decodes for display into this recorder; the host never +//! has to do anything. Variable-framerate is preserved by timing each sample +//! from its arrival timestamp (the host only sends a frame when the screen +//! changes, so fixed-rate muxing would speed up idle stretches). +//! +//! Developed by Akronlogic. +use crate::error::{NdError, Result}; +use crate::proto::{nal_type, split_nals}; +use std::fs::File; +use std::io::{BufReader, BufWriter}; +use std::path::{Path, PathBuf}; + +fn other(msg: impl std::fmt::Display) -> NdError { + NdError::Other(anyhow::anyhow!("recorder: {msg}")) +} + +/// Muxes H.264 access units into an MP4 file on disk. +pub struct Mp4Recorder { + path: PathBuf, + width: u16, + height: u16, + writer: Option>>, + sps: Option>, + pps: Option>, + start_ms: Option, + /// One buffered sample so its duration can be set from the NEXT sample's + /// timestamp (MP4 samples carry how long they are shown). + pending: Option<(Vec, bool, u64)>, // (avcc bytes, is_sync, rel_ms) + samples: u64, +} + +const TIMESCALE: u32 = 1000; // milliseconds + +impl Mp4Recorder { + /// Create a recorder that will write to `path`. `width`/`height` are the + /// encoded frame size (from `session.info`); the file is only created once + /// the first keyframe (SPS+PPS) arrives. + pub fn new(path: impl AsRef, width: u16, height: u16) -> Self { + Self { + path: path.as_ref().to_path_buf(), + width: width.max(2), + height: height.max(2), + writer: None, + sps: None, + pps: None, + start_ms: None, + pending: None, + samples: 0, + } + } + + /// Feed one access unit (Annex-B). `ts_ms` is a monotonically increasing + /// arrival time in milliseconds. SPS/PPS are harvested automatically and the + /// file starts on the first keyframe. + pub fn push(&mut self, au: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> { + let nals = split_nals(au); + + // Harvest parameter sets (needed for the avcC box). + for nal in &nals { + match nal_type(nal) { + 7 if self.sps.is_none() => self.sps = Some(nal.to_vec()), + 8 if self.pps.is_none() => self.pps = Some(nal.to_vec()), + _ => {} + } + } + + // Lazily open the file once we can describe the track. + if self.writer.is_none() { + let (sps, pps) = match (self.sps.clone(), self.pps.clone()) { + (Some(s), Some(p)) => (s, p), + _ => return Ok(()), // wait for the first keyframe's SPS/PPS + }; + let file = BufWriter::new(File::create(&self.path).map_err(other)?); + let config = mp4::Mp4Config { + major_brand: mp4::FourCC::from(*b"isom"), + minor_version: 512, + compatible_brands: vec![ + mp4::FourCC::from(*b"isom"), + mp4::FourCC::from(*b"iso2"), + mp4::FourCC::from(*b"avc1"), + mp4::FourCC::from(*b"mp41"), + ], + timescale: TIMESCALE, + }; + let mut w = mp4::Mp4Writer::write_start(file, &config).map_err(other)?; + let track = mp4::TrackConfig { + track_type: mp4::TrackType::Video, + timescale: TIMESCALE, + language: "und".to_string(), + media_conf: mp4::MediaConfig::AvcConfig(mp4::AvcConfig { + width: self.width, + height: self.height, + seq_param_set: sps, + pic_param_set: pps, + }), + }; + w.add_track(&track).map_err(other)?; + self.writer = Some(w); + } + + // Convert the Annex-B access unit to AVCC (4-byte length-prefixed NALs), + // dropping parameter sets (already in avcC) and access-unit delimiters. + let mut avcc = Vec::with_capacity(au.len()); + for nal in &nals { + if nal.is_empty() { + continue; + } + match nal_type(nal) { + 7 | 8 | 9 => continue, // SPS, PPS, AUD + _ => {} + } + avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes()); + avcc.extend_from_slice(nal); + } + if avcc.is_empty() { + return Ok(()); + } + + let rel_ms = { + let start = *self.start_ms.get_or_insert(ts_ms); + ts_ms.saturating_sub(start) + }; + + // Flush the previously buffered sample now that we know its duration. + if let Some((bytes, is_sync, prev_rel)) = self.pending.take() { + let dur = rel_ms.saturating_sub(prev_rel).clamp(1, 10_000) as u32; + self.write_sample(bytes, is_sync, prev_rel, dur)?; + } + self.pending = Some((avcc, keyframe, rel_ms)); + Ok(()) + } + + fn write_sample(&mut self, bytes: Vec, is_sync: bool, start: u64, dur: u32) -> Result<()> { + if let Some(w) = self.writer.as_mut() { + let sample = mp4::Mp4Sample { + start_time: start, + duration: dur, + rendering_offset: 0, + is_sync, + bytes: mp4::Bytes::from(bytes), + }; + w.write_sample(1, &sample).map_err(other)?; + self.samples += 1; + } + Ok(()) + } + + /// Flush the last sample and finalize the MP4. Returns (path, sample count). + pub fn finish(mut self) -> Result<(PathBuf, u64)> { + if let Some((bytes, is_sync, rel)) = self.pending.take() { + self.write_sample(bytes, is_sync, rel, 33)?; // nominal last-frame hold + } + if let Some(mut w) = self.writer.take() { + w.write_end().map_err(other)?; + } + Ok((self.path.clone(), self.samples)) + } + + pub fn samples(&self) -> u64 { + self.samples + } +} + +/// Read an MP4 back and return (video track count, total video samples, width, +/// height). Used to verify a recording is a valid, parseable, playable file. +pub fn probe_mp4(path: impl AsRef) -> Result<(u32, u64, u16, u16)> { + let f = File::open(path.as_ref()).map_err(other)?; + let size = f.metadata().map_err(other)?.len(); + let reader = mp4::Mp4Reader::read_header(BufReader::new(f), size).map_err(other)?; + let (mut vtracks, mut samples, mut w, mut h) = (0u32, 0u64, 0u16, 0u16); + for track in reader.tracks().values() { + if matches!(track.track_type(), Ok(mp4::TrackType::Video)) { + vtracks += 1; + samples += track.sample_count() as u64; + w = track.width(); + h = track.height(); + } + } + Ok((vtracks, samples, w, h)) +} diff --git a/native/core/src/rtc.rs b/native/core/src/rtc.rs new file mode 100644 index 0000000..bc7320a --- /dev/null +++ b/native/core/src/rtc.rs @@ -0,0 +1,585 @@ +//! WebRTC peer-connection helpers for the native P2P fast path (webrtc 0.17.2, +//! the classic tokio-callback API). This module builds the `API` + a +//! `RTCPeerConnection` from the ICE servers the relay hands us in `rtc.config` +//! / `rtc.offer.ice`. The DataChannel transport ([`crate::transport`]) and the +//! offer/answer signaling are layered on top of these. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use webrtc::api::interceptor_registry::register_default_interceptors; +use webrtc::api::media_engine::MediaEngine; +use webrtc::api::{APIBuilder, API}; +use webrtc::data_channel::data_channel_init::RTCDataChannelInit; +use webrtc::data_channel::RTCDataChannel; +use webrtc::ice_transport::ice_candidate_type::RTCIceCandidateType; +use webrtc::ice_transport::ice_server::RTCIceServer; +use webrtc::interceptor::registry::Registry; +use webrtc::peer_connection::configuration::RTCConfiguration; +use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +use webrtc::peer_connection::RTCPeerConnection; + +/// One ICE server exactly as it arrives in the relay's `rtc.config` reply and +/// is forwarded in `rtc.offer.ice`: `{"urls":[...],"username":...,"credential":...}`. +/// coturn REST creds (username = ":nd", credential = base64 HMAC-SHA1) +/// drop straight into webrtc's long-term TURN credential fields. +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct RtcIceServerJson { + #[serde(default)] + pub urls: Vec, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub credential: Option, +} + +/// Map the relay's ICE JSON into webrtc `RTCIceServer`s (0.17.2 has only +/// urls/username/credential). +pub fn ice_servers_from_json(ice: &[RtcIceServerJson]) -> Vec { + ice.iter() + .filter(|s| !s.urls.is_empty()) + .map(|s| RTCIceServer { + urls: s.urls.clone(), + username: s.username.clone().unwrap_or_default(), + credential: s.credential.clone().unwrap_or_default(), + ..Default::default() + }) + .collect() +} + +/// Build a webrtc `API` with the default media engine + interceptors. One per +/// peer connection is fine (cheap). +/// +/// Raises the SCTP max send message size to 256 KiB (webrtc-rs defaults to +/// 64 KiB), matching the web client's advertised `a=max-message-size:262144`. +/// Without this a whole ~200 KiB video keyframe is rejected with +/// `ErrOutboundPacketTooLarge`; with it the native peer can send whole E2E +/// frames exactly like the browser / aiortc, which is what web/Python interop +/// on the `media` channel requires. (Harmless for the native single channel, +/// which fragments at 16 KiB.) +pub fn build_api() -> Result { + use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine}; + let mut media = MediaEngine::default(); + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media)?; + let mut se = SettingEngine::default(); + se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(262144)); + Ok(APIBuilder::new() + .with_media_engine(media) + .with_interceptor_registry(registry) + .with_setting_engine(se) + .build()) +} + +/// A peer-connection configuration from a list of ICE servers. +pub fn rtc_config(ice_servers: Vec) -> RTCConfiguration { + RTCConfiguration { + ice_servers, + ..Default::default() + } +} + +/// Create a new peer connection on `api` with `cfg`. +pub async fn new_pc(api: &API, cfg: RTCConfiguration) -> Result> { + Ok(Arc::new(api.new_peer_connection(cfg).await?)) +} + +/// Cap on how long we wait for ICE gathering to finish before sending the SDP. +/// The web/Python peers are NON-TRICKLE (candidates must be embedded in the +/// offer/answer), so we gather to completion — but never hang on a stuck +/// network interface: 2.5 s matches the reference signaling timeout. +pub const GATHER_CAP: Duration = Duration::from_millis(2500); + +/// The max DataChannel message size we advertise (bytes) — matches the web +/// client's `a=max-message-size:262144`. +pub const MAX_MESSAGE_SIZE: u32 = 262144; + +/// Ensure the SDP's `m=application` section advertises our max-message-size. +/// webrtc-rs never emits `a=max-message-size`, so a peer defaults our receive +/// cap to 64 KiB and refuses to send us a whole ~200 KiB keyframe. Injecting it +/// (right after `a=sctp-port:`) raises the PEER's send-to-us cap to 256 KiB — +/// and is exactly what aiortc / the browser advertise, so it is also how we +/// receive whole video frames from the Python host over the `media` channel. +pub fn advertise_max_message_size(sdp: String) -> String { + if sdp.contains("a=max-message-size:") { + return sdp; // already present (e.g. echoed from a web peer) + } + let nl = if sdp.contains("\r\n") { "\r\n" } else { "\n" }; + let needle = "a=sctp-port:"; + if let Some(pos) = sdp.find(needle) { + // insert the attribute on its own line after the sctp-port line + if let Some(eol) = sdp[pos..].find('\n') { + let insert_at = pos + eol + 1; + let mut out = String::with_capacity(sdp.len() + 32); + out.push_str(&sdp[..insert_at]); + out.push_str(&format!("a=max-message-size:{MAX_MESSAGE_SIZE}{nl}")); + out.push_str(&sdp[insert_at..]); + return out; + } + } + sdp +} + +/// Offerer (viewer): create the offer, then gather ICE **to completion** and +/// return the full local SDP for `rtc.offer.sdp`. Non-trickle: every candidate +/// is embedded in the returned SDP, because the Python host has no trickle +/// candidate handler. The caller must create its DataChannel(s) on `pc` BEFORE +/// calling (they shape the offer). `gathering_complete_promise` is taken before +/// `set_local_description`, which is what actually starts gathering. +pub async fn offer_nontrickle(pc: &Arc, gather_cap: Duration) -> Result { + let offer = pc.create_offer(None).await?; + let mut gather = pc.gathering_complete_promise().await; + pc.set_local_description(offer).await?; + let _ = tokio::time::timeout(gather_cap, gather.recv()).await; + let local = pc + .local_description() + .await + .ok_or_else(|| anyhow!("no local description after ICE gathering"))?; + Ok(advertise_max_message_size(local.sdp)) +} + +/// Answerer (host): apply the remote offer SDP, create the answer, gather ICE +/// to completion (non-trickle) and return the full local SDP for +/// `rtc.answer.sdp`. Register `on_data_channel` on `pc` BEFORE calling so the +/// viewer-created channel is captured. +pub async fn answer_nontrickle( + pc: &Arc, + offer_sdp: &str, + gather_cap: Duration, +) -> Result { + let offer = RTCSessionDescription::offer(offer_sdp.to_string())?; + pc.set_remote_description(offer).await?; + let answer = pc.create_answer(None).await?; + let mut gather = pc.gathering_complete_promise().await; + pc.set_local_description(answer).await?; + let _ = tokio::time::timeout(gather_cap, gather.recv()).await; + let local = pc + .local_description() + .await + .ok_or_else(|| anyhow!("no local description after ICE gathering"))?; + Ok(advertise_max_message_size(local.sdp)) +} + +/// Offerer (viewer): apply the remote answer SDP received in `rtc.answer`. +pub async fn apply_answer(pc: &Arc, answer_sdp: &str) -> Result<()> { + let answer = RTCSessionDescription::answer(answer_sdp.to_string())?; + pc.set_remote_description(answer).await?; + Ok(()) +} + +/// Wait for the server to return rtc.config before falling back to a default +/// STUN server. 2.5 s matches the reference signaling timeout. +pub const CONFIG_CAP: Duration = Duration::from_millis(2500); +/// How long to wait for the peer's rtc.answer. +pub const ANSWER_CAP: Duration = Duration::from_secs(10); +/// How long to wait for the pc to connect + the DataChannel to open. +pub const CONNECT_CAP: Duration = Duration::from_secs(15); + +/// Default ICE if the server does not answer rtc.config in time. A public STUN +/// is enough to still find a server-reflexive path; host/LAN pairs connect +/// without it (and loopback needs no ICE server at all). +pub fn default_ice() -> Vec { + vec![RtcIceServerJson { + urls: vec!["stun:stun.l.google.com:19302".into()], + username: None, + credential: None, + }] +} + +/// Pull rtc.* Values from `rtc_in` until one matches `want` (dropping any other +/// rtc.* that arrives first), capped by `cap`. rtc.unavailable short-circuits to +/// an error so the caller abandons the upgrade and stays on the relay. +async fn wait_rtc( + rtc_in: &mut mpsc::UnboundedReceiver, + want: &str, + cap: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + cap; + loop { + match tokio::time::timeout_at(deadline, rtc_in.recv()).await { + Ok(Some(v)) => { + let t = v.get("t").and_then(|t| t.as_str()).unwrap_or(""); + if t == want { + return Ok(v); + } + if t == "rtc.unavailable" { + return Err(anyhow!("peer declined P2P (rtc.unavailable)")); + } + // ignore any other rtc.* and keep waiting + } + Ok(None) => return Err(anyhow!("rtc signaling channel closed")), + Err(_) => return Err(anyhow!("timed out waiting for {want}")), + } + } +} + +/// Viewer offerer: run the relay-brokered P2P upgrade handshake and return the +/// connected pc + its DataChannel once the channel opens. `send_text` enqueues +/// an rtc.* text message onto the relay (the caller supplies one that carries +/// `sid`); `rtc_in` yields inbound rtc.config / rtc.answer / rtc.unavailable +/// Values the relay loop forwards. When `ice_override` is `None` the offerer +/// asks the server for ICE via rtc.config.req (default STUN on timeout); tests +/// pass `Some(vec![])` for a loopback host-candidate-only pc. +pub async fn upgrade_offerer( + api: &API, + sid: &str, + ice_override: Option>, + channel_label: &str, + send_text: &(dyn Fn(String) + Send + Sync), + rtc_in: &mut mpsc::UnboundedReceiver, +) -> Result<(Arc, Arc)> { + let ice = match ice_override { + Some(i) => i, + None => { + send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string()); + match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await { + Ok(v) => v + .get("iceServers") + .and_then(|x| serde_json::from_value::>(x.clone()).ok()) + .unwrap_or_else(default_ice), + Err(_) => default_ice(), + } + } + }; + + let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?; + let (open_tx, mut open_rx) = mpsc::channel::<()>(1); + let dc = pc + .create_data_channel( + channel_label, + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await?; + { + let tx = open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + + // Non-trickle: gather fully, then send the offer with the ICE config echoed + // through so a TURN-only carrier-NAT host can reach us. + let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?; + // `native:true` marks a native single-channel offerer: a native host only + // answers these, and declines (rtc.unavailable) a web/3-channel offerer so + // that pairing safely stays on the relay. + send_text( + json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice, "native": true }) + .to_string(), + ); + + let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?; + let answer_sdp = answer + .get("sdp") + .and_then(|s| s.as_str()) + .ok_or_else(|| anyhow!("rtc.answer missing sdp"))?; + apply_answer(&pc, answer_sdp).await?; + + tokio::time::timeout(CONNECT_CAP, open_rx.recv()) + .await + .map_err(|_| anyhow!("P2P DataChannel did not open"))? + .ok_or_else(|| anyhow!("P2P open signal dropped"))?; + Ok((pc, dc)) +} + +/// Host answerer: handle one inbound rtc.offer. Build the answerer pc from the +/// offer plus the `ice` the offerer echoed, capture the viewer-created channel +/// via on_data_channel, create+send the answer via `send_text` (carrying +/// `sid`), and return the connected pc + channel once it opens. +pub async fn answer_offer( + api: &API, + sid: &str, + offer_sdp: &str, + ice: Vec, + send_text: &(dyn Fn(String) + Send + Sync), +) -> Result<(Arc, Arc)> { + let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?; + let (dc_tx, mut dc_rx) = mpsc::channel::>(1); + let (open_tx, mut open_rx) = mpsc::channel::<()>(1); + { + let dc_tx = dc_tx.clone(); + let open_tx = open_tx.clone(); + pc.on_data_channel(Box::new(move |d: Arc| { + let dc_tx = dc_tx.clone(); + let open_tx = open_tx.clone(); + Box::pin(async move { + let ot = open_tx.clone(); + d.on_open(Box::new(move || { + let ot = ot.clone(); + Box::pin(async move { + let _ = ot.send(()).await; + }) + })); + let _ = dc_tx.send(d).await; + }) + })); + } + + let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?; + send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string()); + + let dc = tokio::time::timeout(CONNECT_CAP, dc_rx.recv()) + .await + .map_err(|_| anyhow!("no DataChannel from the offerer"))? + .ok_or_else(|| anyhow!("DataChannel signal dropped"))?; + tokio::time::timeout(CONNECT_CAP, open_rx.recv()) + .await + .map_err(|_| anyhow!("P2P DataChannel did not open (host)"))? + .ok_or_else(|| anyhow!("host open signal dropped"))?; + Ok((pc, dc)) +} + +/// True when the pc's nominated ICE pair is a DIRECT path (host / server- +/// reflexive on both ends) rather than a TURN relay. The relay-first state +/// machine only reports "Direct P2P" and moves media off the WSS relay for a +/// direct pair: a TURN-relayed hop is no faster than our own relay, and a +/// not-yet-selected pair is unknown, so both keep media on the relay. +pub async fn selected_pair_is_direct(pc: &Arc) -> bool { + let dtls = pc.sctp().transport(); + let ice = dtls.ice_transport(); + match ice.get_selected_candidate_pair().await { + Some(pair) => { + pair.local.typ != RTCIceCandidateType::Relay + && pair.remote.typ != RTCIceCandidateType::Relay + } + None => false, + } +} + +/// The web/Python 3-channel DataChannel topology. Labels + options match +/// `app.js` exactly so a native peer interops with the browser and the Python +/// host: `media` carries video (unreliable, 150 ms lifetime — drop beats late), +/// `ctl` carries input/clipboard/file/keyframe (reliable, ordered), `move` +/// carries pointer motion (unreliable, no retransmit). +pub struct WebChannels { + pub media: Arc, + pub ctl: Arc, + pub mov: Arc, +} + +/// Offerer: create the three web channels on `pc`. Call BEFORE `offer_nontrickle` +/// so they are announced in the SDP. The option set is byte-for-byte the web +/// client's (`media`{ordered:false,maxPacketLifeTime:150}, +/// `ctl`{ordered:true}, `move`{ordered:false,maxRetransmits:0}). +pub async fn create_web_channels(pc: &Arc) -> Result { + let media = pc + .create_data_channel( + "media", + Some(RTCDataChannelInit { + ordered: Some(false), + max_packet_life_time: Some(150), + ..Default::default() + }), + ) + .await?; + let ctl = pc + .create_data_channel( + "ctl", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await?; + let mov = pc + .create_data_channel( + "move", + Some(RTCDataChannelInit { + ordered: Some(false), + max_retransmits: Some(0), + ..Default::default() + }), + ) + .await?; + Ok(WebChannels { media, ctl, mov }) +} + +/// Like [`build_api`] but with DETACHED DataChannels enabled. In detach mode we +/// read each channel with our OWN buffer instead of webrtc-rs's fixed 64 KiB +/// `read_loop` buffer — which is the ONLY way to receive a whole >64 KiB +/// DataChannel message on webrtc-rs 0.17.2. This is required for the web/Python +/// `media` channel, where the browser / aiortc send whole ~100-200 KiB video +/// frames. Use this API for the web 3-channel topology; [`build_api`] (on_message, +/// 64 KiB) stays the default for the native single channel, which fragments at +/// 16 KiB and never needs a large receive. +pub fn build_api_web() -> Result { + use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine}; + let mut media = MediaEngine::default(); + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media)?; + let mut se = SettingEngine::default(); + se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(MAX_MESSAGE_SIZE)); + se.detach_data_channels(); + Ok(APIBuilder::new() + .with_media_engine(media) + .with_interceptor_registry(registry) + .with_setting_engine(se) + .build()) +} + +/// The web DataChannels the viewer actually uses, after detaching (raw +/// read/write handles with our own buffer — the only way to receive whole +/// >64KiB frames on webrtc-rs 0.17.2). `move` is created for topology/interop +/// but the viewer never reads/writes it, so it is not detached (fewer detaches +/// = more reliable). +pub struct WebDetached { + pub media: Arc, + pub ctl: Arc, +} + +/// Viewer offerer, WEB topology (interop with the browser / Python host). Builds +/// the 3 channels (media/ctl/move, labels+options per app.js), runs the +/// non-trickle rtc.offer/answer handshake over `send_text`+`rtc_in` (offer does +/// NOT carry `native` — it must look like a standard web offer so the Python +/// host accepts it), waits for all 3 to open, then detaches them SEQUENTIALLY +/// (concurrent multi-channel detach deadlocks) and returns the raw handles. The +/// pc must be built from [`build_api_web`] (detach enabled). +pub async fn upgrade_offerer_web( + api: &API, + sid: &str, + ice_override: Option>, + send_text: &(dyn Fn(String) + Send + Sync), + rtc_in: &mut mpsc::UnboundedReceiver, +) -> Result<(Arc, WebDetached)> { + let ice = match ice_override { + Some(i) => i, + None => { + send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string()); + match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await { + Ok(v) => v + .get("iceServers") + .and_then(|x| serde_json::from_value::>(x.clone()).ok()) + .unwrap_or_else(default_ice), + Err(_) => default_ice(), + } + } + }; + + let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?; + let web = create_web_channels(&pc).await?; + + // open signals for the three channels + let (open_tx, mut open_rx) = mpsc::unbounded_channel::<&'static str>(); + for (lbl, ch) in [ + ("media", &web.media), + ("ctl", &web.ctl), + ("move", &web.mov), + ] { + let tx = open_tx.clone(); + ch.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(lbl); + }) + })); + } + + let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?; + // NOTE: no `native` marker — this is a standard web offer. + send_text(json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice }).to_string()); + let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?; + let answer_sdp = answer + .get("sdp") + .and_then(|s| s.as_str()) + .ok_or_else(|| anyhow!("rtc.answer missing sdp"))?; + apply_answer(&pc, answer_sdp).await?; + + // wait for the two channels we use (media+ctl) to open, capped. + let mut opened = std::collections::HashSet::new(); + let deadline = tokio::time::Instant::now() + CONNECT_CAP; + while !(opened.contains("media") && opened.contains("ctl")) { + match tokio::time::timeout_at(deadline, open_rx.recv()).await { + Ok(Some(l)) => { + opened.insert(l); + } + _ => return Err(anyhow!("web channels did not open")), + } + } + + // detach SEQUENTIALLY (concurrent detach deadlocks) — only media + ctl. + let media = web.media.detach().await?; + let ctl = web.ctl.detach().await?; + Ok((pc, WebDetached { media, ctl })) +} + +/// Host answerer, WEB topology (a browser / web viewer connects to a native +/// host). Builds the answerer pc, binds the viewer-created media/ctl/move +/// channels by label, runs the non-trickle answer, waits for media+ctl to open, +/// then detaches them SEQUENTIALLY. The pc must be built from [`build_api_web`]. +/// The host then SENDS video on media (whole frames, cap 256KiB) and READS +/// input on ctl. +pub async fn answer_offer_web( + api: &API, + sid: &str, + offer_sdp: &str, + ice: Vec, + send_text: &(dyn Fn(String) + Send + Sync), +) -> Result<(Arc, WebDetached)> { + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + + let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?; + + // bind channels by label + signal opens + let chans: Arc>>> = + Arc::new(StdMutex::new(HashMap::new())); + let (open_tx, mut open_rx) = mpsc::unbounded_channel::(); + { + let chans = chans.clone(); + let open_tx = open_tx.clone(); + pc.on_data_channel(Box::new(move |d: Arc| { + let chans = chans.clone(); + let open_tx = open_tx.clone(); + let label = d.label().to_string(); + Box::pin(async move { + chans.lock().unwrap().insert(label.clone(), d.clone()); + let tx = open_tx.clone(); + let l = label.clone(); + d.on_open(Box::new(move || { + let tx = tx.clone(); + let l = l.clone(); + Box::pin(async move { + let _ = tx.send(l); + }) + })); + }) + })); + } + + let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?; + send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string()); + + // wait for media + ctl to open, capped + let mut opened = std::collections::HashSet::new(); + let deadline = tokio::time::Instant::now() + CONNECT_CAP; + while !(opened.contains("media") && opened.contains("ctl")) { + match tokio::time::timeout_at(deadline, open_rx.recv()).await { + Ok(Some(l)) => { + opened.insert(l); + } + _ => return Err(anyhow!("web channels (media/ctl) did not open")), + } + } + + // grab wrappers, then detach SEQUENTIALLY (concurrent detach deadlocks). + let (media_w, ctl_w) = { + let m = chans.lock().unwrap(); + ( + m.get("media").cloned().ok_or_else(|| anyhow!("no media channel"))?, + m.get("ctl").cloned().ok_or_else(|| anyhow!("no ctl channel"))?, + ) + }; + let media = media_w.detach().await?; + let ctl = ctl_w.detach().await?; + Ok((pc, WebDetached { media, ctl })) +} diff --git a/native/core/src/signaling.rs b/native/core/src/signaling.rs new file mode 100644 index 0000000..a16e809 --- /dev/null +++ b/native/core/src/signaling.rs @@ -0,0 +1,1170 @@ +//! WebSocket signaling + session lifecycle against the NetDesk server. +//! Phase 1: connect, complete the auth/E2E handshake, then stream decrypted +//! H.264 video frames out as events. Transport is WSS relay; the P2P (WebRTC) +//! fast path is added in a later phase behind the same event API. + +use crate::crypto::{self, E2e}; +use crate::error::{NdError, Result}; +use crate::proto::*; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rand::RngCore; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +/// Upload chunk size (viewer→host 0xf2 data frames), matching the web viewer's +/// 256 KiB `CHUNK`. The host reassembles by offset, so any value works, but the +/// native client uses the same size the browser does. +pub const UPLOAD_CHUNK: usize = 256 * 1024; + +/// Config for one outbound connection. +#[derive(Clone)] +pub struct ConnectConfig { + pub ws_url: String, // e.g. wss://netdesk.space/ws + pub host_id: String, // 9-digit partner id + pub password: String, + pub totp: String, + pub takeover: bool, +} + +/// Events emitted to the UI / caller during a session. +#[derive(Debug, Clone)] +pub enum SessionEvent { + Connecting, + Challenged { e2e: bool }, + /// Session accepted; the remote screen is about to stream. + Connected { host_name: String, e2e: bool, w: u32, h: u32 }, + /// A decode-ready video access unit, with keyframe flag and codec id + /// (`proto::VideoCodec`: 0 = H.264 Annex-B, 1 = VP9). The viewer selects the + /// matching decoder and can switch mid-session at any keyframe. + Video { keyframe: bool, codec: u8, au: Vec, nals: usize }, + /// A decode-ready Opus packet (one frame) from the host's system audio + /// (CHAN_AUDIO 0xa1, 48 kHz stereo). The UI decodes + plays it if unmuted. + Audio { data: Vec }, + /// Remote mouse cursor position in frame-pixel coordinates (RustDesk-style + /// overlay — sent separately from video so idle screens still move the + /// cursor). `hidden` true means the remote cursor is not shown. + Cursor { id: u64, x: i32, y: i32, hidden: bool }, + /// Remote cursor bitmap: a decoded PNG (`png` bytes) with size + hotspot, or + /// a named standard cursor (`css`). The UI uploads it to a texture. + CursorShape { + id: u64, + png: Vec, + w: u32, + h: u32, + hx: i32, + hy: i32, + css: Option, + }, + /// A chat message from the host (web viewer's inbound `chat`). The host + /// does not currently send a `from`, so it defaults to "Host". + ChatMessage { from: String, text: String }, + /// Remote clipboard text pushed by the host (web viewer's `clip.set`, + /// `origin:"host"`). RustDesk-style clipboard sync. + Clipboard { text: String }, + /// Remote clipboard IMAGE pushed by the host (`clip.image`, origin "host"), + /// as decoded PNG bytes. The UI writes it to the local clipboard. + ClipboardImage { png: Vec }, + /// A live `session.info` update: the host's `info` object serialized as a + /// JSON string. Carries `w,h,mon,monitors,fps,bitrate,host_name,e2e,audio, + /// perms,lock_on_end,privacy_active,logon_screen`. (web viewer's `onInfo`.) + Info { json: String }, + /// Result of a `sys.cmd` action (web viewer's `sys.res`), as a JSON string. + /// Carries `kind` + `ok` + `msg` and, per kind: `items` (processes / the + /// resolution-mode list) with `current:[w,h]` for `modes`, and `on` for the + /// `privacy` / `block_input` / `wallpaper` toggles. + SysResult { json: String }, + + // ---- file transfer (web viewer's Files panel) ------------------------- + /// A remote directory listing — the host's `file.roots.res` (drives / home) + /// or `file.list.res`. `path` is `""` for the roots/home listing, otherwise + /// the directory just listed. Entries arrive pre-sorted by the host (folders + /// first, then case-insensitive by name) and are capped at 2000 by the host. + FileList { path: String, entries: Vec }, + /// A recursive folder manifest (the host's `file.walk.res`): every FILE + /// under the requested folder as (relative forward-slash path, size), so the + /// viewer can recreate the tree and download each entry. `truncated` = the + /// walk hit the host's entry/depth cap. + FileWalk { path: String, files: Vec<(String, u64)>, truncated: bool }, + /// Progress of an in-flight download (identified by the `fid` that + /// `download()` returned). `received` is the number of bytes assembled so + /// far. The host does not send the total size until the transfer completes, + /// so for a percentage the UI uses the size it already has from the listing + /// (`FileEntry::size`) — exactly as the web viewer does. + FileDownloadProgress { fid: u32, received: u64 }, + /// A download finished: the host's `file.done`. `path` is a temp `.part` + /// file the 0xf1 chunks were streamed to on disk (never buffered in RAM); + /// `name` is the remote file's base name and `size` the host's byte count. + /// The consumer MOVES `path` to its destination (delete it on failure). + FileDownloadDone { fid: u32, name: String, size: u64, path: std::path::PathBuf }, + /// A file operation the host reports as successful: the host's `file.ok`. + /// Covers an upload completing *and* a successful `make_dir` / `delete` / + /// `rename`. `refresh == true` means the host wants the current folder + /// re-listed (the web viewer re-sends `file.list` on it). + FileUploadAck { msg: String, refresh: bool }, + /// A file operation failed: the host's `file.err` (bad path, permission + /// denied, upload write error, …). + FileError { msg: String }, + /// Interactive terminal output streamed from the host's shell (`term.data`). + TermData { tid: u32, data: String }, + /// The host's shell for this terminal ended (`term.exit`). + TermExit { tid: u32 }, + + /// The active transport changed: `active` true means media + latency- + /// critical input now ride a direct WebRTC DataChannel (`direct` = a + /// non-TURN pair); false means it fell back to the relay. Surfaced for the + /// UI ("Direct P2P" / "relay") and drives the e2e P2P assertions. Emitted + /// only when NETDESK_P2P is on. + P2pState { active: bool, direct: bool }, + + Denied { reason: String }, + Ended { reason: String }, +} + +/// One entry in a remote directory listing — the web viewer's `{n,t,s,m}` items +/// (see server/web/app.js `onFileList` and host `file_list`). +#[derive(Debug, Clone)] +pub struct FileEntry { + /// File or folder name (no path), the host's `n`. + pub name: String, + /// `true` for a directory (host `t:"d"`), `false` for a file (`t:"f"`). + pub is_dir: bool, + /// Size in bytes (host `s`; 0 for directories). + pub size: u64, + /// Last-modified time in Unix epoch seconds (host `m`). + pub mtime: i64, +} + +/// Reader-side state for one in-flight download. Chunks are written straight to +/// a temp `.part` file on disk (seeking to each chunk's offset) instead of being +/// buffered in RAM, so a multi-gigabyte file never exhausts memory. The consumer +/// moves the finished `.part` to its destination. +struct FileDownload { + received: u64, + pos: u64, // current write position, to avoid a seek on the common sequential path + part: std::path::PathBuf, + writer: Option>, +} + +impl FileDownload { + fn new(fid: u32) -> Self { + let mut part = std::env::temp_dir(); + part.push(format!("netdesk-dl-{fid}-{:x}.part", rand::random::())); + Self { received: 0, pos: 0, part, writer: None } + } + + fn write_chunk(&mut self, off: u64, data: &[u8]) { + use std::io::{Seek, SeekFrom, Write}; + if self.writer.is_none() { + match std::fs::File::create(&self.part) { + Ok(f) => self.writer = Some(std::io::BufWriter::new(f)), + Err(_) => return, + } + } + if let Some(w) = self.writer.as_mut() { + if off != self.pos { + if w.seek(SeekFrom::Start(off)).is_err() { + return; + } + self.pos = off; + } + if w.write_all(data).is_ok() { + self.pos += data.len() as u64; + self.received = self.received.max(self.pos); + } + } + } + + /// Flush and return the path to the completed `.part` file. + fn finish(mut self) -> std::path::PathBuf { + if let Some(mut w) = self.writer.take() { + use std::io::Write; + let _ = w.flush(); + } + self.part + } +} + +/// A monitor/display the host reported in the accept `info.monitors` array. +/// Mirrors the web viewer's `{ i, w, h }` entries (see server/web/app.js onInfo). +#[derive(Debug, Clone)] +pub struct MonitorInfo { + pub i: i32, + pub w: u32, + pub h: u32, +} + +/// A live session handle. Drop it (or call `close`) to end the session. +pub struct Session { + pub info: AcceptInfo, + out: mpsc::UnboundedSender, + sid: String, + monitors: Vec, + cur_mon: i32, + /// Next file-transfer id, handed out by `download()` / `upload_begin()`. + /// Starts at 1, matching the web viewer's `S.nextFid`. + next_fid: AtomicU32, + /// Session AES-256-GCM key, when E2E is active. Needed to encrypt outbound + /// upload (0xf2) binary frames the same way the media path is encrypted; + /// `None` on a non-E2E session (frames sent in the clear, like the browser). + cipher: Option, +} + +impl Session { + /// Send a pointer event (coordinates in the remote screen's pixel space). + pub fn mouse(&self, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) { + let m = MouseInput { t: "i.mouse", x, y, b: buttons, w: wheel, hw: hwheel, sid: self.sid.clone() }; + if let Ok(s) = serde_json::to_string(&m) { + let _ = self.out.send(Message::Text(s)); + } + } + /// Send a RELATIVE pointer move (game / pointer-lock mode): dx/dy are + /// deltas in the remote screen's pixel space, not absolute coordinates. + /// Apps that read raw mouse motion (FPS games, 3D/CAD) consume these. + pub fn mouse_rel(&self, dx: i32, dy: i32, buttons: u32, wheel: f32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"i.mouse.rel","dx":{},"dy":{},"b":{},"w":{},"sid":"{}"}}"#, + dx, dy, buttons, wheel, self.sid + ))); + } + /// Send typed text for translate / international-keyboard mode: the host + /// injects it as literal Unicode (KEYEVENTF_UNICODE), bypassing its + /// layout — how AltGr combos, dead keys and non-US layouts come through. + pub fn text(&self, s: &str) { + self.send_json(serde_json::json!({ "t": "i.text", "text": s, "sid": self.sid })); + } + /// Send a key event (code is a DOM-style key code, e.g. "KeyA", "Enter"). + pub fn key(&self, code: &str, down: bool) { + let m = KeyInput { t: "i.key", code: code.to_string(), down, sid: self.sid.clone() }; + if let Ok(s) = serde_json::to_string(&m) { + let _ = self.out.send(Message::Text(s)); + } + } + /// Annotation: draw a stroke on the host's screen overlay. `pts` are in + /// the remote FRAME pixel space (same as mouse coords); the host maps + /// them to the desktop. `color` is 0xRRGGBB. + pub fn annotate_stroke(&self, color: u32, width: i32, pts: &[(i32, i32)]) { + let arr: Vec<[i32; 2]> = pts.iter().map(|&(x, y)| [x, y]).collect(); + self.send_json(serde_json::json!({ + "t": "annot", "op": "stroke", "color": color, "width": width, + "pts": arr, "sid": self.sid + })); + } + /// Annotation: erase all strokes on the host overlay. + pub fn annotate_clear(&self) { + self.send_json(serde_json::json!({ "t": "annot", "op": "clear", "sid": self.sid })); + } + /// Ask the host for a fresh keyframe (used after a decode resync). + pub fn request_keyframe(&self) { + let _ = self + .out + .send(Message::Text(format!(r#"{{"t":"keyframe","sid":"{}"}}"#, self.sid))); + } + /// Ask the host to (re)configure the encoder: target fps, bitrate (kbps) and + /// resolution scale. Matches the web viewer's `set.quality` control message. + pub fn set_quality(&self, fps: u32, bitrate: u32, scale: f32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"set.quality","fps":{},"bitrate":{},"scale":{},"sid":"{}"}}"#, + fps, bitrate, scale, self.sid + ))); + } + /// Switch the captured monitor (index as reported in `monitors()`). + /// Toggle HOST-ENFORCED view-only for this session: while on, the host + /// drops all mouse/key input from this viewer at the engine (defense in + /// depth under the local UI toggle — a modified viewer can't bypass it). + pub fn view_only(&self, on: bool) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"view.only","on":{},"sid":"{}"}}"#, + on, self.sid + ))); + } + + /// Pin the video codec for this session: -1 = auto, 0 = H.264, 1 = VP9. + /// The viewer uses this to force H.264 while recording (the .mp4 muxer is + /// H.264-only), so the host won't auto-switch to VP9 and break the file. + pub fn pin_codec(&self, codec: i32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"set.codec","codec":{},"sid":"{}"}}"#, + codec, self.sid + ))); + } + + /// Pick the host audio source: 0 = off, 1 = system ("what you hear"), 2 = mic. + pub fn audio_set(&self, src: u8) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"audio.set","src":{},"sid":"{}"}}"#, + src, self.sid + ))); + } + /// Send one Opus packet of the local microphone to the host (voice call): + /// `[CHAN_VOICE][opus]`, E2E-sealed via `send_binary` like file uploads. + pub fn voice(&self, opus: &[u8]) { + let mut frame = Vec::with_capacity(opus.len() + 1); + frame.push(CHAN_VOICE); + frame.extend_from_slice(opus); + self.send_binary(frame); + } + pub fn monitor_switch(&self, mon: i32) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"monitor.switch","mon":{},"sid":"{}"}}"#, + mon, self.sid + ))); + } + /// Send a system command to the host (`"cad"` = Ctrl+Alt+Del, `"lock"`, + /// `"logoff"`, `"reboot"`, …). Matches the web viewer's `sys.cmd` message. + /// This is the escape hatch for any bare-action `sys.cmd`; the named + /// wrappers below cover the actions the web viewer exposes. + pub fn sys(&self, cmd: &str) { + let _ = self.out.send(Message::Text(format!( + r#"{{"t":"sys.cmd","a":"{}","sid":"{}"}}"#, + cmd, self.sid + ))); + } + + /// Serialize a control message to JSON and enqueue it on the outbound + /// channel. Using serde_json guarantees text fields (chat, clipboard, run + /// commands) are correctly escaped. + fn send_json(&self, v: serde_json::Value) { + if let Ok(s) = serde_json::to_string(&v) { + let _ = self.out.send(Message::Text(s)); + } + } + + // ---- clipboard -------------------------------------------------------- + /// Push text to the remote clipboard — and the same message is how the + /// viewer announces its own local clipboard to the host. The host only + /// applies viewer-originated clips (and only when its clipboard perm is on). + /// Wire: `{"t":"clip.set","text":,"origin":"viewer","sid":}`. + /// (There is no separate "request local clipboard" message in the protocol: + /// the viewer reads its own OS clipboard and pushes it with this same call.) + pub fn clipboard(&self, text: &str) { + self.send_json(serde_json::json!({ + "t": "clip.set", "text": text, "origin": "viewer", "sid": self.sid, + })); + } + + /// Push an IMAGE (PNG bytes) to the remote clipboard — the image equivalent + /// of [`clipboard`]. The host applies it when its clipboard perm is on. + /// Wire: `{"t":"clip.image","png":,"origin":"viewer","sid":}`. + pub fn clipboard_image(&self, png: &[u8]) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + self.send_json(serde_json::json!({ + "t": "clip.image", "png": b64, "origin": "viewer", "sid": self.sid, + })); + } + + // ---- chat ------------------------------------------------------------- + /// Send a chat message to the host. + /// Wire: `{"t":"chat","text":,"ts":,"sid":}`. + pub fn chat(&self, text: &str) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + self.send_json(serde_json::json!({ + "t": "chat", "text": text, "ts": ts, "sid": self.sid, + })); + } + + // ---- interactive terminal ("cmd") ------------------------------------- + /// Open a shell on the host. `tid` identifies this terminal; the host + /// streams its output back as `SessionEvent::TermData{tid,..}`. + pub fn term_open(&self, tid: u32) { + self.send_json(serde_json::json!({ "t": "term.open", "tid": tid, "sid": self.sid })); + } + /// Send keystrokes / a command line to the terminal (include the trailing + /// `\r\n` to run a command). + pub fn term_data(&self, tid: u32, data: &str) { + self.send_json(serde_json::json!({ "t": "term.data", "tid": tid, "d": data, "sid": self.sid })); + } + /// Resize the terminal (cols×rows). A no-op on a pipe-based host shell. + pub fn term_resize(&self, tid: u32, cols: u32, rows: u32) { + self.send_json(serde_json::json!({ + "t": "term.resize", "tid": tid, "cols": cols, "rows": rows, "sid": self.sid, + })); + } + /// Close the terminal's shell on the host. + pub fn term_close(&self, tid: u32) { + self.send_json(serde_json::json!({ "t": "term.close", "tid": tid, "sid": self.sid })); + } + + // ---- power / actions (all map to sys.cmd) ----------------------------- + /// Lock the remote workstation. Wire: `{"t":"sys.cmd","a":"lock","sid":}`. + pub fn lock(&self) { self.sys("lock"); } + /// Sign the remote user out. Wire: `{"t":"sys.cmd","a":"logoff","sid":}`. + pub fn logoff(&self) { self.sys("logoff"); } + /// Reboot the remote computer (ends the session). + /// Wire: `{"t":"sys.cmd","a":"reboot","sid":}`. + pub fn reboot(&self) { self.sys("reboot"); } + /// Shut the remote computer down. Wire: `{"t":"sys.cmd","a":"shutdown","sid":}`. + pub fn shutdown(&self) { self.sys("shutdown"); } + /// Send Ctrl+Alt+Del on the host. Wire: `{"t":"sys.cmd","a":"cad","sid":}`. + pub fn ctrl_alt_del(&self) { self.sys("cad"); } + /// Run a command line on the host (e.g. `cmd /c echo hi`). The host replies + /// with a `sys.res` carrying `ok`/`msg` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"run","cmd":,"sid":}`. + pub fn run_command(&self, cmd: &str) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "run", "cmd": cmd, "sid": self.sid, + })); + } + + // ---- toggles ---------------------------------------------------------- + /// Toggle blocking the remote user's own mouse/keyboard. The host flips the + /// state and replies `sys.res` `kind:"block_input"` with the new `on` flag. + /// Wire: `{"t":"sys.cmd","a":"block_input","sid":}`. + pub fn block_input(&self) { self.sys("block_input"); } + /// Toggle privacy mode (host monitor off). The host flips the state and + /// replies `sys.res` `kind:"privacy"` with the new `on` flag; live state is + /// also reflected in `session.info`'s `privacy_active`. + /// Wire: `{"t":"sys.cmd","a":"privacy","sid":}`. + pub fn privacy(&self) { self.sys("privacy"); } + /// Toggle hiding the host wallpaper. The host flips the state and replies + /// `sys.res` `kind:"wallpaper"` with the new `on` flag. + /// Wire: `{"t":"sys.cmd","a":"wallpaper","sid":}`. + pub fn hide_wallpaper(&self) { self.sys("wallpaper"); } + /// Set whether the remote PC locks when this session ends. The host saves + /// its config and replies `sys.res` `ok:true`. + /// Wire: `{"t":"set.cfg","lock_on_end":,"sid":}`. + pub fn set_lock_on_end(&self, on: bool) { + self.send_json(serde_json::json!({ + "t": "set.cfg", "lock_on_end": on, "sid": self.sid, + })); + } + + // ---- screen ----------------------------------------------------------- + /// Refresh the screen (force a fresh keyframe). Same wire message as + /// `request_keyframe`: `{"t":"keyframe","sid":}`. Named for the web + /// viewer's "Refresh screen" menu item. + pub fn refresh_screen(&self) { self.request_keyframe(); } + + // ---- display / resolution --------------------------------------------- + /// Ask the host for its list of display resolutions ("Load resolutions…"). + /// The reply is a `sys.res` `kind:"modes"` with `items:[[w,h],…]` and + /// `current:[w,h]` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"modes","sid":}`. + pub fn list_modes(&self) { self.sys("modes"); } + /// Set the host's display resolution. The host replies `sys.res` + /// `kind:"setmode"` and forces a keyframe on success; it restores the + /// original resolution at session end. + /// Wire: `{"t":"sys.cmd","a":"setmode","w":,"h":,"sid":}`. + pub fn set_mode(&self, w: u32, h: u32) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "setmode", "w": w, "h": h, "sid": self.sid, + })); + } + + // ---- processes -------------------------------------------------------- + /// Ask the host for its process list. The reply is a `sys.res` + /// `kind:"processes"` with `items` (delivered as `SessionEvent::SysResult`). + /// Wire: `{"t":"sys.cmd","a":"processes","sid":}`. + pub fn list_processes(&self) { self.sys("processes"); } + /// Kill a process by PID on the host. The host replies `sys.res` `ok`/`msg`. + /// Wire: `{"t":"sys.cmd","a":"kill","pid":,"sid":}`. + pub fn kill_process(&self, pid: u32) { + self.send_json(serde_json::json!({ + "t": "sys.cmd", "a": "kill", "pid": pid, "sid": self.sid, + })); + } + + // NOTE: two of the web viewer's controls are client-only and have NO server + // message, so there is deliberately no Session method for them: + // * Scaling "Best fit" / "Original (1:1)" — a CSS object-fit choice on the + // canvas. (Encoder-side resolution scale is the `scale` arg of + // `set_quality`, which IS a server message and already exists.) + // * "Play remote computer sound" — a local mute/unmute of the audio output; + // the host streams audio whenever `session.info.audio` is true and the + // core emits `SessionEvent::Audio`. The UI decides whether to play it. + // * "Session information…" is likewise assembled on the client from the + // already-received `session.info` (see `SessionEvent::Info`) plus local + // stats; there is no request/response round-trip. + + /// Monitors the host advertised at accept time (may be empty). + pub fn monitors(&self) -> &[MonitorInfo] { + &self.monitors + } + /// Index of the monitor the host started streaming. + pub fn current_monitor(&self) -> i32 { + self.cur_mon + } + + // ---- file transfer ---------------------------------------------------- + // Outbound half of the web viewer's Files panel. The host replies with + // `file.*` JSON (-> FileList / FileUploadAck / FileError / FileDownloadDone) + // and, for downloads, 0xf1 binary chunks (-> FileDownloadProgress). + + /// Request the remote "home" listing — drive letters on Windows (`C:\`, + /// `D:\`, …) or `/` on Unix. The reply (`file.roots.res`) is delivered as + /// `SessionEvent::FileList` with `path == ""`. This is the web viewer's + /// "Home" button / initial Files-panel open. + /// Wire: `{"t":"file.roots","sid":}`. The `sid` is required: the server + /// relays a viewer→host message only when its `sid` matches the session (the + /// web viewer's `send()` auto-adds it to every message); without it the + /// server drops the message and the host never replies. + pub fn list_roots(&self) { + self.send_json(serde_json::json!({ "t": "file.roots", "sid": self.sid })); + } + + /// Request a remote directory listing. An empty `path` is treated by the + /// host as a `file.roots` request. The reply (`file.list.res`) is delivered + /// as `SessionEvent::FileList`. To go "up", pass the parent path; to enter a + /// folder, pass its full path (the UI joins listing entries onto `path`). + /// Wire: `{"t":"file.list","path":,"sid":}`. + pub fn list_dir(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.list", "path": path, "sid": self.sid })); + } + + /// Request a recursive manifest of every file under `path` (for folder + /// download). The reply (`file.walk.res`) arrives as `SessionEvent::FileWalk`. + /// Wire: `{"t":"file.walk","path":,"sid":}`. + pub fn walk(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.walk", "path": path, "sid": self.sid })); + } + + /// Create a remote folder at `path`. The host replies + /// `file.ok{refresh:true}` (delivered as `SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.mkdir","path":,"sid":}`. + pub fn make_dir(&self, path: &str) { + self.send_json(serde_json::json!({ "t": "file.mkdir", "path": path, "sid": self.sid })); + } + + /// Delete a remote file, or — with `is_dir` — recursively delete a folder. + /// The host replies `file.ok{refresh:true}` (`SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.del","path":,"dir":,"sid":}`. + pub fn delete(&self, path: &str, is_dir: bool) { + self.send_json(serde_json::json!({ "t": "file.del", "path": path, "dir": is_dir, "sid": self.sid })); + } + + /// Rename / move a remote entry from `src` to `dst`. The host replies + /// `file.ok{refresh:true}` (`SessionEvent::FileUploadAck`). + /// Wire: `{"t":"file.ren","src":,"dst":,"sid":}`. + pub fn rename(&self, src: &str, dst: &str) { + self.send_json(serde_json::json!({ "t": "file.ren", "src": src, "dst": dst, "sid": self.sid })); + } + + /// Start a remote→viewer download of `remote_path`. Returns the transfer id + /// (`fid`) the host tags its `0xf1` chunks and terminating `file.done` with. + /// As chunks arrive the read loop emits + /// `SessionEvent::FileDownloadProgress{fid,received}`, and on `file.done` it + /// emits `SessionEvent::FileDownloadDone{fid,name,size,path}` with a temp + /// `.part` file the chunks were streamed to on disk (no RAM buffering, so + /// multi-GB files are safe). Errors come back as `SessionEvent::FileError`. + /// Wire: `{"t":"file.get","path":,"fid":,"sid":}`. + pub fn download(&self, remote_path: &str) -> u32 { + let fid = self.alloc_fid(); + self.send_json(serde_json::json!({ "t": "file.get", "path": remote_path, "fid": fid, "sid": self.sid })); + fid + } + + /// Upload `bytes` to remote folder `remote_dir` as file `name` + /// (viewer→host), in one call. Returns the transfer id. Convenience wrapper + /// over the chunked API: sends `file.put` then 0xf2 data frames of up to + /// `UPLOAD_CHUNK` (256 KiB) each. The host writes the bytes to + /// `.netdesk.part`, and once `size` bytes have arrived atomically + /// renames it into place and replies `file.ok{refresh:true}` + /// (`SessionEvent::FileUploadAck`); a write error yields `file.err` + /// (`SessionEvent::FileError`). An empty `remote_dir` makes the host drop + /// the file on the remote Desktop (its TeamViewer-style default for a + /// relative path). + pub fn upload(&self, remote_dir: &str, name: &str, bytes: &[u8]) -> u32 { + let fid = self.upload_begin(remote_dir, name, bytes.len() as u64); + if bytes.is_empty() { + // zero-byte file: the host completes on the first (empty) frame. + self.upload_chunk(fid, 0, &[]); + } else { + let mut off: u64 = 0; + for chunk in bytes.chunks(UPLOAD_CHUNK) { + self.upload_chunk(fid, off, chunk); + off += chunk.len() as u64; + } + } + fid + } + + /// Begin a chunked upload: allocate a transfer id and tell the host the + /// destination path + total `size`. Follow with `upload_chunk` calls whose + /// offsets cover exactly `size` bytes; the host finalizes automatically when + /// the last byte lands (there is no explicit "end" message — the host counts + /// received bytes against `size`, then sends `file.ok`). Returns the `fid`. + /// The path is `remote_dir` joined with `name` and normalized to backslashes + /// (every `/` → `\`), matching the web viewer's + /// `joinPath(...).replace(/\//g,"\\")`. + /// Wire: `{"t":"file.put","path":,"size":,"fid":,"sid":}`. + /// (The binary 0xf2 data frames that follow carry no `sid` — the server + /// routes those by the viewer's own socket, exactly as app.js `sendBinary` + /// does — so only this control message needs it.) + pub fn upload_begin(&self, remote_dir: &str, name: &str, size: u64) -> u32 { + let fid = self.alloc_fid(); + let path = join_remote_path(remote_dir, name); + self.send_json(serde_json::json!({ + "t": "file.put", "path": path, "size": size, "fid": fid, "sid": self.sid, + })); + fid + } + + /// Send one upload data frame for transfer `fid` starting at byte `offset`. + /// The binary frame is `[0xf2][fid: u32 LE][offset: u64 LE][data…]` (a + /// 13-byte header), the exact framing the host's upload reader expects; it + /// is AES-256-GCM encrypted with the session key when E2E is active, just + /// like the media frames. Keep `data.len()` ≤ `UPLOAD_CHUNK` to mirror the + /// browser. + pub fn upload_chunk(&self, fid: u32, offset: u64, data: &[u8]) { + let mut frame = Vec::with_capacity(13 + data.len()); + frame.push(CHAN_FILE_END); // 0xf2 — the viewer→host upload data channel + frame.extend_from_slice(&fid.to_le_bytes()); + frame.extend_from_slice(&offset.to_le_bytes()); + frame.extend_from_slice(data); + self.send_binary(frame); + } + + /// Allocate the next file-transfer id (starts at 1, like `S.nextFid`). + fn alloc_fid(&self) -> u32 { + self.next_fid.fetch_add(1, Ordering::Relaxed) + } + + /// Enqueue a binary frame, AES-GCM-encrypting it (random 12-byte nonce, + /// prepended) when the session is E2E — identical to the web viewer's + /// `sendBinary`. The reader task forwards it to the socket verbatim. + fn send_binary(&self, frame: Vec) { + let payload = match &self.cipher { + Some(c) => { + let mut nonce = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let nonce = Nonce::from(nonce); + match c.encrypt(&nonce, frame.as_ref()) { + Ok(ct) => { + let mut out = Vec::with_capacity(12 + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + out + } + Err(_) => return, // drop on the (practically impossible) GCM error + } + } + None => frame, + }; + let _ = self.out.send(Message::Binary(payload)); + } + + pub fn close(&self) { + let _ = self + .out + .send(Message::Text(format!(r#"{{"t":"session.end","sid":"{}"}}"#, self.sid))); + } +} + +/// Join a remote directory and a file name the way the web viewer does, then +/// normalize to Windows separators (`/` → `\`). An empty `dir` yields just the +/// name. Mirrors app.js `joinPath(dir,name).replace(/\//g,"\\")`. +fn join_remote_path(dir: &str, name: &str) -> String { + let joined = if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + }; + joined.replace('/', "\\") +} + +/// Last path component (the file name) of a `/`- or `\`-separated path, used to +/// name a completed download from the host's `file.done` `path`. +fn basename(path: &str) -> String { + let name = path + .rsplit(|c| c == '/' || c == '\\') + .find(|s| !s.is_empty()) + .unwrap_or(""); + if name.is_empty() { + "file".to_string() + } else { + name.to_string() + } +} + +fn install_crypto_provider() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Connect and run the handshake. On success returns a `Session` plus a receiver +/// of `SessionEvent`s. The network loop runs on a spawned task until the socket +/// closes or `Session` is dropped. +pub async fn connect( + cfg: ConnectConfig, + events: mpsc::UnboundedSender, +) -> Result { + let _ = events.send(SessionEvent::Connecting); + // rustls 0.23 needs a process-wide crypto provider; install ring once. + install_crypto_provider(); + let transport = crate::transport::WsRelay::connect(&cfg.ws_url).await?; + // Relay-first, with a transparent direct-P2P upgrade when NETDESK_P2P is on. + // The multiplexer owns the relay and hands the loop an unchanged Transport, + // so the relay path is byte-identical when the flag is off. + if crate::p2p::p2p_enabled() { + let api = std::sync::Arc::new( + crate::rtc::build_api().map_err(|e| NdError::Connect(e.to_string()))?, + ); + let mux = crate::p2p::upgrade_relay(Box::new(transport), crate::p2p::P2pRole::Offerer, api); + connect_with(cfg, events, Box::new(mux)).await + } else { + connect_with(cfg, events, Box::new(transport)).await + } +} + +/// Run a session over any [`crate::transport::Transport`] (the WSS relay today, +/// or a P2P WebRTC DataChannel later). `connect` is the thin WSS wrapper. +pub async fn connect_with( + cfg: ConnectConfig, + events: mpsc::UnboundedSender, + mut transport: Box, +) -> Result { + use crate::transport::TransportMsg; + + // outbound queue so input events and control messages can be sent from the + // Session handle while the read loop owns the stream. + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // 1) begin the session + let begin = SessionBegin { + t: "session.begin", + id: &cfg.host_id, + direct: false, + takeover: if cfg.takeover { Some(true) } else { None }, + // Advertise VP9 decode only where we actually have the decoder (the + // libvpx Vp9Decoder is Windows-only for now). + vp9: if cfg!(windows) { Some(true) } else { None }, + }; + transport.send_text(serde_json::to_string(&begin)?).await?; + + // 2) drive the handshake synchronously until we reach accept/deny. + let mut e2e: Option = None; + let mut sid = String::new(); + let info: AcceptInfo; + let mut monitors: Vec = Vec::new(); + let mut cur_mon: i32 = 0; + loop { + let msg = match transport.recv().await { + Some(Ok(m)) => m, + Some(Err(e)) => return Err(e), + None => return Err(NdError::Closed), + }; + match msg { + TransportMsg::Text(txt) => { + let v: serde_json::Value = serde_json::from_str(&txt)?; + match v.get("t").and_then(|t| t.as_str()).unwrap_or("") { + "session.challenge" => { + let ch: Challenge = serde_json::from_value(v)?; + sid = ch.sid.clone(); + let _ = events.send(SessionEvent::Challenged { e2e: ch.e2e }); + let our_pub = if ch.e2e { + if let Some(hp) = &ch.pub_ { + let derived = crypto::derive_e2e(hp)?; + let pb = derived.our_pub_b64.clone(); + e2e = Some(derived); + Some(pb) + } else { + None + } + } else { + None + }; + let proof = crypto::password_proof(&cfg.password, &ch.salt, &ch.nonce, ch.iters)?; + // Channel-bound proof (MITM hardening): only when E2E is + // active (we received the host's pub). Binds that exact pub + // so a relay key-swap is detected host-side. + let proof_bind = match (&ch.pub_, ch.e2e) { + (Some(hp), true) => Some(crypto::password_proof_bound( + &cfg.password, &ch.salt, &ch.nonce, ch.iters, hp, + )?), + _ => None, + }; + let auth = SessionAuth { + t: "session.auth", + proof, + pub_: our_pub, + proof_bind, + totp: cfg.totp.clone(), + sid: sid.clone(), + // Advertise VP9 decode where we have the decoder + // (Windows); the server relays auth to the host. + vp9: if cfg!(windows) { Some(true) } else { None }, + }; + transport.send_text(serde_json::to_string(&auth)?).await?; + } + "session.accept" => { + let accepted: AcceptInfo = v + .get("info") + .cloned() + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + // if the host didn't actually negotiate e2e, drop our key + if !accepted.e2e { + e2e = None; + } + info = accepted.clone(); + // Parse the host's monitor list + current monitor straight from + // the raw accept `info` (proto.rs AcceptInfo stays untouched). + if let Some(infoval) = v.get("info") { + if let Some(arr) = infoval.get("monitors").and_then(|m| m.as_array()) { + for mon in arr { + monitors.push(MonitorInfo { + i: mon.get("i").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + w: mon.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: mon.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + }); + } + } + cur_mon = infoval.get("mon").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + } + // Advertise decode capabilities now that we're connected. + // The signaling server strips custom fields from the + // handshake, so VP9 capability is delivered here (relayed + // by sid like input) — this is what enables the host's + // auto-VP9 on constrained links. + // The native (Windows) viewer's libvpx decoder handles VP9 + // profile-1 4:4:4, so it advertises i444 too; the host sends + // 4:4:4 only to i444-capable viewers (web/Android decoders omit it). + let caps = format!( + r#"{{"t":"viewer.caps","vp9":{},"i444":{},"sid":"{}"}}"#, + cfg!(windows), + cfg!(windows), + sid + ); + let _ = transport.send_text(caps).await; + let _ = events.send(SessionEvent::Connected { + host_name: accepted.host_name.clone().unwrap_or_default(), + e2e: accepted.e2e, + w: accepted.w.unwrap_or(0), + h: accepted.h.unwrap_or(0), + }); + break; + } + "session.deny" => { + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("denied").to_string(); + let _ = events.send(SessionEvent::Denied { reason: reason.clone() }); + return Err(NdError::Denied(reason)); + } + _ => {} + } + } + TransportMsg::Binary(_) => {} + } + } + + // 3) spawn the steady-state loop: forward outbound queue + handle inbound frames. + let cipher: Option = e2e.map(|e| e.cipher); + // the Session needs the key too, to encrypt outbound upload frames. + let session_cipher = cipher.clone(); + let ev = events.clone(); + tokio::spawn(async move { + // in-flight downloads, keyed by fid: host 0xf1 chunks assembled here and + // emitted as a whole file when the matching `file.done` arrives. + let mut downloads: HashMap = HashMap::new(); + // Outbound commands are queued on each wakeup and flushed AFTER the + // select!, so `ws` is borrowed by only one future at a time. We do NOT + // use futures `.split()`: its BiLock dropped host->viewer replies + // (sys.res / file.*.res) that arrived right after a command was sent, + // which was the "process list / file transfer / block-input never reply" + // bug. This single-task read+write matches the (working) web client. + let mut outbox: std::collections::VecDeque = std::collections::VecDeque::new(); + // Viewer-measured frame-arrival jitter, reported to the host in net.pong so + // the adaptive QoS sees a real end-to-end delay, not just the RTT proxy. + let mut jitter = crate::netqos::Jitter::new(); + let net_start = std::time::Instant::now(); + loop { + tokio::select! { + cmd = rx.recv() => { + match cmd { + Some(m) => outbox.push_back(m), // don't touch ws inside select! + None => break, // Session dropped + } + } + inc = transport.recv() => { + match inc { + Some(Ok(TransportMsg::Binary(buf))) => { + let plain = match &cipher { + Some(c) => match crypto::decrypt_frame(c, &buf) { + Ok(p) => p, + Err(_) => continue, // drop undecryptable chunk + }, + None => buf, + }; + if plain.len() < 2 { continue; } + match plain[0] { + CHAN_VIDEO => { + // Record the arrival for the viewer's jitter estimate. + jitter.frame(net_start.elapsed().as_millis() as u64); + // flags: bit0 = keyframe, bits1-3 = codec id. + let flags = plain[1]; + let keyframe = flags & 1 != 0; + let codec = (flags >> 1) & 0x7; + // NAL count is an H.264-only debug metric. + let nals = if codec == 0 { + split_nals(&plain[2..]).len() + } else { + 0 + }; + let _ = ev.send(SessionEvent::Video { + keyframe, + codec, + au: plain[2..].to_vec(), // codec bitstream + nals, + }); + } + CHAN_AUDIO => { + // plain = [0xa1][opus packet]; hand the raw + // Opus frame to the UI for decode + playback. + if plain.len() > 1 { + let _ = ev.send(SessionEvent::Audio { + data: plain[1..].to_vec(), + }); + } + } + // Download data (host→viewer). The host only ever + // uses 0xf1 for this, but the web viewer treats a + // 0xf2 receive identically, so mirror that. Frame: + // [chan][fid: u32 LE][offset: u64 LE][data…]. + CHAN_FILE | CHAN_FILE_END => { + if plain.len() >= 13 { + let fid = u32::from_le_bytes( + [plain[1], plain[2], plain[3], plain[4]]); + let off = u64::from_le_bytes([ + plain[5], plain[6], plain[7], plain[8], + plain[9], plain[10], plain[11], plain[12], + ]); + let data = &plain[13..]; + let dl = downloads + .entry(fid) + .or_insert_with(|| FileDownload::new(fid)); + dl.write_chunk(off, data); + let _ = ev.send(SessionEvent::FileDownloadProgress { + fid, + received: dl.received, + }); + } + } + _ => {} + } + } + Some(Ok(TransportMsg::Text(txt))) => { + if let Ok(v) = serde_json::from_str::(&txt) { + match v.get("t").and_then(|t| t.as_str()).unwrap_or("") { + "session.end" => { + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("ended").to_string(); + let _ = ev.send(SessionEvent::Ended { reason }); + break; + } + "chat" => { + let from = v.get("from").and_then(|x| x.as_str()).unwrap_or("Host").to_string(); + let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::ChatMessage { from, text }); + } + "term.data" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let data = v.get("d").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::TermData { tid, data }); + } + "term.exit" => { + let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let _ = ev.send(SessionEvent::TermExit { tid }); + } + "clip.set" => { + let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let _ = ev.send(SessionEvent::Clipboard { text }); + } + "clip.image" => { + if let Some(b64) = v.get("png").and_then(|x| x.as_str()) { + use base64::Engine as _; + if let Ok(png) = base64::engine::general_purpose::STANDARD.decode(b64) { + let _ = ev.send(SessionEvent::ClipboardImage { png }); + } + } + } + "session.info" => { + // emit the host's `info` object (fall back to the whole message) + let info = v.get("info").cloned().unwrap_or(v); + if let Ok(json) = serde_json::to_string(&info) { + let _ = ev.send(SessionEvent::Info { json }); + } + } + "sys.res" => { + if let Ok(json) = serde_json::to_string(&v) { + let _ = ev.send(SessionEvent::SysResult { json }); + } + } + // ---- file transfer replies ---------------- + "file.roots.res" | "file.list.res" => { + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let mut entries = Vec::new(); + if let Some(items) = + v.get("items").and_then(|x| x.as_array()) + { + for it in items { + entries.push(FileEntry { + name: it.get("n").and_then(|x| x.as_str()) + .unwrap_or("").to_string(), + is_dir: it.get("t").and_then(|x| x.as_str()) + == Some("d"), + size: it.get("s").and_then(|x| x.as_u64()) + .unwrap_or(0), + mtime: it.get("m").and_then(|x| x.as_i64()) + .unwrap_or(0), + }); + } + } + let _ = ev.send(SessionEvent::FileList { path, entries }); + } + "file.walk.res" => { + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let truncated = v.get("truncated") + .and_then(|x| x.as_bool()).unwrap_or(false); + let mut files = Vec::new(); + if let Some(items) = v.get("items").and_then(|x| x.as_array()) { + for it in items { + let rel = it.get("rel").and_then(|x| x.as_str()) + .unwrap_or("").to_string(); + let size = it.get("s").and_then(|x| x.as_u64()) + .unwrap_or(0); + if !rel.is_empty() { + files.push((rel, size)); + } + } + } + let _ = ev.send(SessionEvent::FileWalk { path, files, truncated }); + } + "file.done" => { + let fid = v.get("fid").and_then(|x| x.as_u64()) + .unwrap_or(0) as u32; + let path = v.get("path").and_then(|x| x.as_str()) + .unwrap_or(""); + let size = v.get("size").and_then(|x| x.as_u64()) + .unwrap_or(0); + let part = match downloads.remove(&fid) { + Some(dl) => dl.finish(), + None => { + // zero-byte file, or no chunks seen: hand + // over an empty .part so the consumer still + // gets a real file. + let dl = FileDownload::new(fid); + let _ = std::fs::File::create(&dl.part); + dl.part + } + }; + let _ = ev.send(SessionEvent::FileDownloadDone { + fid, + name: basename(path), + size, + path: part, + }); + } + "file.ok" => { + let msg = v.get("msg").and_then(|x| x.as_str()) + .unwrap_or("ok").to_string(); + let refresh = v.get("refresh").and_then(|x| x.as_bool()) + .unwrap_or(false); + let _ = ev.send(SessionEvent::FileUploadAck { msg, refresh }); + } + "file.err" => { + let msg = v.get("msg").and_then(|x| x.as_str()) + .unwrap_or("error").to_string(); + let _ = ev.send(SessionEvent::FileError { msg }); + } + // ---- remote cursor overlay (RustDesk-style) ---- + "cursor" => { + let hidden = v.get("hidden").and_then(|x| x.as_bool()).unwrap_or(false); + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + let x = v.get("x").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let y = v.get("y").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let _ = ev.send(SessionEvent::Cursor { id, x, y, hidden }); + } + "cursor.shape" => { + let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); + let w = v.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let h = v.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let hx = v.get("hx").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let hy = v.get("hy").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + let css = v.get("css").and_then(|x| x.as_str()).map(|s| s.to_string()); + let png = v.get("png").and_then(|x| x.as_str()) + .and_then(|s| B64.decode(s).ok()) + .unwrap_or_default(); + let _ = ev.send(SessionEvent::CursorShape { id, png, w, h, hx, hy, css }); + } + // Synthetic signal injected by the P2P multiplexer (never + // from the peer): the active transport changed. + "p2p.state" => { + let active = v.get("active").and_then(|x| x.as_bool()).unwrap_or(false); + let direct = v.get("direct").and_then(|x| x.as_bool()).unwrap_or(false); + let _ = ev.send(SessionEvent::P2pState { active, direct }); + } + // Host RTT probe: echo it straight back (same sid + id) so + // the host can measure the round trip for adaptive QoS. + "net.ping" => { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or(""); + let id = v.get("id").cloned().unwrap_or(serde_json::Value::from(0)); + // Piggyback the viewer-measured frame jitter so the + // host's QoS gets a real delay signal, not just RTT. + outbox.push_back(Message::Text( + serde_json::json!({"t":"net.pong","sid":sid,"id":id,"jit":jitter.jitter()}).to_string())); + } + _ => {} + } + } + } + None => { + let _ = ev.send(SessionEvent::Ended { reason: "connection closed".into() }); + break; + } + Some(Err(_)) => { + let _ = ev.send(SessionEvent::Ended { reason: "socket error".into() }); + break; + } + } + } + } + // `ws` is free again (the select! futures are dropped). Flush every + // queued outbound command now, plus any that arrived meanwhile. + while let Some(m) = outbox.pop_front() { + let r = match m { + Message::Text(s) => transport.send_text(s).await, + Message::Binary(b) => transport.send_binary_lane(b, crate::transport::Lane::Ctl).await, // viewer binary is only file upload (reliable) + _ => Ok(()), + }; + if r.is_err() { + let _ = ev.send(SessionEvent::Ended { reason: "send failed".into() }); + return; + } + } + while let Ok(m) = rx.try_recv() { + let r = match m { + Message::Text(s) => transport.send_text(s).await, + Message::Binary(b) => transport.send_binary_lane(b, crate::transport::Lane::Ctl).await, // viewer binary is only file upload (reliable) + _ => Ok(()), + }; + if r.is_err() { + let _ = ev.send(SessionEvent::Ended { reason: "send failed".into() }); + return; + } + } + } + }); + + Ok(Session { + info, + out: tx, + sid, + monitors, + cur_mon, + next_fid: AtomicU32::new(1), + cipher: session_cipher, + }) +} diff --git a/native/core/src/transport.rs b/native/core/src/transport.rs new file mode 100644 index 0000000..7126e0a --- /dev/null +++ b/native/core/src/transport.rs @@ -0,0 +1,471 @@ +//! Pluggable transport for the session protocol. The E2E AES-GCM framing sits +//! ABOVE this layer on both the viewer and host, so a transport only ever moves +//! opaque frames (binary media/file chunks, or text control JSON). The current +//! WSS relay ([`WsRelay`]) and the P2P WebRTC DataChannel (added later) both +//! implement [`Transport`], so `connect_with` / `run_host_with` carry either and +//! the relay→P2P upgrade / downgrade can swap transports without touching the +//! protocol, crypto, or framing. + +use crate::error::{NdError, Result}; +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; + +use bytes::Bytes; +use webrtc::data_channel::data_channel_message::DataChannelMessage; +use webrtc::data_channel::RTCDataChannel; + +/// One message crossing a transport. Opaque here; the caller encrypts/decrypts +/// and dispatches by channel byte above this layer. +#[derive(Debug, Clone)] +pub enum TransportMsg { + Binary(Vec), + Text(String), +} + +/// Which logical stream a binary frame belongs to. The caller knows this from +/// the (pre-encryption) channel byte; the transport cannot see it because the +/// byte is inside the E2E ciphertext. It decides per-lane delivery: `Media` is +/// drop-tolerant (video — drop a fresh frame rather than queue it late), `Ctl` +/// must be reliable (file bytes, control), `Move` is drop-tolerant pointer +/// motion. Maps 1:1 to the web/Python `media` / `ctl` / `move` DataChannels; +/// the relay and the native single channel use it only for back-pressure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lane { + Media, + Ctl, + Move, +} + +/// A bidirectional message transport. Used from a single task (the viewer's +/// socket task / the host's `run_host` loop): `recv` is awaited in one +/// `tokio::select!` arm and `send_*` in other arms' bodies, so `&mut self` for +/// all three is sound (select drops the non-chosen futures first). +#[async_trait] +pub trait Transport: Send + 'static { + async fn send_binary(&mut self, data: Vec) -> Result<()>; + async fn send_text(&mut self, text: String) -> Result<()>; + /// Next inbound message, or `None` when the transport has closed. + async fn recv(&mut self) -> Option>; + + /// Send a binary frame tagged with its [`Lane`]. The default ignores the + /// lane (relay, native single channel today), so existing transports need + /// no change; the P2P multiplexer overrides it to pick the channel / + /// back-pressure policy. Callers that know the channel (video vs file) + /// should prefer this over [`Transport::send_binary`]. + async fn send_binary_lane(&mut self, data: Vec, lane: Lane) -> Result<()> { + let _ = lane; + self.send_binary(data).await + } +} + +/// The WSS relay transport — the current path, behaviourally identical to the +/// pre-seam code. Wraps the tungstenite WebSocket (TLS to the relay); the server +/// relays frames opaquely between the two session peers. +pub struct WsRelay { + ws: WebSocketStream>, +} + +impl WsRelay { + /// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping. + pub async fn connect(ws_url: &str) -> Result { + let (ws, _resp) = connect_async(ws_url) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + Ok(Self { ws }) + } + + /// Wrap an already-connected WebSocket (e.g. one opened during a handshake + /// elsewhere). + pub fn from_ws(ws: WebSocketStream>) -> Self { + Self { ws } + } +} + +#[async_trait] +impl Transport for WsRelay { + async fn send_binary(&mut self, data: Vec) -> Result<()> { + self.ws + .send(Message::Binary(data)) + .await + .map_err(|e| NdError::Connect(e.to_string())) + } + + async fn send_text(&mut self, text: String) -> Result<()> { + self.ws + .send(Message::Text(text)) + .await + .map_err(|e| NdError::Connect(e.to_string())) + } + + async fn recv(&mut self) -> Option> { + loop { + match self.ws.next().await { + Some(Ok(Message::Binary(b))) => return Some(Ok(TransportMsg::Binary(b))), + Some(Ok(Message::Text(t))) => return Some(Ok(TransportMsg::Text(t))), + Some(Ok(Message::Close(_))) | None => return None, + // Ping/Pong/Frame are handled by tungstenite; skip at this layer. + Some(Ok(_)) => continue, + Some(Err(e)) => return Some(Err(NdError::Connect(e.to_string()))), + } + } + } +} + +// ============================ P2P WebRTC DataChannel ============================ + +/// 9-byte fragment header prepended to every binary chunk: +/// `[frame_id:u32 BE][idx:u16 BE][count:u16 BE][flags:u8]`. `flags` bit0 is +/// reserved for a keyframe hint (set by a higher layer in a later phase); the +/// reassembler ignores it. +const FRAG_HEADER: usize = 9; +/// Each SCTP message stays at or below 16 KiB (header + payload) — safely under +/// the negotiated max-message-size, and small enough that one slow frame never +/// head-of-line-blocks the queue for long. +const MAX_CHUNK_PAYLOAD: usize = 16 * 1024 - FRAG_HEADER; +/// Pause sending once this much is queued in the SCTP send buffer... +const HIGH_WATER: usize = 1024 * 1024; +/// ...and resume when it drains to here (`on_buffered_amount_low` fires). +const LOW_WATER: usize = 512 * 1024; + +/// Reassembly accumulator for one multi-chunk binary frame. +struct FrameAsm { + count: u16, + got: u16, + parts: Vec>>, +} + +impl FrameAsm { + fn new(count: u16) -> Self { + Self { + count, + got: 0, + parts: vec![None; count as usize], + } + } + fn put(&mut self, idx: u16, payload: &[u8]) { + let i = idx as usize; + if i < self.parts.len() && self.parts[i].is_none() { + self.parts[i] = Some(payload.to_vec()); + self.got += 1; + } + } + fn complete(&self) -> bool { + self.got == self.count + } + /// Concatenate the chunks in index order. Because the chunks are contiguous + /// slices of the original nonce||ciphertext||tag blob, the rejoined bytes + /// are byte-identical — a mis-ordered or missing chunk would corrupt the + /// blob and the viewer AES-GCM decrypt_frame would fail the auth tag, which + /// is exactly the integrity guarantee we want. + fn concat(self) -> Vec { + let mut out = Vec::new(); + for p in self.parts.into_iter().flatten() { + out.extend_from_slice(&p); + } + out + } +} + +/// Install the inbound reassembly handler on `dc`: SCTP string messages become +/// `TransportMsg::Text`; fragmented binary chunks are rejoined by `frame_id` +/// and pushed as exactly one contiguous `TransportMsg::Binary` (so the E2E +/// `nonce||ct||tag` blob stays intact for AES-GCM). Shared by `WebRtcChannel` +/// (direct P2P) and the relay/P2P multiplexer so both frame the wire identically. +pub fn install_reassembler(dc: &Arc, out: mpsc::UnboundedSender) { + let reasm: Arc>> = Arc::new(Mutex::new(HashMap::new())); + dc.on_message(Box::new(move |msg: DataChannelMessage| { + let out = out.clone(); + let reasm = reasm.clone(); + Box::pin(async move { + if msg.is_string { + if let Ok(s) = String::from_utf8(msg.data.to_vec()) { + let _ = out.send(TransportMsg::Text(s)); + } + return; + } + let data = &msg.data; + if data.len() < FRAG_HEADER { + return; // malformed; drop + } + let frame_id = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); + let idx = u16::from_be_bytes([data[4], data[5]]); + let count = u16::from_be_bytes([data[6], data[7]]); + // data[8] = flags (bit0 = keyframe), reserved; unused for reassembly. + let payload = &data[FRAG_HEADER..]; + match count { + 0 => {} + 1 => { + let _ = out.send(TransportMsg::Binary(payload.to_vec())); + } + _ => { + let mut map = reasm.lock().unwrap(); + let entry = map.entry(frame_id).or_insert_with(|| FrameAsm::new(count)); + entry.put(idx, payload); + if entry.complete() { + if let Some(asm) = map.remove(&frame_id) { + drop(map); + let _ = out.send(TransportMsg::Binary(asm.concat())); + } + } + } + } + }) + })); +} + +/// Fragment `data` into `<=16 KiB` SCTP messages (9-byte header each) under one +/// `frame_id` and send them in order on `dc`. No back-pressure here — the caller +/// applies its own policy (wait vs drop-oldest) around this. Returns the error +/// from the first failed `dc.send`. +pub async fn send_fragmented(dc: &Arc, frame_id: u32, data: &[u8]) -> Result<()> { + let total = data.len(); + let count = ((total + MAX_CHUNK_PAYLOAD - 1) / MAX_CHUNK_PAYLOAD).max(1); + if count > u16::MAX as usize { + return Err(NdError::Connect(format!( + "frame too large to fragment: {total} bytes" + ))); + } + for idx in 0..count { + let start = idx * MAX_CHUNK_PAYLOAD; + let end = (start + MAX_CHUNK_PAYLOAD).min(total); + let chunk = &data[start..end]; + let mut framed = Vec::with_capacity(FRAG_HEADER + chunk.len()); + framed.extend_from_slice(&frame_id.to_be_bytes()); + framed.extend_from_slice(&(idx as u16).to_be_bytes()); + framed.extend_from_slice(&(count as u16).to_be_bytes()); + framed.push(0u8); // flags (bit0 = keyframe) reserved + framed.extend_from_slice(chunk); + dc.send(&Bytes::from(framed)) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + } + Ok(()) +} + +/// A [`Transport`] over a single reliable+ordered WebRTC DataChannel — the +/// native-to-native direct P2P fast path. The E2E AES-GCM framing is unchanged +/// above this layer; this channel only moves opaque binary frames (fragmented +/// to fit SCTP, reassembled whole so the GCM blob stays contiguous) and opaque +/// text control JSON (sent as SCTP string messages, so the peer tells the two +/// apart with `DataChannelMessage::is_string`). +pub struct WebRtcChannel { + dc: Arc, + inbound: mpsc::UnboundedReceiver, + /// Drains one token whenever the SCTP send buffer falls below `LOW_WATER`. + low_water_rx: mpsc::Receiver<()>, + /// Set true by `on_close`; makes `recv` return `None` once inbound is empty. + closed_flag: Arc, + closed_rx: mpsc::Receiver<()>, + next_frame_id: u32, +} + +impl WebRtcChannel { + /// Wrap an open DataChannel. Registers the inbound reassembly handler, the + /// back-pressure low-water signal, and the close signal. Call once the + /// channel is open (sends before open error at the SCTP layer). + pub async fn new(dc: Arc) -> Self { + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + + // --- inbound: reassemble binary frames, pass text straight through. + install_reassembler(&dc, in_tx); + + // --- back-pressure: a size-1 mpsc is race-free vs Notify here. The + // low-water event only fires when the buffer crosses DOWN through the + // threshold, so a token can be pending at most once; try_send drops + // extras. + let (low_tx, low_rx) = mpsc::channel::<()>(1); + dc.set_buffered_amount_low_threshold(LOW_WATER).await; + dc.on_buffered_amount_low(Box::new(move || { + let low_tx = low_tx.clone(); + Box::pin(async move { + let _ = low_tx.try_send(()); + }) + })) + .await; + + // --- close signal so recv() can return None on a dropped channel. + let closed_flag = Arc::new(AtomicBool::new(false)); + let (closed_tx, closed_rx) = mpsc::channel::<()>(1); + let cf = closed_flag.clone(); + dc.on_close(Box::new(move || { + let cf = cf.clone(); + let closed_tx = closed_tx.clone(); + Box::pin(async move { + cf.store(true, Ordering::SeqCst); + let _ = closed_tx.try_send(()); + }) + })); + + Self { + dc, + inbound: in_rx, + low_water_rx: low_rx, + closed_flag, + closed_rx, + next_frame_id: 0, + } + } + + /// The underlying channel, for stats / state inspection (upgrade logic). + pub fn data_channel(&self) -> &Arc { + &self.dc + } +} + +#[async_trait] +impl Transport for WebRtcChannel { + async fn send_binary(&mut self, data: Vec) -> Result<()> { + // Pause if the SCTP send queue is already deep: wait for it to drain + // below LOW_WATER (fresh frames never pile up unbounded in RAM). + while self.dc.buffered_amount().await + FRAG_HEADER + data.len() > HIGH_WATER { + if self.low_water_rx.recv().await.is_none() { + break; + } + } + let frame_id = self.next_frame_id; + self.next_frame_id = self.next_frame_id.wrapping_add(1); + send_fragmented(&self.dc, frame_id, &data).await + } + + async fn send_text(&mut self, text: String) -> Result<()> { + self.dc + .send_text(text) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + Ok(()) + } + + async fn recv(&mut self) -> Option> { + if self.closed_flag.load(Ordering::SeqCst) { + // Drain anything already reassembled, then report closed. + return self.inbound.try_recv().ok().map(Ok); + } + tokio::select! { + biased; + msg = self.inbound.recv() => msg.map(Ok), + _ = self.closed_rx.recv() => self.inbound.try_recv().ok().map(Ok), + } + } +} + +/// Direct-IP transport — a raw TCP socket instead of the relay. The viewer dials +/// the host's `IP:port` and the session runs the SAME challenge/auth + E2E +/// AES-GCM above this layer (so direct-IP is encrypted, unlike RustDesk's). +/// Framing is `[type:u8][len:u32 BE][payload]` (type 0 = binary, 1 = text). A +/// dedicated reader task parses frames into a channel, so `recv` is +/// cancellation-safe in the caller's `select!` (a bare `read_exact` is not). +pub struct DirectTcp { + writer: tokio::net::tcp::OwnedWriteHalf, + rx: mpsc::UnboundedReceiver>, +} + +/// Cap on a single framed message (generous for a 4K keyframe). +const DIRECT_MAX_FRAME: usize = 64 * 1024 * 1024; + +impl DirectTcp { + /// Wrap an accepted / dialed TCP stream. + pub fn new(stream: TcpStream) -> Self { + let _ = stream.set_nodelay(true); + let (mut read, write) = stream.into_split(); + let (tx, rx) = mpsc::unbounded_channel(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut hdr = [0u8; 5]; + loop { + if read.read_exact(&mut hdr).await.is_err() { + break; + } + let ty = hdr[0]; + let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize; + if len > DIRECT_MAX_FRAME { + break; + } + let mut buf = vec![0u8; len]; + if read.read_exact(&mut buf).await.is_err() { + break; + } + let msg = if ty == 1 { + TransportMsg::Text(String::from_utf8_lossy(&buf).into_owned()) + } else { + TransportMsg::Binary(buf) + }; + if tx.send(Ok(msg)).is_err() { + break; + } + } + }); + DirectTcp { writer: write, rx } + } + + async fn write_frame(&mut self, ty: u8, payload: &[u8]) -> Result<()> { + use tokio::io::AsyncWriteExt; + let mut hdr = [0u8; 5]; + hdr[0] = ty; + hdr[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes()); + self.writer + .write_all(&hdr) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + self.writer + .write_all(payload) + .await + .map_err(|e| NdError::Connect(e.to_string()))?; + Ok(()) + } +} + +#[async_trait] +impl Transport for DirectTcp { + async fn send_binary(&mut self, data: Vec) -> Result<()> { + self.write_frame(0, &data).await + } + async fn send_text(&mut self, text: String) -> Result<()> { + self.write_frame(1, text.as_bytes()).await + } + async fn recv(&mut self) -> Option> { + self.rx.recv().await + } +} + +#[cfg(test)] +mod direct_tests { + use super::*; + + // DirectTcp framing must round-trip text + binary, in order, over a real + // loopback socket (the foundation of encrypted direct-IP access). + #[tokio::test] + async fn direct_tcp_roundtrips_text_and_binary_in_order() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let srv = tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let mut t = DirectTcp::new(s); + for _ in 0..3 { + match t.recv().await.unwrap().unwrap() { + TransportMsg::Text(s) => t.send_text(format!("echo:{s}")).await.unwrap(), + TransportMsg::Binary(b) => { + let mut r = b.clone(); + r.reverse(); + t.send_binary(r).await.unwrap(); + } + } + } + }); + let cs = tokio::net::TcpStream::connect(addr).await.unwrap(); + let mut c = DirectTcp::new(cs); + c.send_text("hello".into()).await.unwrap(); + c.send_binary(vec![1, 2, 3, 4]).await.unwrap(); + c.send_text("world".into()).await.unwrap(); + assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:hello")); + assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Binary(b) if b == vec![4, 3, 2, 1])); + assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:world")); + srv.await.unwrap(); + } +} diff --git a/native/core/src/update.rs b/native/core/src/update.rs new file mode 100644 index 0000000..9a0a045 --- /dev/null +++ b/native/core/src/update.rs @@ -0,0 +1,133 @@ +//! Self-update support: a tiny release manifest the client fetches to learn the +//! latest version, where to download it, and its SHA-256 — so an update is only +//! ever applied after the bytes are verified. RustDesk-style sha256-checked +//! auto-update, distilled to pure, testable helpers. +//! +//! The server publishes `version.json` alongside the installer (served by the +//! existing `/download/` static route — no server code change needed): +//! ```json +//! { "version": "0.2.0", +//! "url": "https://netdesk.space/download/NetDesk-Setup.exe", +//! "sha256": "", "notes": "what changed" } +//! ``` + +use crate::error::{NdError, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// A parsed release manifest. +#[derive(Debug, Clone, Deserialize)] +pub struct Manifest { + pub version: String, + pub url: String, + #[serde(default)] + pub sha256: String, + #[serde(default)] + pub notes: String, +} + +/// Parse the `version.json` manifest. +pub fn parse_manifest(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| NdError::Other(anyhow::anyhow!("manifest: {e}"))) +} + +/// Parse a dotted `x.y.z` version into a comparable tuple. Extra components are +/// ignored; a missing/garbage component is treated as 0 (so a malformed version +/// never reads as "newer" and triggers a spurious update). +fn parse_ver(v: &str) -> (u64, u64, u64) { + let mut it = v + .trim() + .trim_start_matches('v') + .split(|c| c == '.' || c == '-' || c == '+') + .map(|p| p.parse::().unwrap_or(0)); + ( + it.next().unwrap_or(0), + it.next().unwrap_or(0), + it.next().unwrap_or(0), + ) +} + +/// True when `latest` is strictly newer than `current` (semantic x.y.z compare). +/// Equal or older → false, so re-checking never re-triggers. +pub fn is_newer(current: &str, latest: &str) -> bool { + parse_ver(latest) > parse_ver(current) +} + +/// Verify a downloaded artifact against the manifest's SHA-256 (hex, any case). +/// An empty expected hash fails closed (we never install unverified bytes). +pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> bool { + if expected_hex.trim().is_empty() { + return false; + } + let mut h = Sha256::new(); + h.update(bytes); + let got = hex::encode(h.finalize()); + // length-independent constant-time-ish compare on the lowercased hex + let exp = expected_hex.trim().to_lowercase(); + got.len() == exp.len() + && got + .bytes() + .zip(exp.bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +/// Decide whether to update given the running version + a fetched manifest. +/// Returns Some(manifest) only when the manifest names a strictly newer version +/// AND carries a sha256 (we refuse to "update" to an unverifiable artifact). +pub fn update_available(current: &str, manifest: &Manifest) -> bool { + !manifest.sha256.trim().is_empty() && is_newer(current, &manifest.version) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compare() { + assert!(is_newer("0.1.0", "0.2.0")); + assert!(is_newer("0.1.0", "0.1.1")); + assert!(is_newer("1.9.9", "2.0.0")); + assert!(!is_newer("0.2.0", "0.2.0")); // equal + assert!(!is_newer("0.2.0", "0.1.9")); // older + assert!(!is_newer("0.2.0", "garbage")); // malformed -> not newer + assert!(is_newer("v0.1.0", "v0.1.1")); // tolerate a 'v' prefix + } + + #[test] + fn sha256_verify_roundtrip() { + let data = b"NetDesk-Setup.exe bytes"; + let mut h = Sha256::new(); + h.update(data); + let good = hex::encode(h.finalize()); + assert!(verify_sha256(data, &good)); + assert!(verify_sha256(data, &good.to_uppercase())); // case-insensitive + assert!(!verify_sha256(data, "")); // empty fails closed + assert!(!verify_sha256(data, &"0".repeat(64))); // wrong hash + assert!(!verify_sha256(b"tampered", &good)); // tampered bytes + } + + #[test] + fn update_available_needs_newer_and_hash() { + let m = |ver: &str, sha: &str| Manifest { + version: ver.into(), + url: "https://x/NetDesk-Setup.exe".into(), + sha256: sha.into(), + notes: String::new(), + }; + assert!(update_available("0.1.0", &m("0.2.0", "abcd"))); + assert!(!update_available("0.1.0", &m("0.2.0", "")), "no hash -> refuse"); + assert!(!update_available("0.2.0", &m("0.2.0", "abcd")), "not newer"); + } + + #[test] + fn manifest_parses() { + let m = parse_manifest( + r#"{"version":"0.3.0","url":"https://netdesk.space/download/NetDesk-Setup.exe","sha256":"deadbeef","notes":"x"}"#, + ) + .unwrap(); + assert_eq!(m.version, "0.3.0"); + assert!(m.url.ends_with("NetDesk-Setup.exe")); + assert_eq!(m.sha256, "deadbeef"); + } +} diff --git a/native/core/src/webm.rs b/native/core/src/webm.rs new file mode 100644 index 0000000..6d4c727 --- /dev/null +++ b/native/core/src/webm.rs @@ -0,0 +1,420 @@ +//! Pure-Rust WebM (Matroska/EBML) muxer for session recording — remuxes the +//! received VP9 video frames + Opus audio packets into a playable `.webm` with +//! **no re-encode** (just like `recorder::Mp4Recorder` does for H.264 → MP4). +//! +//! Why WebM and not MP4: our audio is Opus and the `mp4` crate is AAC-only (and +//! we have no AAC encoder), so Opus can't ride an MP4 track. VP9 + Opus are both +//! WebM-native, so a WebM container tees both received streams with zero +//! transcoding. This is a hand-rolled EBML writer (no C++ libwebm / no mingw +//! dependency): an EBML header, a streaming Segment (unknown size), Info, +//! Tracks (V_VP9 + optional A_OPUS), then live Clusters of SimpleBlocks. +//! +//! Developed by Akronlogic. +#![allow(clippy::unusual_byte_groupings)] + +use crate::error::{NdError, Result}; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +fn other(msg: impl std::fmt::Display) -> NdError { + NdError::Other(anyhow::anyhow!("webm: {msg}")) +} + +// ---- EBML element IDs (written as their literal bytes; the ID already encodes +// its own length in the leading bits) ---- +const ID_EBML: &[u8] = &[0x1A, 0x45, 0xDF, 0xA3]; +const ID_EBMLVERSION: &[u8] = &[0x42, 0x86]; +const ID_EBMLREADVERSION: &[u8] = &[0x42, 0xF7]; +const ID_EBMLMAXIDLENGTH: &[u8] = &[0x42, 0xF2]; +const ID_EBMLMAXSIZELENGTH: &[u8] = &[0x42, 0xF3]; +const ID_DOCTYPE: &[u8] = &[0x42, 0x82]; +const ID_DOCTYPEVERSION: &[u8] = &[0x42, 0x87]; +const ID_DOCTYPEREADVERSION: &[u8] = &[0x42, 0x85]; +const ID_SEGMENT: &[u8] = &[0x18, 0x53, 0x80, 0x67]; +const ID_INFO: &[u8] = &[0x15, 0x49, 0xA9, 0x66]; +const ID_TIMECODESCALE: &[u8] = &[0x2A, 0xD7, 0xB1]; +const ID_MUXINGAPP: &[u8] = &[0x4D, 0x80]; +const ID_WRITINGAPP: &[u8] = &[0x57, 0x41]; +const ID_TRACKS: &[u8] = &[0x16, 0x54, 0xAE, 0x6B]; +const ID_TRACKENTRY: &[u8] = &[0xAE]; +const ID_TRACKNUMBER: &[u8] = &[0xD7]; +const ID_TRACKUID: &[u8] = &[0x73, 0xC5]; +const ID_TRACKTYPE: &[u8] = &[0x83]; +const ID_CODECID: &[u8] = &[0x86]; +const ID_CODECPRIVATE: &[u8] = &[0x63, 0xA2]; +const ID_VIDEO: &[u8] = &[0xE0]; +const ID_PIXELWIDTH: &[u8] = &[0xB0]; +const ID_PIXELHEIGHT: &[u8] = &[0xBA]; +const ID_AUDIO: &[u8] = &[0xE1]; +const ID_SAMPLINGFREQ: &[u8] = &[0xB5]; +const ID_CHANNELS: &[u8] = &[0x9F]; +const ID_CLUSTER: &[u8] = &[0x1F, 0x43, 0xB6, 0x75]; +const ID_TIMECODE: &[u8] = &[0xE7]; +const ID_SIMPLEBLOCK: &[u8] = &[0xA3]; + +/// EBML variable-length integer encoding `value` as a size/number field. +fn vint(value: u64) -> Vec { + for n in 1..=8u32 { + if value < (1u64 << (7 * n)) - 1 { + let v = value | (1u64 << (7 * n)); + return v.to_be_bytes()[(8 - n as usize)..].to_vec(); + } + } + let v = (value & ((1u64 << 56) - 1)) | (1u64 << 56); + v.to_be_bytes()[1..].to_vec() +} + +/// Minimal big-endian unsigned (EBML uint element body). +fn uint_be(v: u64) -> Vec { + if v == 0 { + return vec![0u8]; + } + let b = v.to_be_bytes(); + let first = b.iter().position(|&x| x != 0).unwrap(); + b[first..].to_vec() +} + +fn elem(id: &[u8], data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(id.len() + 8 + data.len()); + out.extend_from_slice(id); + out.extend_from_slice(&vint(data.len() as u64)); + out.extend_from_slice(data); + out +} + +fn elem_uint(id: &[u8], v: u64) -> Vec { + elem(id, &uint_be(v)) +} + +fn ebml_header() -> Vec { + let mut h = Vec::new(); + h.extend(elem_uint(ID_EBMLVERSION, 1)); + h.extend(elem_uint(ID_EBMLREADVERSION, 1)); + h.extend(elem_uint(ID_EBMLMAXIDLENGTH, 4)); + h.extend(elem_uint(ID_EBMLMAXSIZELENGTH, 8)); + h.extend(elem(ID_DOCTYPE, b"webm")); + h.extend(elem_uint(ID_DOCTYPEVERSION, 2)); + h.extend(elem_uint(ID_DOCTYPEREADVERSION, 2)); + elem(ID_EBML, &h) +} + +fn info() -> Vec { + let mut i = Vec::new(); + i.extend(elem_uint(ID_TIMECODESCALE, 1_000_000)); // 1 tick = 1 ms + i.extend(elem(ID_MUXINGAPP, b"NetDesk")); + i.extend(elem(ID_WRITINGAPP, b"NetDesk")); + elem(ID_INFO, &i) +} + +fn opus_head(channels: u8) -> Vec { + let mut h = Vec::new(); + h.extend_from_slice(b"OpusHead"); + h.push(1); // version + h.push(channels.max(1)); + h.extend_from_slice(&3840u16.to_le_bytes()); // pre-skip + h.extend_from_slice(&48000u32.to_le_bytes()); // input sample rate + h.extend_from_slice(&0i16.to_le_bytes()); // output gain + h.push(0); // channel-mapping family 0 + h +} + +fn tracks(width: u16, height: u16, channels: u8) -> Vec { + let mut v = Vec::new(); + v.extend(elem_uint(ID_TRACKNUMBER, 1)); + v.extend(elem_uint(ID_TRACKUID, 1)); + v.extend(elem_uint(ID_TRACKTYPE, 1)); // video + v.extend(elem(ID_CODECID, b"V_VP9")); + let mut vid = Vec::new(); + vid.extend(elem_uint(ID_PIXELWIDTH, width as u64)); + vid.extend(elem_uint(ID_PIXELHEIGHT, height as u64)); + v.extend(elem(ID_VIDEO, &vid)); + let mut entries = elem(ID_TRACKENTRY, &v); + + if channels > 0 { + let mut a = Vec::new(); + a.extend(elem_uint(ID_TRACKNUMBER, 2)); + a.extend(elem_uint(ID_TRACKUID, 2)); + a.extend(elem_uint(ID_TRACKTYPE, 2)); // audio + a.extend(elem(ID_CODECID, b"A_OPUS")); + a.extend(elem(ID_CODECPRIVATE, &opus_head(channels))); + let mut aud = Vec::new(); + aud.extend(elem(ID_SAMPLINGFREQ, &48000.0f64.to_be_bytes())); + aud.extend(elem_uint(ID_CHANNELS, channels as u64)); + a.extend(elem(ID_AUDIO, &aud)); + entries.extend(elem(ID_TRACKENTRY, &a)); + } + elem(ID_TRACKS, &entries) +} + +fn simpleblock(track: u64, rel_tc: i16, keyframe: bool, frame: &[u8]) -> Vec { + let mut d = Vec::with_capacity(4 + frame.len()); + d.extend_from_slice(&vint(track)); // track number (EBML vint, 1-based) + d.extend_from_slice(&rel_tc.to_be_bytes()); // signed 16-bit relative timecode + d.push(if keyframe { 0x80 } else { 0x00 }); // flags: 0x80 = keyframe + d.extend_from_slice(frame); + elem(ID_SIMPLEBLOCK, &d) +} + +/// Muxes received VP9 video (+ optional Opus audio) into a `.webm`. No decode / +/// re-encode: the frames are stored exactly as they arrive from the session. +pub struct WebmRecorder { + path: PathBuf, + width: u16, + height: u16, + channels: u8, // 0 = video-only (no audio track) + writer: Option>, + start_ms: Option, + cluster_base: u64, + cluster: Vec, + open_cluster: bool, + frames: u64, +} + +impl WebmRecorder { + /// `channels` 0 records video only; >0 adds an Opus audio track. + pub fn new(path: impl AsRef, width: u16, height: u16, channels: u8) -> Self { + Self { + path: path.as_ref().to_path_buf(), + width: width.max(2), + height: height.max(2), + channels, + writer: None, + start_ms: None, + cluster_base: 0, + cluster: Vec::new(), + open_cluster: false, + frames: 0, + } + } + + fn ensure_open(&mut self) -> Result<()> { + if self.writer.is_some() { + return Ok(()); + } + let mut w = BufWriter::new(File::create(&self.path).map_err(other)?); + w.write_all(&ebml_header()).map_err(other)?; + // Streaming Segment: unknown size (all data bits set in an 8-octet vint). + w.write_all(ID_SEGMENT).map_err(other)?; + w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).map_err(other)?; + w.write_all(&info()).map_err(other)?; + w.write_all(&tracks(self.width, self.height, self.channels)).map_err(other)?; + self.writer = Some(w); + Ok(()) + } + + fn flush_cluster(&mut self) -> Result<()> { + if self.open_cluster && !self.cluster.is_empty() { + if let Some(w) = self.writer.as_mut() { + let mut body = Vec::with_capacity(self.cluster.len() + 8); + body.extend(elem_uint(ID_TIMECODE, self.cluster_base)); + body.extend_from_slice(&self.cluster); + w.write_all(ID_CLUSTER).map_err(other)?; + w.write_all(&vint(body.len() as u64)).map_err(other)?; + w.write_all(&body).map_err(other)?; + } + } + self.cluster.clear(); + self.open_cluster = false; + Ok(()) + } + + fn add_block(&mut self, track: u64, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> { + let start = *self.start_ms.get_or_insert(ts_ms); + let rel_total = ts_ms.saturating_sub(start); + // New cluster on a video keyframe (so seeking works) or before the + // relative timecode could overflow the signed-16-bit SimpleBlock field. + if !self.open_cluster + || (track == 1 && keyframe) + || rel_total.saturating_sub(self.cluster_base) > 30_000 + { + self.flush_cluster()?; + self.cluster_base = rel_total; + self.open_cluster = true; + } + let rel = rel_total.saturating_sub(self.cluster_base).min(32_767) as i16; + self.cluster.extend(simpleblock(track, rel, keyframe, data)); + self.frames += 1; + Ok(()) + } + + /// Feed one VP9 frame. The file opens on the first keyframe. + pub fn push_video(&mut self, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> { + if self.writer.is_none() { + if !keyframe { + return Ok(()); // wait for the first keyframe to start the file + } + self.ensure_open()?; + } + if data.is_empty() { + return Ok(()); + } + self.add_block(1, data, keyframe, ts_ms) + } + + /// Feed one Opus packet (ignored until the video track + file exist, and + /// when the recorder was created video-only). + pub fn push_audio(&mut self, data: &[u8], ts_ms: u64) -> Result<()> { + if self.writer.is_none() || self.channels == 0 || data.is_empty() { + return Ok(()); + } + self.add_block(2, data, true, ts_ms) // every Opus frame is independent + } + + /// Flush the final cluster and close the file. Returns (path, block count). + pub fn finish(mut self) -> Result<(PathBuf, u64)> { + self.flush_cluster()?; + if let Some(mut w) = self.writer.take() { + w.flush().map_err(other)?; + } + Ok((self.path.clone(), self.frames)) + } + + pub fn frames(&self) -> u64 { + self.frames + } +} + +// ---- verification: a minimal EBML walk that re-reads a muxed file ---- +fn find_sub(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +fn read_id(d: &[u8], p: &mut usize) -> Option> { + let b0 = *d.get(*p)?; + let n = (0..4).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?; + if *p + n > d.len() { + return None; + } + let id = d[*p..*p + n].to_vec(); + *p += n; + Some(id) +} + +fn read_size(d: &[u8], p: &mut usize) -> Option { + let b0 = *d.get(*p)?; + let n = (0..8).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?; + if *p + n > d.len() { + return None; + } + let mut v = (b0 & (0xFF >> n)) as u64; + for i in 1..n { + v = (v << 8) | d[*p + i] as u64; + } + *p += n; + Some(v) +} + +/// Re-read a `.webm` and confirm its structure: returns +/// (is_webm_doctype, has_vp9, has_opus, simpleblock_count). Walks the top-level +/// Segment children and descends into each Cluster to count SimpleBlocks for +/// real (no byte-scan false positives for the block count). +pub fn probe_webm(path: impl AsRef) -> Result<(bool, bool, bool, u64)> { + let data = std::fs::read(path.as_ref()).map_err(other)?; + if data.len() < 4 || &data[0..4] != ID_EBML { + return Err(other("missing EBML magic")); + } + let webm = find_sub(&data, b"webm"); + let mut p = 0usize; + // EBML header element (skip). + let _ = read_id(&data, &mut p).ok_or_else(|| other("no EBML id"))?; + let hsz = read_size(&data, &mut p).ok_or_else(|| other("no EBML size"))? as usize; + p += hsz; + // Segment (unknown/huge size) — walk its children to EOF. + let _seg = read_id(&data, &mut p).ok_or_else(|| other("no Segment id"))?; + let _ = read_size(&data, &mut p).ok_or_else(|| other("no Segment size"))?; + let (mut vp9, mut opus, mut blocks) = (false, false, 0u64); + while p < data.len() { + let id = match read_id(&data, &mut p) { + Some(i) => i, + None => break, + }; + let sz = match read_size(&data, &mut p) { + Some(s) => s as usize, + None => break, + }; + let end = (p + sz).min(data.len()); + if id == ID_CLUSTER { + let mut q = p; + while q < end { + let cid = match read_id(&data, &mut q) { + Some(i) => i, + None => break, + }; + let csz = match read_size(&data, &mut q) { + Some(s) => s as usize, + None => break, + }; + if cid == ID_SIMPLEBLOCK { + blocks += 1; + } + q += csz; + } + } else if id == ID_TRACKS { + let seg = &data[p..end]; + vp9 = find_sub(seg, b"V_VP9"); + opus = find_sub(seg, b"A_OPUS"); + } + p = end; + } + Ok((webm, vp9, opus, blocks)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mux_vp9_opus_webm_roundtrips() { + // Codec-agnostic remux: the payload bytes are arbitrary (the muxer does + // not parse VP9/Opus — the live session supplies real frames). We prove + // the CONTAINER: a valid WebM with a V_VP9 + A_OPUS track and the fed + // frames stored as SimpleBlocks, re-read by probe_webm. + let dir = std::env::temp_dir(); + let path = dir.join("netdesk_webm_mux_test.webm"); + let _ = std::fs::remove_file(&path); + + let mut rec = WebmRecorder::new(&path, 320, 240, 2); + // audio before the first keyframe is dropped (file not open yet) + rec.push_audio(&[0xAA; 40], 0).unwrap(); + // first keyframe opens the file + rec.push_video(&[0x11; 300], true, 0).unwrap(); + rec.push_audio(&[0xBB; 40], 0).unwrap(); + rec.push_video(&[0x22; 120], false, 33).unwrap(); + rec.push_audio(&[0xCC; 40], 20).unwrap(); + rec.push_video(&[0x33; 110], false, 66).unwrap(); + rec.push_video(&[0x44; 280], true, 100).unwrap(); // 2nd keyframe => new cluster + rec.push_audio(&[0xDD; 40], 100).unwrap(); + let (out, frames) = rec.finish().unwrap(); + + assert!(out.exists(), "webm file not written"); + let sz = std::fs::metadata(&out).unwrap().len(); + let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap(); + println!( + "WEBM_OK file={} bytes={} frames={} vp9={} opus={} simpleblocks={}", + out.display(), sz, frames, has_vp9, has_opus, blocks + ); + assert!(is_webm, "DocType is not webm"); + assert!(has_vp9, "no V_VP9 track"); + assert!(has_opus, "no A_OPUS track"); + // 4 video + 4 audio pushed, but the 1st audio (pre-keyframe) was dropped. + assert_eq!(blocks, 7, "expected 7 SimpleBlocks, got {blocks}"); + assert_eq!(frames, 7, "recorder frame count"); + let _ = std::fs::remove_file(&out); + } + + #[test] + fn video_only_webm_has_no_audio_track() { + let path = std::env::temp_dir().join("netdesk_webm_vid_only.webm"); + let _ = std::fs::remove_file(&path); + let mut rec = WebmRecorder::new(&path, 64, 64, 0); + rec.push_audio(&[0x01; 20], 0).unwrap(); // ignored (video-only) + rec.push_video(&[0x11; 50], true, 0).unwrap(); + rec.push_video(&[0x22; 40], false, 33).unwrap(); + let (out, frames) = rec.finish().unwrap(); + let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap(); + assert!(is_webm && has_vp9 && !has_opus, "video-only must have VP9 and no Opus"); + assert_eq!((frames, blocks), (2, 2)); + let _ = std::fs::remove_file(&out); + } +} diff --git a/native/core/tests/host_viewer.rs b/native/core/tests/host_viewer.rs new file mode 100644 index 0000000..742ca68 --- /dev/null +++ b/native/core/tests/host_viewer.rs @@ -0,0 +1,149 @@ +//! End-to-end: the core HOST (host.rs) serves a session that the core VIEWER +//! (signaling.rs connect) authenticates to, over the LIVE server. Proves the +//! Android host's protocol (register -> challenge -> auth/E2E -> accept) and +//! input routing interoperate before any Kotlin is wired. +//! +//! Network test -> #[ignore] by default. Run explicitly: +//! cargo test -p netdesk-core --target x86_64-pc-windows-gnu --test host_viewer -- --ignored --nocapture + +use netdesk_core::crypto::{gen_host_id, new_host_credentials}; +use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame}; +use netdesk_core::{connect, ConnectConfig, SessionEvent}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; + +async fn wait_host bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +async fn wait_view bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +#[tokio::test] +#[ignore] +async fn host_viewer_handshake_and_input() { + let ws = "wss://netdesk.space/ws"; + let id = gen_host_id(); + let password = "e2e-host-test-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: ws.into(), + host_id: id.clone(), + name: "TestAndroidHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1080, + height: 1920, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + // Outbound control channel (unused by this test; drop the sender). + let (_hctl_tx, hctl_rx) = tokio::sync::mpsc::unbounded_channel(); + let host_task = + tokio::spawn(async move { run_host(hcfg, frame_rx, hev_tx, hctl_rx).await }); + + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Registered), 10) + .await + .is_some(), + "host did not register" + ); + // let the registration settle on the server before the viewer dials in + tokio::time::sleep(Duration::from_millis(600)).await; + + // viewer connects to our host id + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: ws.into(), + host_id: id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect(vcfg, vtx).await.expect("viewer connect+handshake"); + + // host sees the viewer; viewer sees Connected with our advertised size + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10) + .await + .is_some(), + "host never saw ViewerConnected" + ); + let connected = wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10).await; + match connected { + Some(SessionEvent::Connected { w, h, e2e, .. }) => { + assert_eq!((w, h), (1080, 1920), "advertised resolution"); + assert!(e2e, "E2E negotiated"); + } + _ => panic!("viewer never got Connected"), + } + + // viewer input must surface on the host side with matching coordinates + session.mouse(123, 456, 1, 0.0, 0.0); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await { + Some(HostEvent::Mouse { x, y, buttons, .. }) => { + assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed to host"); + } + _ => panic!("host never received the viewer's mouse"), + } + + session.key("KeyA", true); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await { + Some(HostEvent::Key { code, down }) => { + assert_eq!((code.as_str(), down), ("KeyA", true), "key routed to host"); + } + _ => panic!("host never received the viewer's key"), + } + + // a frame fed to the host reaches the viewer as a binary message (we only + // assert the host accepts + seals it; decode is covered elsewhere). + let _ = frame_tx.send(HostFrame { + data: vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28], + keyframe: true, + codec: 0, + }); + tokio::time::sleep(Duration::from_millis(300)).await; + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(3), host_task).await; + println!("HOST_VIEWER_E2E_OK id={id}"); +} diff --git a/native/core/tests/p2p_loopback.rs b/native/core/tests/p2p_loopback.rs new file mode 100644 index 0000000..3dbb470 --- /dev/null +++ b/native/core/tests/p2p_loopback.rs @@ -0,0 +1,1723 @@ +//! Phase 2 of the direct-P2P build (see P2P-PLAN.md): prove a raw WebRTC +//! DataChannel forms and round-trips between two `RTCPeerConnection`s *in one +//! process*, with no signaling server and no NAT — pure 127.0.0.1 / host-LAN +//! ICE candidates. +//! +//! The in-process signaling shim is two pairs of `tokio::sync::mpsc` channels: +//! one carries each side's trickled ICE candidates to the other peer, and the +//! SDP offer/answer is handed across directly in the test body (only two +//! messages, strictly ordered). This exercises the exact `on_ice_candidate` → +//! `to_json()` → `add_ice_candidate` path that the Phase 4 native<->native +//! trickle upgrade uses over the real relay. Early remote candidates are +//! "buffered" simply by the unbounded channel — we only start draining a +//! peer's inbound candidate queue AFTER its remote description is set, so +//! `add_ice_candidate` never races ahead of `set_remote_description`. +//! +//! webrtc spins up its own tokio tasks, so these tests MUST run on a +//! multi-thread runtime (`flavor = "multi_thread"`), not the default +//! current-thread `#[tokio::test]`. +//! +//! Run: cargo test -p netdesk-core --target x86_64-pc-windows-gnu \ +//! --test p2p_loopback -- --nocapture + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::sync::mpsc; +use tokio::time::timeout; + +use async_trait::async_trait; +use std::sync::atomic::{AtomicBool, Ordering}; +use netdesk_core::crypto::{gen_host_id, new_host_credentials}; +use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame}; +use netdesk_core::{upgrade_relay, NdError, P2pControl, P2pRole, Session, Transport, TransportMsg}; +use netdesk_core::rtc::{ + answer_nontrickle, answer_offer, apply_answer, build_api, build_api_web, create_web_channels, + new_pc, offer_nontrickle, rtc_config, upgrade_offerer, RtcIceServerJson, +}; +use std::collections::HashMap; +use std::sync::Mutex as StdMutex; +use serde_json::Value; +use netdesk_core::signaling::connect_with; +use netdesk_core::transport::WebRtcChannel; +use netdesk_core::{ConnectConfig, SessionEvent}; + +use webrtc::data_channel::data_channel_init::RTCDataChannelInit; +use webrtc::data_channel::data_channel_message::DataChannelMessage; +use webrtc::data_channel::RTCDataChannel; +use webrtc::ice_transport::ice_candidate::{RTCIceCandidate, RTCIceCandidateInit}; +use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; +use webrtc::peer_connection::RTCPeerConnection; + +/// Drain one peer's inbound trickled-candidate queue into the peer connection. +/// Spawned only after that peer's remote description is set, so every +/// `add_ice_candidate` is valid; the unbounded channel is the "early candidate" +/// buffer. +async fn drain_candidates( + pc: Arc, + mut rx: mpsc::UnboundedReceiver, +) { + while let Some(cand) = rx.recv().await { + if let Err(e) = pc.add_ice_candidate(cand).await { + eprintln!("add_ice_candidate: {e}"); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn datachannel_forms_and_roundtrips() { + // No ICE servers: on loopback the agents gather host candidates (127.0.0.1 + // + the machine's LAN addresses) and connect directly, server-free. + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // --- signaling shim: candidate wires (offerer->answerer, answerer->offerer). + let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::(); + let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::(); + + // --- event wires back to the test body. + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + let (off_recv_tx, mut off_recv_rx) = mpsc::channel::>(4); + let (ans_recv_tx, mut ans_recv_rx) = mpsc::channel::>(4); + + // ============ register ALL handlers BEFORE create_offer/set_remote ============ + + // Offerer: forward its gathered candidates toward the answerer. + { + let tx = off_ice_tx.clone(); + pc_off.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(init) = c.to_json() { + let _ = tx.send(init); + } + } + }) + })); + } + pc_off.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + Box::pin(async move { eprintln!("offerer pc state: {s}"); }) + })); + + // Offerer creates the reliable+ordered channel it will own. + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + + // Offerer's channel: on open, announce + send the ping; on message, forward + // whatever comes back to the test body. + { + let open_tx = off_open_tx.clone(); + let dc_send = dc.clone(); + dc.on_open(Box::new(move || { + let open_tx = open_tx.clone(); + let dc_send = dc_send.clone(); + Box::pin(async move { + let _ = open_tx.send(()).await; + let _ = dc_send.send(&Bytes::from_static(b"ping")).await; + }) + })); + let recv_tx = off_recv_tx.clone(); + dc.on_message(Box::new(move |msg: DataChannelMessage| { + let recv_tx = recv_tx.clone(); + Box::pin(async move { + let _ = recv_tx.send(msg.data.to_vec()).await; + }) + })); + } + + // Answerer: forward its gathered candidates toward the offerer. + { + let tx = ans_ice_tx.clone(); + pc_ans.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(init) = c.to_json() { + let _ = tx.send(init); + } + } + }) + })); + } + pc_ans.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + Box::pin(async move { eprintln!("answerer pc state: {s}"); }) + })); + + // Answerer binds the incoming channel: on open announce; on message forward + // to the test body AND echo "pong" back so the round-trip closes. + { + let open_tx = ans_open_tx.clone(); + let recv_tx = ans_recv_tx.clone(); + pc_ans.on_data_channel(Box::new(move |dc2: Arc| { + let open_tx = open_tx.clone(); + let recv_tx = recv_tx.clone(); + Box::pin(async move { + let open_tx2 = open_tx.clone(); + dc2.on_open(Box::new(move || { + let open_tx2 = open_tx2.clone(); + Box::pin(async move { + let _ = open_tx2.send(()).await; + }) + })); + let recv_tx2 = recv_tx.clone(); + let dc_echo = dc2.clone(); + dc2.on_message(Box::new(move |msg: DataChannelMessage| { + let recv_tx2 = recv_tx2.clone(); + let dc_echo = dc_echo.clone(); + Box::pin(async move { + let _ = recv_tx2.send(msg.data.to_vec()).await; + let _ = dc_echo.send(&Bytes::from_static(b"pong")).await; + }) + })); + }) + })); + } + + // ============ negotiate (offer/answer handed across directly) ============ + + let offer = pc_off.create_offer(None).await.expect("create_offer"); + pc_off + .set_local_description(offer.clone()) + .await + .expect("offerer set_local"); // starts offerer ICE gathering + + pc_ans + .set_remote_description(offer) + .await + .expect("answerer set_remote(offer)"); + // Answerer's remote desc is set -> safe to add the offerer's candidates. + tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx)); + + let answer = pc_ans.create_answer(None).await.expect("create_answer"); + pc_ans + .set_local_description(answer.clone()) + .await + .expect("answerer set_local"); // starts answerer ICE gathering + + pc_off + .set_remote_description(answer) + .await + .expect("offerer set_remote(answer)"); + // Offerer's remote desc is set -> safe to add the answerer's candidates. + tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx)); + + // ============ assert: both ends open + the payload round-trips ============ + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("offerer DataChannel open timed out") + .expect("offerer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("answerer DataChannel open timed out") + .expect("answerer open channel closed"); + + let got_ping = timeout(t, ans_recv_rx.recv()) + .await + .expect("answerer recv timed out") + .expect("answerer recv channel closed"); + assert_eq!(got_ping, b"ping", "answerer should receive the ping intact"); + + let got_pong = timeout(t, off_recv_rx.recv()) + .await + .expect("offerer recv timed out") + .expect("offerer recv channel closed"); + assert_eq!(got_pong, b"pong", "offerer should receive the echoed pong intact"); + + // Tidy up (also stops the drain tasks when the senders drop). + pc_off.close().await.expect("close offerer"); + pc_ans.close().await.expect("close answerer"); + + println!("P2P_LOOPBACK_OK datachannel formed + round-tripped server-free"); +} + +// ========================= Phase 3: full host<->viewer over the DataChannel ========================= + +/// Register the trickle-ICE candidate shim on both peers and drive the +/// offer/answer exchange. Caller must have registered every dc / on_data_channel +/// handler BEFORE calling this (candidates + the data channel must be in place +/// before set_local_description / set_remote_description). +async fn drive_negotiation(pc_off: &Arc, pc_ans: &Arc) { + let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::(); + let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::(); + { + let tx = off_ice_tx.clone(); + pc_off.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(i) = c.to_json() { + let _ = tx.send(i); + } + } + }) + })); + } + { + let tx = ans_ice_tx.clone(); + pc_ans.on_ice_candidate(Box::new(move |c: Option| { + let tx = tx.clone(); + Box::pin(async move { + if let Some(c) = c { + if let Ok(i) = c.to_json() { + let _ = tx.send(i); + } + } + }) + })); + } + + let offer = pc_off.create_offer(None).await.expect("create_offer"); + pc_off + .set_local_description(offer.clone()) + .await + .expect("offerer set_local"); + // Munge the SDP the peer receives to advertise max-message-size:262144 + // (same as the production offer_nontrickle path), so whole ~200KB frames + // are accepted. + let offer_rx = webrtc::peer_connection::sdp::session_description::RTCSessionDescription::offer( + netdesk_core::advertise_max_message_size(offer.sdp), + ) + .expect("offer munge"); + pc_ans + .set_remote_description(offer_rx) + .await + .expect("answerer set_remote(offer)"); + tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx)); + + let answer = pc_ans.create_answer(None).await.expect("create_answer"); + pc_ans + .set_local_description(answer.clone()) + .await + .expect("answerer set_local"); + let answer_rx = + webrtc::peer_connection::sdp::session_description::RTCSessionDescription::answer( + netdesk_core::advertise_max_message_size(answer.sdp), + ) + .expect("answer munge"); + pc_off + .set_remote_description(answer_rx) + .await + .expect("offerer set_remote(answer)"); + tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx)); +} + +/// Bring up a connected pair of `WebRtcChannel`s on loopback: the viewer side +/// is the offerer that creates the single reliable+ordered channel, the host +/// side binds it via on_data_channel. Both channels are open on return. The pcs +/// are returned so the caller keeps them alive (and can close them). +async fn connected_channel_pair() -> ( + WebRtcChannel, + WebRtcChannel, + Arc, + Arc, +) { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("viewer pc"); // offerer + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("host pc"); // answerer + + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + let (host_dc_tx, mut host_dc_rx) = mpsc::channel::>(1); + + // Viewer owns the channel. + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + { + let tx = off_open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + + // Host binds the incoming channel + reports when it opens. + { + let open_tx = ans_open_tx.clone(); + let dc_tx = host_dc_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let open_tx = open_tx.clone(); + let dc_tx = dc_tx.clone(); + Box::pin(async move { + let open_tx2 = open_tx.clone(); + d.on_open(Box::new(move || { + let t = open_tx2.clone(); + Box::pin(async move { + let _ = t.send(()).await; + }) + })); + let _ = dc_tx.send(d).await; + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("viewer dc open timed out") + .expect("viewer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("host dc open timed out") + .expect("host open channel closed"); + let host_dc = timeout(t, host_dc_rx.recv()) + .await + .expect("host dc never arrived") + .expect("host dc channel closed"); + + // Wrap AFTER open and BEFORE any protocol loop runs, so no app message is + // sent before its reassembly handler is installed. + let viewer_chan = WebRtcChannel::new(dc).await; + let host_chan = WebRtcChannel::new(host_dc).await; + (viewer_chan, host_chan, pc_off, pc_ans) +} + +/// A ~`n`-byte stand-in Annex-B access unit with a varying byte pattern (so a +/// mis-ordered / dropped fragment is caught by the byte-equality check on top +/// of the AES-GCM auth tag). Starts with a NAL start code for realism. +fn make_big_keyframe(n: usize) -> Vec { + let mut v = Vec::with_capacity(n); + v.extend_from_slice(&[0, 0, 0, 1, 0x65]); // start code + IDR NAL header + let mut i: usize = 0; + while v.len() < n { + v.push((i % 251) as u8); + i += 1; + } + v.truncate(n); + v +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn host_viewer_over_datachannel() { + let (viewer_chan, host_chan, pc_off, pc_ans) = connected_channel_pair().await; + + // ---- host over its DataChannel end ---- + let password = "p2p-dc-test-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "p2p://loopback".into(), // unused: transport is supplied directly + host_id: host_id.clone(), + name: "P2PHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1280, + height: 720, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_chan)).await + }); + + // ---- viewer over its DataChannel end ---- + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "p2p://loopback".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_chan)) + .await + .expect("viewer connect+handshake over DataChannel"); + + // Handshake completed peer-to-peer (no relay): E2E negotiated, size advertised. + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { w, h, e2e, .. }) => { + assert_eq!((w, h), (1280, 720), "advertised resolution over DataChannel"); + assert!(e2e, "E2E negotiated over DataChannel"); + } + _ => panic!("viewer never got Connected over the DataChannel"), + } + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15) + .await + .is_some(), + "host never saw ViewerConnected over the DataChannel" + ); + + // Input flows viewer -> host over the ctl path of the channel. + session.mouse(123, 456, 1, 0.0, 0.0); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await { + Some(HostEvent::Mouse { x, y, buttons, .. }) => { + assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed over DataChannel"); + } + _ => panic!("host never received the viewer's mouse over the DataChannel"), + } + session.key("KeyA", true); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await { + Some(HostEvent::Key { code, down }) => { + assert_eq!((code.as_str(), down), ("KeyA", true), "key routed over DataChannel"); + } + _ => panic!("host never received the viewer's key over the DataChannel"), + } + + // A small keyframe fed to the host decrypts to Video on the viewer (one chunk). + let small = vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28, 0, 0, 0, 1, 0x65, 9, 9, 9]; + let _ = frame_tx.send(HostFrame { + data: small.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await { + Some(SessionEvent::Video { au, .. }) => { + assert_eq!(au, small, "small keyframe AU must survive the DataChannel intact"); + } + _ => panic!("small keyframe never arrived as Video over the DataChannel"), + } + + // A ~200 KB keyframe exercises fragmentation (>16 KiB -> ~13 chunks) + + // reassembly. If any chunk were mis-ordered or dropped, the rejoined blob + // would fail the AES-GCM auth tag and never emit — so arrival with matching + // bytes proves contiguous reassembly end to end. + let big = make_big_keyframe(200 * 1024); + let _ = frame_tx.send(HostFrame { + data: big.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 15).await { + Some(SessionEvent::Video { au, .. }) => { + assert_eq!(au.len(), big.len(), "reassembled keyframe length must match"); + assert_eq!(au, big, "reassembled keyframe bytes identical (GCM proves contiguity)"); + } + _ => panic!("200KB keyframe never reassembled into Video over the DataChannel"), + } + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_HOST_VIEWER_OVER_DC_OK video+input+200KB keyframe over the DataChannel, E2E intact"); +} + +// --- waiters (mirror core/tests/host_viewer.rs) --- +async fn wait_host bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +async fn wait_view bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +// ===================== Phase 4: non-trickle SDP-only negotiation ===================== +// The relay carries only rtc.offer.sdp and rtc.answer.sdp (NON-TRICKLE): every +// ICE candidate is embedded in the gathered SDP, with no separate candidate +// message. This test drives offer_nontrickle / answer_nontrickle / apply_answer +// exactly as the relay path will, exchanging ONLY the two SDP strings (no +// candidate shim), and proves the pc still connects + the DataChannel opens. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn nontrickle_sdp_exchange_connects() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1); + + // Offerer creates the channel BEFORE offer_nontrickle (it shapes the offer). + let dc = pc_off + .create_data_channel( + "netdesk", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create_data_channel"); + { + let tx = off_open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + { + let tx = ans_open_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let tx = tx.clone(); + Box::pin(async move { + let t = tx.clone(); + d.on_open(Box::new(move || { + let t = t.clone(); + Box::pin(async move { + let _ = t.send(()).await; + }) + })); + }) + })); + } + + // The ENTIRE signaling is these three calls + two SDP strings. No candidate + // exchange at all — exactly the relay topology. + let offer_sdp = offer_nontrickle(&pc_off, Duration::from_millis(2500)) + .await + .expect("offer_nontrickle"); + let answer_sdp = answer_nontrickle(&pc_ans, &offer_sdp, Duration::from_millis(2500)) + .await + .expect("answer_nontrickle"); + apply_answer(&pc_off, &answer_sdp) + .await + .expect("apply_answer"); + + // Non-trickle means candidates must be embedded in the SDP. + assert!( + offer_sdp.contains("a=candidate"), + "offer SDP must embed ICE candidates (non-trickle)" + ); + assert!( + answer_sdp.contains("a=candidate"), + "answer SDP must embed ICE candidates (non-trickle)" + ); + + let t = Duration::from_secs(20); + timeout(t, off_open_rx.recv()) + .await + .expect("offerer dc open timed out") + .expect("offerer open channel closed"); + timeout(t, ans_open_rx.recv()) + .await + .expect("answerer dc open timed out") + .expect("answerer open channel closed"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_NONTRICKLE_OK offer/answer SDP (candidates embedded) connected, server-free"); +} + +// ============ Phase 4: relay-brokered rtc.* handshake -> live pc ============ +// Proves upgrade_offerer (viewer) + answer_offer (host) negotiate a real pc +// purely by exchanging rtc.* text through a relay-like pipe — exactly what the +// WSS relay carries between the two session peers, minus the server (so the +// rtc.config.req is skipped via ice_override, as on loopback). A dumb +// bidirectional String pipe stands in for the relay. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn rtc_relay_handshake_connects() { + // relay pipe: viewer->host and host->viewer rtc.* text. + let (v2h_tx, mut v2h_rx) = mpsc::unbounded_channel::(); + let (h2v_tx, mut h2v_rx) = mpsc::unbounded_channel::(); + + // viewer's rtc_in: forward only rtc.* Values arriving from the host. + let (v_rtc_tx, mut v_rtc_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + while let Some(s) = h2v_rx.recv().await { + if let Ok(v) = serde_json::from_str::(&s) { + if v.get("t") + .and_then(|t| t.as_str()) + .map(|t| t.starts_with("rtc.")) + .unwrap_or(false) + { + let _ = v_rtc_tx.send(v); + } + } + } + }); + + // host side: wait for rtc.offer off the relay, then answer it. + let h2v_tx_host = h2v_tx.clone(); + let host = tokio::spawn(async move { + let api = build_api().expect("host build_api"); + loop { + match v2h_rx.recv().await { + Some(s) => { + let v: Value = match serde_json::from_str(&s) { + Ok(v) => v, + Err(_) => continue, + }; + if v.get("t").and_then(|t| t.as_str()) == Some("rtc.offer") { + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let sdp = v.get("sdp").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let ice: Vec = v + .get("ice") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let send = { + let tx = h2v_tx_host.clone(); + move |m: String| { + let _ = tx.send(m); + } + }; + return answer_offer(&api, &sid, &sdp, ice, &send).await; + } + // ignore rtc.config.req + anything else (server-answered, not us) + } + None => return Err(anyhow::anyhow!("relay closed before rtc.offer")), + } + } + }); + + // viewer side: run the offerer handshake. ice_override Some(vec![]) => no + // rtc.config.req, loopback host candidates only. + let send_v = { + let tx = v2h_tx.clone(); + move |m: String| { + let _ = tx.send(m); + } + }; + let viewer = upgrade_offerer( + &build_api().expect("viewer build_api"), + "relay-test-sid", + Some(vec![]), + "netdesk", + &send_v, + &mut v_rtc_rx, + ) + .await; + + let (vpc, vdc) = viewer.expect("viewer upgrade_offerer"); + let (hpc, hdc) = host.await.unwrap().expect("host answer_offer"); + + // Both returned only after pc Connected + DataChannel open. Prove the + // established channel actually carries data end to end. + let (got_tx, mut got_rx) = mpsc::channel::>(1); + hdc.on_message(Box::new(move |msg: DataChannelMessage| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let _ = got_tx.send(msg.data.to_vec()).await; + }) + })); + vdc.send(&Bytes::from_static(b"over-relay")) + .await + .expect("send over negotiated channel"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("round-trip timed out") + .expect("round-trip channel closed"); + assert_eq!(got, b"over-relay", "data must cross the relay-negotiated channel"); + + vpc.close().await.ok(); + hpc.close().await.ok(); + println!("P2P_RTC_RELAY_OK rtc.offer/rtc.answer over the relay -> live pc + DataChannel"); +} + +// ===================== Phase 5 harness: in-memory relay ===================== +// A dumb bidirectional in-process pipe that behaves like the WSS relay for a +// single viewer<->host pair: every frame one end sends is delivered verbatim to +// the other. Lets the full connect_with / run_host_with stacks run server-free, +// and (next) lets the P2P multiplexer negotiate over it. + +struct MemTransport { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, + /// When set, this end silently drops outbound binary (simulates the relay + /// media path going away so only a P2P channel can carry video). + drop_binary: Arc, + /// When set, this end silently drops outbound rtc.* text (simulates a P2P + /// upgrade that can never negotiate, forcing the relay-only fallback). + drop_rtc: Arc, +} + +/// Control handles for one relay end: toggle dropping of binary / rtc.* on it. +#[derive(Clone)] +struct MemCtl { + drop_binary: Arc, + drop_rtc: Arc, +} + +impl MemTransport { + fn ctl(&self) -> MemCtl { + MemCtl { + drop_binary: self.drop_binary.clone(), + drop_rtc: self.drop_rtc.clone(), + } + } +} + +fn mem_relay_pair() -> (MemTransport, MemTransport) { + let (a_tx, a_rx) = mpsc::unbounded_channel::(); + let (b_tx, b_rx) = mpsc::unbounded_channel::(); + // end A writes to b_tx (read by B), end B writes to a_tx (read by A). + ( + MemTransport { + tx: b_tx, + rx: a_rx, + drop_binary: Arc::new(AtomicBool::new(false)), + drop_rtc: Arc::new(AtomicBool::new(false)), + }, + MemTransport { + tx: a_tx, + rx: b_rx, + drop_binary: Arc::new(AtomicBool::new(false)), + drop_rtc: Arc::new(AtomicBool::new(false)), + }, + ) +} + +#[async_trait] +impl Transport for MemTransport { + async fn send_binary(&mut self, data: Vec) -> netdesk_core::Result<()> { + if self.drop_binary.load(Ordering::SeqCst) { + return Ok(()); // silently dropped + } + self.tx + .send(TransportMsg::Binary(data)) + .map_err(|_| NdError::Connect("mem relay peer gone".into())) + } + async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> { + if self.drop_rtc.load(Ordering::SeqCst) && text.contains("\"t\":\"rtc.") { + return Ok(()); // silently dropped + } + self.tx + .send(TransportMsg::Text(text)) + .map_err(|_| NdError::Connect("mem relay peer gone".into())) + } + async fn recv(&mut self) -> Option> { + self.rx.recv().await.map(Ok) + } +} + +/// Sanity: the full host<->viewer stack runs over the in-memory relay with NO +/// server and NO P2P — proving the harness faithfully stands in for the WSS +/// relay before we layer the P2P multiplexer on top. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn relay_pair_host_viewer_no_p2p() { + let (viewer_relay, host_relay) = mem_relay_pair(); + + let password = "mem-relay-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "MemHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1600, + height: 900, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)) + .await + .expect("viewer connect over the in-memory relay"); + + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { w, h, e2e, .. }) => { + assert_eq!((w, h), (1600, 900)); + assert!(e2e, "E2E over the in-memory relay"); + } + _ => panic!("viewer never got Connected over the in-memory relay"), + } + // The viewer advertises VP9 decode in session.begin; the host must surface + // it so the capture loop can safely pick VP9 on constrained links. + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15).await { + Some(HostEvent::ViewerConnected { vp9, .. }) => { + assert!(vp9, "host should see the viewer's VP9 capability (negotiation)"); + } + _ => panic!("host never saw ViewerConnected over the in-memory relay"), + } + + session.mouse(321, 654, 2, 0.0, 0.0); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await { + Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)), + _ => panic!("mouse not routed over the in-memory relay"), + } + + let au = vec![0, 0, 0, 1, 0x65, 1, 2, 3, 4, 5]; + let _ = frame_tx.send(HostFrame { + data: au.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await { + Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au), + _ => panic!("video not routed over the in-memory relay"), + } + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + println!("MEM_RELAY_OK full host<->viewer stack over an in-memory relay, server-free"); +} + +// ===================== Phase 5: relay-first upgrade / fallback / downgrade ===================== +// Each wraps BOTH ends of the in-memory relay in the P2pRelay multiplexer and +// runs the unchanged connect_with / run_host_with over them. The controllable +// relay lets us prove routing: dropping relay BINARY forces video onto P2P (so +// its arrival proves the upgrade); dropping relay RTC.* forces the relay-only +// fallback; P2pControl::force_relay drives the seamless downgrade. + +struct P2pFixture { + session: Session, + vrx: mpsc::UnboundedReceiver, + hev_rx: mpsc::UnboundedReceiver, + frame_tx: mpsc::UnboundedSender, + host_task: tokio::task::JoinHandle>, + viewer_ctl: P2pControl, + host_ctl: P2pControl, +} + +async fn start_p2p_session(viewer_relay: MemTransport, host_relay: MemTransport) -> P2pFixture { + let viewer_p2p = upgrade_relay( + Box::new(viewer_relay), + P2pRole::Offerer, + Arc::new(build_api().expect("viewer api")), + ); + let host_p2p = upgrade_relay( + Box::new(host_relay), + P2pRole::Answerer, + Arc::new(build_api().expect("host api")), + ); + let viewer_ctl = viewer_p2p.control(); + let host_ctl = host_p2p.control(); + + let password = "p2p-mux-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "MuxHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1024, + height: 768, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_p2p)).await + }); + + let (vtx, vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_p2p)) + .await + .expect("viewer connect over the P2P multiplexer"); + + P2pFixture { + session, + vrx, + hev_rx, + frame_tx, + host_task, + viewer_ctl, + host_ctl, + } +} + +/// Feed `au` as a keyframe up to `tries` times, returning true as soon as the +/// viewer emits a matching Video (used to ride out the few seconds the upgrade +/// takes without a fixed sleep). +async fn feed_until_video(fx: &mut P2pFixture, au: &[u8], tries: u32) -> bool { + for _ in 0..tries { + let _ = fx.frame_tx.send(HostFrame { + data: au.to_vec(), + keyframe: true, + codec: 0, + }); + if let Some(SessionEvent::Video { au: got, .. }) = + wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 1).await + { + if got == au { + return true; + } + } + } + false +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_upgrade_video_rides_the_channel() { + let (viewer_relay, mut host_relay) = mem_relay_pair(); + // Kill the relay BINARY path host->viewer from the start. The rtc.* handshake + // is text, so it still completes; once P2P is up, video can ONLY arrive over + // the DataChannel. + host_relay.drop_binary.store(true, Ordering::SeqCst); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(SessionEvent::Connected { e2e, .. }) => assert!(e2e, "E2E over the relay handshake"), + _ => panic!("session never connected over the relay"), + } + + // ~40 KB keyframe (fragments over the channel). Arrival proves the upgrade + // took over, since the relay binary path is dead. + let au = make_big_keyframe(40 * 1024); + assert!( + feed_until_video(&mut fx, &au, 40).await, + "video never arrived — P2P upgrade did not take over the dead relay binary path" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_UPGRADE_OK video rode the DataChannel while the relay binary path was dead"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_fallback_stays_on_relay() { + let (mut viewer_relay, mut host_relay) = mem_relay_pair(); + // Drop rtc.* on BOTH ends: the upgrade can never negotiate, so media must + // keep flowing over the relay, uninterrupted. + viewer_relay.drop_rtc.store(true, Ordering::SeqCst); + host_relay.drop_rtc.store(true, Ordering::SeqCst); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("session never connected (relay fallback)"), + } + + // Video flows over the relay (binary path is open). + let au = vec![0, 0, 0, 1, 0x65, 7, 7, 7, 7]; + let _ = fx.frame_tx.send(HostFrame { + data: au.clone(), + keyframe: true, + codec: 0, + }); + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 10).await { + Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au), + _ => panic!("video did not flow over the relay while the upgrade was failing"), + } + // Input flows over the relay too. + fx.session.mouse(11, 22, 1, 0.0, 0.0); + assert!( + wait_host(&mut fx.hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10) + .await + .is_some(), + "input did not flow over the relay during fallback" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_FALLBACK_OK upgrade could not negotiate; media stayed on the relay"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_downgrade_reverts_to_relay() { + let (viewer_relay, host_relay) = mem_relay_pair(); + let host_bin = host_relay.drop_binary.clone(); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("session never connected"), + } + + // 1) Upgrade: kill the relay binary path, prove video rides P2P. + host_bin.store(true, Ordering::SeqCst); + let au = make_big_keyframe(32 * 1024); + assert!( + feed_until_video(&mut fx, &au, 40).await, + "never upgraded to P2P before the downgrade test" + ); + + // 2) Downgrade: force both ends back to the relay and re-open the relay + // binary path. The reroute must be seamless — no Ended, video resumes. + fx.viewer_ctl.force_relay(); + fx.host_ctl.force_relay(); + host_bin.store(false, Ordering::SeqCst); + + let au2 = vec![0, 0, 0, 1, 0x65, 9, 8, 7, 6, 5, 4]; + assert!( + feed_until_video(&mut fx, &au2, 40).await, + "video did not resume over the relay after the forced downgrade" + ); + // The session is still alive (no Ended surfaced during the reroute). + assert!( + !fx.host_task.is_finished(), + "host ended during downgrade — reroute was not seamless" + ); + + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_DOWNGRADE_OK forced back to the relay mid-session; video resumed seamlessly"); +} + +// =============== Phase 6: web 3-channel topology forms + routes per lane =============== +// Proves the native peer can speak the web/Python media/ctl/move topology: +// the offerer creates the three labelled channels, the answerer binds them by +// label, and a probe on each arrives on the matching channel. This is the +// interop wire shape (labels + options match app.js); routing it through the +// P2pRelay engine is layered on top. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn web_channels_form_and_route_per_lane() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // offerer: the three web channels (created before negotiation so they are + // announced in the offer SDP). + let web = create_web_channels(&pc_off).await.expect("create_web_channels"); + + // answerer: bind each incoming channel by label; record which label each + // received message arrived on. + let (open_tx, mut open_rx) = mpsc::channel::(8); + let got: Arc>>> = Arc::new(StdMutex::new(HashMap::new())); + { + let open_tx = open_tx.clone(); + let got = got.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let open_tx = open_tx.clone(); + let got = got.clone(); + let label = d.label().to_string(); + Box::pin(async move { + let l_open = label.clone(); + let open_tx2 = open_tx.clone(); + d.on_open(Box::new(move || { + let l = l_open.clone(); + let tx = open_tx2.clone(); + Box::pin(async move { + let _ = tx.send(l).await; + }) + })); + let l_msg = label.clone(); + let got2 = got.clone(); + d.on_message(Box::new(move |msg: DataChannelMessage| { + let l = l_msg.clone(); + let got2 = got2.clone(); + Box::pin(async move { + got2.lock().unwrap().insert(l, msg.data.to_vec()); + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + // all three channels must open on the answerer (labels prove the topology). + let t = Duration::from_secs(20); + let mut opened = std::collections::HashSet::new(); + while opened.len() < 3 { + let lbl = timeout(t, open_rx.recv()) + .await + .expect("web channel open timed out") + .expect("open channel closed"); + opened.insert(lbl); + } + assert!(opened.contains("media") && opened.contains("ctl") && opened.contains("move"), + "expected media/ctl/move, got {opened:?}"); + + // send a distinct probe on each lane; each must arrive on its own channel. + web.media.send(&Bytes::from_static(b"V")).await.expect("media send"); + web.ctl.send(&Bytes::from_static(b"C")).await.expect("ctl send"); + web.mov.send(&Bytes::from_static(b"M")).await.expect("move send"); + + // wait until all three distinct labels have delivered. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + { + let g = got.lock().unwrap(); + if g.len() >= 3 { + assert_eq!(g.get("media").map(|v| v.as_slice()), Some(&b"V"[..])); + assert_eq!(g.get("ctl").map(|v| v.as_slice()), Some(&b"C"[..])); + assert_eq!(g.get("move").map(|v| v.as_slice()), Some(&b"M"[..])); + break; + } + } + if tokio::time::Instant::now() > deadline { + panic!("not all web channels delivered: {:?}", got.lock().unwrap().keys().collect::>()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WEB3_OK media/ctl/move formed with web labels/options + routed per channel"); +} + + + +/// Pins down webrtc-rs 0.17.2's whole-message behaviour, which decides how +/// web/Python interop is possible: +/// * A whole frame up to ~64 KiB round-trips intact in one message. +/// * A whole ~200 KiB frame: the SEND is accepted (we advertise +/// max-message-size:262144, so the sender's cap is lifted) but the RECEIVER +/// never delivers it — webrtc-rs reassembles whole messages only up to the +/// 64 KiB default, with no setting to raise the receive side. +/// +/// So the native single channel app-fragments at 16 KiB (each chunk a separate +/// <64 KiB message, reassembled by install_reassembler) — which is why +/// host_viewer_over_datachannel carries a 200 KiB keyframe fine. The web/Python +/// `media` channel instead sends ONE whole large frame (SCTP-fragmented by +/// aiortc/the browser), which webrtc-rs 0.17.2 will not reassemble on receive — +/// the concrete blocker for native<->web/Python video interop (see P2P-PLAN.md). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn webrtc_whole_message_64k_receive_limit() { + let api = build_api().expect("build_api"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + let (open_tx, mut open_rx) = mpsc::channel::<()>(1); + let (got_tx, mut got_rx) = mpsc::channel::(8); + let dc = pc_off + .create_data_channel( + "ctl", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create channel"); + { + let tx = open_tx.clone(); + dc.on_open(Box::new(move || { + let tx = tx.clone(); + Box::pin(async move { + let _ = tx.send(()).await; + }) + })); + } + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let got_tx = got_tx.clone(); + d.on_message(Box::new(move |msg: DataChannelMessage| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let _ = got_tx.send(msg.data.len()).await; + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + timeout(Duration::from_secs(20), open_rx.recv()) + .await + .expect("open timeout") + .expect("closed"); + + // A sub-64 KiB whole message round-trips. + let small = vec![0x5Au8; 60 * 1024]; + dc.send(&Bytes::from(small.clone())).await.expect("60K send"); + let n = timeout(Duration::from_secs(5), got_rx.recv()) + .await + .expect("60K recv timed out") + .expect("closed"); + assert_eq!(n, small.len(), "a sub-64KiB whole message must arrive intact"); + + // A ~200 KiB whole message: send accepted (cap lifted), but NOT received. + let big = make_big_keyframe(200 * 1024); + dc.send(&Bytes::from(big)).await.expect("200K send is accepted"); + let late = timeout(Duration::from_secs(3), got_rx.recv()).await; + assert!( + late.is_err() || late.unwrap().is_none(), + "webrtc-rs 0.17.2 does NOT deliver a whole >64KiB message (interop blocker)" + ); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WHOLEMSG_LIMIT_OK <=64KiB whole msg delivers; a whole 200KiB msg is sent but not received"); +} + +/// THE UNBLOCK: with DETACHED channels (build_api_web) we read with our own +/// 256 KiB buffer, so a whole ~200 KiB message is received intact — exactly +/// what the default on_message path (64 KiB buffer) could not do. This is how a +/// native peer receives whole video frames from the web/Python `media` channel. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detached_channel_receives_200k_whole() { + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc"); + + // offerer: detach on open, hand the raw channel to the test body to write on. + let (raw_off_tx, mut raw_off_rx) = + mpsc::channel::>(1); + let dc = pc_off + .create_data_channel( + "ctl", + Some(RTCDataChannelInit { + ordered: Some(true), + ..Default::default() + }), + ) + .await + .expect("create channel"); + { + let dc_for_detach = dc.clone(); + let raw_off_tx = raw_off_tx.clone(); + dc.on_open(Box::new(move || { + let dc_for_detach = dc_for_detach.clone(); + let raw_off_tx = raw_off_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dc_for_detach.detach().await { + let _ = raw_off_tx.send(raw).await; + } + }) + })); + } + + // answerer: detach on open, read with a 256 KiB buffer, forward whole msgs. + let (got_tx, mut got_rx) = mpsc::channel::>(4); + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let d_for_detach = d.clone(); + let got_tx = got_tx.clone(); + d.on_open(Box::new(move || { + let d_for_detach = d_for_detach.clone(); + let got_tx = got_tx.clone(); + Box::pin(async move { + if let Ok(raw) = d_for_detach.detach().await { + let mut buf = vec![0u8; 262144]; + loop { + match raw.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { + let _ = got_tx.send(buf[..n].to_vec()).await; + } + Err(_) => break, + } + } + } + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + let t = Duration::from_secs(20); + let raw_off = timeout(t, raw_off_rx.recv()) + .await + .expect("offerer detach timed out") + .expect("offerer raw channel closed"); + + // 200 KB whole frame over the detached channel. + let frame = make_big_keyframe(200 * 1024); + raw_off + .write_data_channel(&Bytes::from(frame.clone()), false) + .await + .expect("200KB detached write"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("200KB detached recv timed out") + .expect("closed"); + assert_eq!(got, frame, "detached channel must receive the 200KB frame whole"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_DETACH_OK detached channel received a whole 200KB frame (webrtc-rs 64K read limit bypassed)"); +} + + + +/// Diagnostic: does a DETACHED UNRELIABLE media channel (ordered:false, +/// maxPacketLifeTime:150) deliver a whole 200KB frame? (The passing detach test +/// used a reliable ctl channel.) +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn detached_media_unreliable_200k() { + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans"); + let (raw_off_tx, mut raw_off_rx) = + mpsc::channel::>(1); + let dc = pc_off + .create_data_channel( + "media", + Some(RTCDataChannelInit { + ordered: Some(false), + max_packet_life_time: Some(150), + ..Default::default() + }), + ) + .await + .expect("create media"); + { + let dcd = dc.clone(); + let raw_off_tx = raw_off_tx.clone(); + dc.on_open(Box::new(move || { + let dcd = dcd.clone(); + let raw_off_tx = raw_off_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dcd.detach().await { + let _ = raw_off_tx.send(raw).await; + } + }) + })); + } + let (got_tx, mut got_rx) = mpsc::channel::>(4); + { + let got_tx = got_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let got_tx = got_tx.clone(); + Box::pin(async move { + let dd = d.clone(); + let got_tx = got_tx.clone(); + d.on_open(Box::new(move || { + let dd = dd.clone(); + let got_tx = got_tx.clone(); + Box::pin(async move { + if let Ok(raw) = dd.detach().await { + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match raw.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { + let _ = got_tx.send(buf[..n].to_vec()).await; + } + Err(_) => break, + } + } + }); + } + }) + })); + }) + })); + } + drive_negotiation(&pc_off, &pc_ans).await; + let raw_off = timeout(Duration::from_secs(20), raw_off_rx.recv()) + .await + .expect("off detach timeout") + .expect("closed"); + let frame = make_big_keyframe(200 * 1024); + raw_off + .write_data_channel(&Bytes::from(frame.clone()), false) + .await + .expect("media write"); + let got = timeout(Duration::from_secs(10), got_rx.recv()) + .await + .expect("media recv timeout") + .expect("closed"); + assert_eq!(got, frame); + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_MEDIA_UNREL_OK unreliable media delivered 200KB whole"); +} + +/// web-mode with detaches done SEQUENTIALLY in the test body (after opens are +/// signalled) instead of concurrently inside on_open handlers — isolating a +/// multi-detach contention. media(video 200KB host->viewer) + ctl(input +/// viewer->host) over the 3-channel topology. +/// +/// IGNORED: flaky when BOTH pcs + their detach read-loops share ONE process / +/// tokio runtime (this loopback harness) — multi-channel detach intermittently +/// starves the runtime. In production the viewer and the Python/web host are +/// SEPARATE processes with separate runtimes, so this contention does not +/// occur; that path is verified live instead. Single-channel detach +/// (detached_channel_receives_200k_whole) is reliable. +#[ignore = "multi-channel detach flaky only when both pcs share one runtime (loopback); verified live"] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn web_mode_body_detach() { + use std::collections::HashMap as Map; + let api = build_api_web().expect("build_api_web"); + let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off"); + let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans"); + let web = create_web_channels(&pc_off).await.expect("web chans"); + + // offerer open signals (no detach in handler) + let (off_open_tx, mut off_open_rx) = mpsc::channel::(8); + for (lbl, ch) in [("media", &web.media), ("ctl", &web.ctl), ("move", &web.mov)] { + let tx = off_open_tx.clone(); + let lbl = lbl.to_string(); + ch.on_open(Box::new(move || { + let tx = tx.clone(); + let lbl = lbl.clone(); + Box::pin(async move { + let _ = tx.send(lbl).await; + }) + })); + } + + // answerer: collect wrappers by label + open signals (no detach in handler) + let ans_map: Arc>>> = + Arc::new(StdMutex::new(Map::new())); + let (ans_open_tx, mut ans_open_rx) = mpsc::channel::(8); + { + let ans_map = ans_map.clone(); + let ans_open_tx = ans_open_tx.clone(); + pc_ans.on_data_channel(Box::new(move |d: Arc| { + let ans_map = ans_map.clone(); + let ans_open_tx = ans_open_tx.clone(); + let label = d.label().to_string(); + Box::pin(async move { + ans_map.lock().unwrap().insert(label.clone(), d.clone()); + let tx = ans_open_tx.clone(); + let l = label.clone(); + d.on_open(Box::new(move || { + let tx = tx.clone(); + let l = l.clone(); + Box::pin(async move { + let _ = tx.send(l).await; + }) + })); + }) + })); + } + + drive_negotiation(&pc_off, &pc_ans).await; + + // wait until media+ctl opened on both sides + let t = Duration::from_secs(20); + let mut off_o = std::collections::HashSet::new(); + while !(off_o.contains("media") && off_o.contains("ctl")) { + off_o.insert(timeout(t, off_open_rx.recv()).await.expect("off open to").expect("c")); + } + let mut ans_o = std::collections::HashSet::new(); + while !(ans_o.contains("media") && ans_o.contains("ctl")) { + ans_o.insert(timeout(t, ans_open_rx.recv()).await.expect("ans open to").expect("c")); + } + + // detach sequentially in the body + let v_media = web.media.detach().await.expect("v media detach"); + let v_ctl = web.ctl.detach().await.expect("v ctl detach"); + let (h_media, h_ctl) = { + let m = ans_map.lock().unwrap(); + (m.get("media").unwrap().clone(), m.get("ctl").unwrap().clone()) + }; + let h_media = h_media.detach().await.expect("h media detach"); + let h_ctl = h_ctl.detach().await.expect("h ctl detach"); + + // viewer reads media (video), host reads ctl (input) + let (vid_tx, mut vid_rx) = mpsc::channel::>(4); + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match v_media.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { let _ = vid_tx.send(buf[..n].to_vec()).await; } + Err(_) => break, + } + } + }); + let (inp_tx, mut inp_rx) = mpsc::channel::>(4); + tokio::spawn(async move { + let mut buf = vec![0u8; 262144]; + loop { + match h_ctl.read_data_channel(&mut buf).await { + Ok((0, _)) => break, + Ok((n, _)) => { let _ = inp_tx.send(buf[..n].to_vec()).await; } + Err(_) => break, + } + } + }); + + let frame = make_big_keyframe(200 * 1024); + h_media.write_data_channel(&Bytes::from(frame.clone()), false).await.expect("h media write"); + let got_vid = timeout(Duration::from_secs(10), vid_rx.recv()).await.expect("vid to").expect("c"); + assert_eq!(got_vid, frame, "viewer must receive whole 200KB video on media"); + + let input = br#"{"t":"i.mouse","x":1,"y":2,"b":1}"#.to_vec(); + v_ctl.write_data_channel(&Bytes::from(input.clone()), true).await.expect("v ctl write"); + let got_inp = timeout(Duration::from_secs(10), inp_rx.recv()).await.expect("inp to").expect("c"); + assert_eq!(got_inp, input, "host must receive input on ctl"); + + pc_off.close().await.ok(); + pc_ans.close().await.ok(); + println!("P2P_WEBMODE_OK web topology (body-detach): 200KB video + input over media/ctl"); +} + +/// Re-upgrade hysteresis: after a forced downgrade, the offerer retries the +/// upgrade after the backoff and media rides P2P again (a transient drop +/// recovers instead of staying on the relay forever). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_reupgrade_after_drop() { + std::env::set_var("NETDESK_P2P_REUP_MS", "1200"); // short backoff for the test + let (viewer_relay, host_relay) = mem_relay_pair(); + let host_bin = host_relay.drop_binary.clone(); + + let mut fx = start_p2p_session(viewer_relay, host_relay).await; + match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await { + Some(_) => {} + _ => panic!("no Connected"), + } + + // 1) initial upgrade: drop relay binary -> video can only ride P2P. + host_bin.store(true, Ordering::SeqCst); + let au = make_big_keyframe(32 * 1024); + assert!(feed_until_video(&mut fx, &au, 40).await, "never upgraded initially"); + + // 2) force a downgrade; re-open the relay so video resumes there. + fx.viewer_ctl.force_relay(); + fx.host_ctl.force_relay(); + host_bin.store(false, Ordering::SeqCst); + let au2 = vec![0, 0, 0, 1, 0x65, 3, 1, 4, 1, 5, 9]; + assert!(feed_until_video(&mut fx, &au2, 40).await, "no relay video after downgrade"); + + // 3) re-upgrade: after the backoff the offerer retries. Drop relay binary + // again; video arriving proves it rode the RE-upgraded P2P channel. + host_bin.store(true, Ordering::SeqCst); + let au3 = make_big_keyframe(28 * 1024); + assert!( + feed_until_video(&mut fx, &au3, 60).await, + "did not re-upgrade to P2P after the drop (video never returned while relay binary was dead)" + ); + + std::env::remove_var("NETDESK_P2P_REUP_MS"); + fx.session.close(); + drop(fx.frame_tx); + let _ = timeout(Duration::from_secs(5), fx.host_task).await; + println!("P2P_REUPGRADE_OK dropped session recovered to P2P after the backoff"); +} + +/// Adaptive-QoS telemetry: the host periodically probes RTT (net.ping), the +/// viewer echoes (net.pong), and the host emits HostEvent::NetStat with the +/// measured round trip — the signal that drives host/src/qos.rs. Verified over +/// the in-memory relay (no server). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn host_measures_rtt_netstat() { + let (viewer_relay, host_relay) = mem_relay_pair(); + + let password = "qos-rtt-pw"; + let (salt_b64, iters, verifier_hex) = new_host_credentials(password); + let host_id = gen_host_id(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let hcfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + name: "QosHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1280, + height: 720, + audio: false, + monitors: Vec::new(), + monitor: 0, + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id: host_id.clone(), + password: password.into(), + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)) + .await + .expect("viewer connect"); + assert!( + wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10) + .await + .is_some() + ); + + // Within a couple of probe intervals the host should measure a round trip. + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::NetStat { .. }), 8).await { + Some(HostEvent::NetStat { rtt_ms, loss }) => { + assert!(rtt_ms < 5000, "loopback RTT should be small, got {rtt_ms}ms"); + assert_eq!(loss, 0.0); + println!("QOS_RTT_OK host measured rtt={rtt_ms}ms via net.ping/pong"); + } + _ => panic!("host never emitted NetStat (net.ping/pong round-trip failed)"), + } + + session.close(); + let _ = timeout(Duration::from_secs(5), host_task).await; +} diff --git a/native/core/tests/perms_enforce.rs b/native/core/tests/perms_enforce.rs new file mode 100644 index 0000000..2ba17d7 --- /dev/null +++ b/native/core/tests/perms_enforce.rs @@ -0,0 +1,739 @@ +//! Host-enforced permission ACL + view-only + approve-before-connect — protocol +//! tests over the in-memory relay (same harness as p2p_loopback.rs): the FULL +//! `connect_with` / `run_host_with` stacks run server-free, and the assertions +//! are on what the ENGINE emits/sends — i.e. what a (possibly modified) viewer +//! could actually make the host do, not what a well-behaved UI chooses to send. +//! +//! Proves the Tier-0 security fix: with a permission disabled, the engine never +//! emits the corresponding platform event (input/clipboard/file/audio), replies +//! `file.err` to file requests, advertises the REAL perms in `session.accept`, +//! enforces viewer-requested view-only host-side, and holds a password-passed +//! session for Accept/Reject when approve mode is on. + +use async_trait::async_trait; +use serde_json::Value; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; + +use netdesk_core::crypto::{ + gen_host_id, new_host_credentials, password_proof, password_proof_bound, +}; +use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame, HostPerms}; +use netdesk_core::signaling::connect_with; +use netdesk_core::{ConnectConfig, NdError, SessionEvent, Transport, TransportMsg}; + +// --------------------------------------------------------------------------- +// In-memory relay pair (the p2p_loopback.rs harness, minus the drop toggles). +// --------------------------------------------------------------------------- + +struct MemTransport { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, +} + +fn mem_relay_pair() -> (MemTransport, MemTransport) { + let (a_tx, a_rx) = mpsc::unbounded_channel::(); + let (b_tx, b_rx) = mpsc::unbounded_channel::(); + (MemTransport { tx: b_tx, rx: a_rx }, MemTransport { tx: a_tx, rx: b_rx }) +} + +#[async_trait] +impl Transport for MemTransport { + async fn send_binary(&mut self, data: Vec) -> netdesk_core::Result<()> { + self.tx + .send(TransportMsg::Binary(data)) + .map_err(|_| NdError::Connect("mem relay peer gone".into())) + } + async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> { + self.tx + .send(TransportMsg::Text(text)) + .map_err(|_| NdError::Connect("mem relay peer gone".into())) + } + async fn recv(&mut self) -> Option> { + self.rx.recv().await.map(Ok) + } +} + +async fn wait_host bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +async fn wait_view bool>( + rx: &mut mpsc::UnboundedReceiver, + pred: F, + secs: u64, +) -> Option { + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if pred(&ev) { + return Some(ev); + } + } + _ => return None, + } + } +} + +fn host_cfg(perms: HostPerms, approve: bool) -> (HostConfig, String) { + host_cfg_totp(perms, approve, "") +} + +fn host_cfg_totp(perms: HostPerms, approve: bool, totp_secret: &str) -> (HostConfig, String) { + let password = "perms-test-pw".to_string(); + let (salt_b64, iters, verifier_hex) = new_host_credentials(&password); + let cfg = HostConfig { + ws_url: "mem://relay".into(), + host_id: gen_host_id(), + name: "PermsHost".into(), + salt_b64, + iters, + verifier_hex, + width: 1280, + height: 720, + audio: true, + monitors: Vec::new(), + monitor: 0, + perms, + approve, + totp_secret: totp_secret.to_string(), + }; + (cfg, password) +} + +/// Collect host events until `marker` matches (inclusive), with a timeout. +/// Returns everything seen before the marker. +async fn collect_until_marker bool>( + rx: &mut mpsc::UnboundedReceiver, + marker: F, + secs: u64, +) -> Vec { + let mut seen = Vec::new(); + let deadline = Duration::from_secs(secs); + loop { + match timeout(deadline, rx.recv()).await { + Ok(Some(ev)) => { + if marker(&ev) { + return seen; + } + seen.push(ev); + } + _ => panic!("marker event never arrived; saw {seen:?}"), + } + } +} + +// --------------------------------------------------------------------------- +// 1) All perms DISABLED: the engine must not emit input/clipboard/file/audio +// events no matter what the viewer sends, and file requests get file.err. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn perms_disabled_block_all_channels() { + let (viewer_relay, host_relay) = mem_relay_pair(); + let perms = HostPerms { input: false, clipboard: false, files: false, audio: false }; + let (hcfg, password) = host_cfg(perms, false); + let host_id = hcfg.host_id.clone(); + + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let (vtx, mut vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id, + password, + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)) + .await + .expect("viewer connects (password auth still works with perms off)"); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10) + .await + .is_some(), + "host saw the viewer" + ); + + // Fire one of EVERYTHING a hostile viewer could send on disabled channels… + session.mouse(100, 100, 1, 0.0, 0.0); + session.key("F15", true); + session.key("F15", false); + session.clipboard("must-not-reach-the-platform"); + session.audio_set(1); + session.list_roots(); + // …then flip view-only as an ORDERED MARKER (its handler is independent of + // the four perms, so it must still emit — everything sent before it was + // processed first on the same ordered channel). + session.view_only(true); + + let before = collect_until_marker( + &mut hev_rx, + |e| matches!(e, HostEvent::ViewOnly { on: true }), + 10, + ) + .await; + for ev in &before { + assert!( + !matches!( + ev, + HostEvent::Mouse { .. } + | HostEvent::Key { .. } + | HostEvent::Clipboard { .. } + | HostEvent::File { .. } + | HostEvent::FileChunk { .. } + | HostEvent::AudioSource { .. } + | HostEvent::Voice { .. } + ), + "engine leaked a blocked event to the platform: {ev:?}" + ); + } + + // The denied file request must answer with a clear file.err. + match wait_view(&mut vrx, |e| matches!(e, SessionEvent::FileError { .. }), 10).await { + Some(SessionEvent::FileError { msg }) => { + assert!( + msg.contains("disabled"), + "file.err should say the channel is disabled, got: {msg}" + ); + } + _ => panic!("viewer never got file.err for the denied file.roots"), + } + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + println!("PERMS_DENY_OK all four channels blocked at the engine + file.err reply"); +} + +// --------------------------------------------------------------------------- +// 2) Default perms: every channel flows (no regression from the gates). +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn perms_default_channels_flow() { + let (viewer_relay, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), false); + let host_id = hcfg.host_id.clone(); + + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let (vtx, _vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id, + password, + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect"); + + session.mouse(321, 654, 2, 0.0, 0.0); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await { + Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)), + _ => panic!("mouse blocked despite perms on"), + } + // Relative (game-mouse) motion flows through the same input gate. + session.mouse_rel(17, -9, 1, 0.0); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::MouseRel { .. }), 10).await { + Some(HostEvent::MouseRel { dx, dy, buttons, .. }) => { + assert_eq!((dx, dy, buttons), (17, -9, 1)) + } + _ => panic!("relative mouse blocked despite perms on"), + } + // Translate-keyboard Unicode text flows through the same input gate. + session.text("h\u{00e9}llo"); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Text { .. }), 10).await { + Some(HostEvent::Text { text }) => assert_eq!(text, "h\u{00e9}llo"), + _ => panic!("text input blocked despite perms on"), + } + // Annotation strokes flow through the same input gate and reach the host. + session.annotate_stroke(0xFF_0000, 3, &[(10, 20), (30, 40)]); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Annot { .. }), 10).await { + Some(HostEvent::Annot { json }) => assert!(json.contains("stroke"), "annot json: {json}"), + _ => panic!("annotation blocked despite perms on"), + } + session.key("KeyA", true); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { down: true, .. }), 10) + .await + .is_some(), + "key blocked despite perms on" + ); + session.clipboard("hello"); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Clipboard { .. }), 10).await { + Some(HostEvent::Clipboard { text }) => assert_eq!(text, "hello"), + _ => panic!("clipboard blocked despite perms on"), + } + session.audio_set(1); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::AudioSource { .. }), 10).await { + Some(HostEvent::AudioSource { src }) => assert_eq!(src, 1), + _ => panic!("audio.set blocked despite perms on"), + } + session.list_roots(); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::File { .. }), 10) + .await + .is_some(), + "file.roots blocked despite perms on" + ); + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + println!("PERMS_ALLOW_OK all channels flow with default perms"); +} + +// --------------------------------------------------------------------------- +// 3) View-only: input gated OFF and back ON mid-session, enforced host-side. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn view_only_gates_input_live() { + let (viewer_relay, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), false); + let host_id = hcfg.host_id.clone(); + + let (frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let host_task = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let (vtx, _vrx) = mpsc::unbounded_channel::(); + let vcfg = ConnectConfig { + ws_url: "mem://relay".into(), + host_id, + password, + totp: String::new(), + takeover: false, + }; + let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect"); + + // Input flows before view-only… + session.mouse(10, 10, 0, 0.0, 0.0); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(), + "input should flow before view-only" + ); + + // …is dropped while it is on… + session.view_only(true); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewOnly { on: true }), 10) + .await + .is_some() + ); + session.mouse(20, 20, 1, 0.0, 0.0); + session.key("KeyB", true); + session.view_only(false); // ordered marker: everything above processed first + let between = collect_until_marker( + &mut hev_rx, + |e| matches!(e, HostEvent::ViewOnly { on: false }), + 10, + ) + .await; + for ev in &between { + assert!( + !matches!(ev, HostEvent::Mouse { .. } | HostEvent::Key { .. }), + "input leaked through host-enforced view-only: {ev:?}" + ); + } + + // …and flows again after it is off. + session.mouse(30, 30, 0, 0.0, 0.0); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(), + "input should flow again after view-only off" + ); + + session.close(); + drop(frame_tx); + let _ = timeout(Duration::from_secs(5), host_task).await; + println!("VIEW_ONLY_OK host-enforced input gate on/off mid-session"); +} + +// --------------------------------------------------------------------------- +// 4) Wire-level: session.accept advertises the REAL perms; approve mode holds +// the session and Accept/Reject finalizes/denies it. Drives the raw +// protocol by hand so the actual JSON on the wire is asserted. +// --------------------------------------------------------------------------- + +/// A parsed session.challenge from the host. +struct Challenge { + sid: String, + salt: String, + iters: u32, + nonce: String, + pub_b64: String, + totp: bool, +} + +/// Drive begin → challenge on a raw viewer-end transport; return the challenge. +async fn raw_challenge(viewer: &mut MemTransport, host_id: &str) -> Challenge { + viewer + .send_text(format!(r#"{{"t":"session.begin","id":"{host_id}","peer":"203.0.113.7","user":"tester@example.com"}}"#)) + .await + .unwrap(); + loop { + match timeout(Duration::from_secs(10), viewer.recv()).await { + Ok(Some(Ok(TransportMsg::Text(t)))) => { + let v: Value = serde_json::from_str(&t).unwrap(); + if v["t"] == "session.challenge" { + return Challenge { + sid: v["sid"].as_str().unwrap().to_string(), + salt: v["salt"].as_str().unwrap().to_string(), + iters: v["iters"].as_u64().unwrap() as u32, + nonce: v["nonce"].as_str().unwrap().to_string(), + pub_b64: v["pub"].as_str().unwrap_or("").to_string(), + totp: v["totp"].as_bool().unwrap_or(false), + }; + } + } + _ => panic!("no session.challenge"), + } + } +} + +/// Drive begin → challenge → auth (plain proof only, legacy viewer). Returns sid. +async fn raw_auth(viewer: &mut MemTransport, host_id: &str, password: &str) -> String { + let ch = raw_challenge(viewer, host_id).await; + let proof = password_proof(password, &ch.salt, &ch.nonce, ch.iters).unwrap(); + viewer + .send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid)) + .await + .unwrap(); + ch.sid +} + +async fn next_text(viewer: &mut MemTransport, secs: u64) -> Option { + loop { + match timeout(Duration::from_secs(secs), viewer.recv()).await { + Ok(Some(Ok(TransportMsg::Text(t)))) => { + return serde_json::from_str(&t).ok(); + } + Ok(Some(Ok(TransportMsg::Binary(_)))) => continue, + _ => return None, + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn accept_advertises_real_perms() { + let (mut viewer, host_relay) = mem_relay_pair(); + let perms = HostPerms { input: true, clipboard: false, files: true, audio: false }; + let (hcfg, password) = host_cfg(perms, false); + let host_id = hcfg.host_id.clone(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let _host = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + raw_auth(&mut viewer, &host_id, &password).await; + let accept = next_text(&mut viewer, 10).await.expect("session.accept"); + assert_eq!(accept["t"], "session.accept"); + let p = &accept["info"]["perms"]; + assert_eq!(p["input"], true, "perms.input must be advertised truthfully"); + assert_eq!(p["clipboard"], false, "perms.clipboard must be advertised truthfully"); + assert_eq!(p["files"], true, "perms.files must be advertised truthfully"); + assert_eq!(p["audio"], false, "perms.audio must be advertised truthfully"); + // Disabled audio also clears the audio capability flag. + assert_eq!(accept["info"]["audio"], false); + println!("PERMS_ADVERTISE_OK session.accept carries the enforced perms"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn approve_holds_then_rejects() { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), true); + let host_id = hcfg.host_id.clone(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let _host = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let sid = raw_auth(&mut viewer, &host_id, &password).await; + + // The platform gets the approve request with the relay-reported identity… + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10).await { + Some(HostEvent::ApproveRequest { sid: s, peer, user }) => { + assert_eq!(s, sid); + assert_eq!(peer, "203.0.113.7"); + assert_eq!(user, "tester@example.com"); + } + _ => panic!("no ApproveRequest emitted in approve mode"), + } + // …and the wire stays SILENT (no session.accept while held). + assert!( + timeout(Duration::from_millis(900), viewer.recv()).await.is_err(), + "session must be held (no accept) until the platform answers" + ); + + // Reject → session.deny with a clear reason. + hctl_tx + .send(HostControl::ApproveReply { sid: sid.clone(), accept: false }) + .unwrap(); + let deny = next_text(&mut viewer, 10).await.expect("session.deny"); + assert_eq!(deny["t"], "session.deny"); + assert_eq!(deny["sid"], sid.as_str()); + assert_eq!(deny["reason"], "not accepted by host"); + println!("APPROVE_REJECT_OK held session denied with 'not accepted by host'"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn approve_accept_finalizes_session() { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), true); + let host_id = hcfg.host_id.clone(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + let _host = tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let sid = raw_auth(&mut viewer, &host_id, &password).await; + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10) + .await + .is_some() + ); + hctl_tx + .send(HostControl::ApproveReply { sid: sid.clone(), accept: true }) + .unwrap(); + let accept = next_text(&mut viewer, 10).await.expect("session.accept"); + assert_eq!(accept["t"], "session.accept"); + assert_eq!(accept["sid"], sid.as_str()); + assert_eq!(accept["info"]["perms"]["input"], true); + assert!( + wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10) + .await + .is_some(), + "accepting must finalize the session (ViewerConnected)" + ); + println!("APPROVE_ACCEPT_OK held session finalized on Accept"); +} + +// --------------------------------------------------------------------------- +// 5) Device-key binding (MITM hardening): a viewer that binds the host's REAL +// ephemeral pub is accepted; one that binds a SWAPPED pub (what a malicious +// relay would do) is denied "key verification failed". Legacy viewers that +// send no proof_bind still connect (covered by the approve_* tests above). +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bound_proof_accepts_matching_key() { + // Rebuild host_cfg inline so we keep both host_id and password. + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), false); + let host_id = hcfg.host_id.clone(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let ch = raw_challenge(&mut viewer, &host_id).await; + assert!(!ch.pub_b64.is_empty(), "host must offer an ECDH pub (E2E)"); + let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap(); + // Bind the host's REAL pub (what an honest, un-swapped path yields). + let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &ch.pub_b64).unwrap(); + viewer + .send_text(format!( + r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#, + ch.sid, ch.pub_b64 + )) + .await + .unwrap(); + let accept = next_text(&mut viewer, 10).await.expect("reply"); + assert_eq!(accept["t"], "session.accept", "matching bound proof must be accepted"); + println!("BIND_OK matching channel-bound proof accepted"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bound_proof_rejects_swapped_key() { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg(HostPerms::default(), false); + let host_id = hcfg.host_id.clone(); + let (_frame_tx, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, _hev_rx) = mpsc::unbounded_channel::(); + let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + let ch = raw_challenge(&mut viewer, &host_id).await; + let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap(); + // A malicious relay swapped the host's pub: the viewer binds the ATTACKER's + // pub, but the host verifies against its own real pub → mismatch → deny. + // (A valid-looking but different P-256 point, base64 of 65 bytes 0x04||…) + let swapped = { + use netdesk_core::crypto::HostE2e; + HostE2e::new().our_pub_b64 // a different, real ECDH pub + }; + assert_ne!(swapped, ch.pub_b64, "the swapped pub must differ from the host's"); + let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &swapped).unwrap(); + viewer + .send_text(format!( + r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#, + ch.sid, swapped + )) + .await + .unwrap(); + let deny = next_text(&mut viewer, 10).await.expect("reply"); + assert_eq!(deny["t"], "session.deny", "a swapped-key bound proof must be denied"); + assert_eq!(deny["reason"], "key verification failed"); + println!("BIND_SWAP_OK relay key-swap detected + denied ('key verification failed')"); +} + +// --------------------------------------------------------------------------- +// 6) TOTP 2FA on the native host: the challenge advertises totp:true, a wrong/ +// missing code is denied "bad 2FA code", and a valid code is accepted. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn totp_required_rejects_missing_and_accepts_valid() { + use netdesk_core::crypto::{password_proof, totp_at}; + use std::time::{SystemTime, UNIX_EPOCH}; + let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // RFC 6238 test secret + + // --- wrong/missing code is denied --- + { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret); + let host_id = hcfg.host_id.clone(); + let (_ft, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, _hev) = mpsc::unbounded_channel::(); + let (_hc, hctl_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + let ch = raw_challenge(&mut viewer, &host_id).await; + assert!(ch.totp, "challenge must advertise totp:true when a secret is set"); + let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap(); + // Correct password, NO totp code → bad 2FA. + viewer + .send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid)) + .await + .unwrap(); + let deny = next_text(&mut viewer, 10).await.expect("reply"); + assert_eq!(deny["t"], "session.deny"); + assert_eq!(deny["reason"], "bad 2FA code"); + } + + // --- a valid, current code is accepted --- + { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret); + let host_id = hcfg.host_id.clone(); + let (_ft, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, _hev) = mpsc::unbounded_channel::(); + let (_hc, hctl_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + let ch = raw_challenge(&mut viewer, &host_id).await; + let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let code = totp_at(secret, now, 30, 6).unwrap(); + viewer + .send_text(format!( + r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","totp":"{code}"}}"#, + ch.sid + )) + .await + .unwrap(); + let accept = next_text(&mut viewer, 10).await.expect("reply"); + assert_eq!(accept["t"], "session.accept", "a valid TOTP code must be accepted"); + } + println!("TOTP_OK native host advertises + enforces TOTP (deny bad, accept valid)"); +} + +// --------------------------------------------------------------------------- +// 7) TCP tunnel routing (core): tunnel.open -> HostEvent::TunnelOpen; a raw +// [0xF3][tid][payload] frame (NOT E2E) -> HostEvent::TunnelData ONLY when +// that tid is open; HostControl::TunnelData -> a [0xF3] frame on the wire. +// Proves the server-relayed tunnel plumbing + the 0xF3 disambiguation. +// --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn tunnel_routing_core() { + let (mut viewer, host_relay) = mem_relay_pair(); + let (hcfg, _password) = host_cfg(HostPerms::default(), false); + let (_ft, frame_rx) = mpsc::unbounded_channel::(); + let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::(); + let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await + }); + + // The server asks the host to open tunnel tid=7 (no video session needed). + viewer + .send_text(r#"{"t":"tunnel.open","tid":7,"thost":"127.0.0.1","port":5555}"#.into()) + .await + .unwrap(); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelOpen { .. }), 10).await { + Some(HostEvent::TunnelOpen { tid, thost, port }) => { + assert_eq!((tid, thost.as_str(), port), (7, "127.0.0.1", 5555)); + } + _ => panic!("no TunnelOpen"), + } + + // Raw [0xF3][tid=7 LE][payload] -> TunnelData for an OPEN tid. + let mut frame = vec![0xF3u8]; + frame.extend_from_slice(&7u32.to_le_bytes()); + frame.extend_from_slice(b"hello-tcp"); + viewer.send_binary(frame).await.unwrap(); + match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelData { .. }), 10).await { + Some(HostEvent::TunnelData { tid, data }) => { + assert_eq!(tid, 7); + assert_eq!(&data, b"hello-tcp"); + } + _ => panic!("no TunnelData for an open tid"), + } + + // Outbound: HostControl::TunnelData -> a raw [0xF3][tid][data] on the wire. + hctl_tx + .send(HostControl::TunnelData { tid: 7, data: b"reply42".to_vec() }) + .unwrap(); + loop { + match timeout(Duration::from_secs(10), viewer.recv()).await { + Ok(Some(Ok(TransportMsg::Binary(b)))) => { + assert_eq!(b[0], 0xF3); + assert_eq!(u32::from_le_bytes([b[1], b[2], b[3], b[4]]), 7); + assert_eq!(&b[5..], b"reply42"); + break; + } + Ok(Some(Ok(TransportMsg::Text(_)))) => continue, + _ => panic!("no outbound tunnel frame"), + } + } + println!("TUNNEL_CORE_OK tunnel.open + [0xF3] route in/out, tid-gated"); +} diff --git a/native/docs/build-android.md b/native/docs/build-android.md new file mode 100644 index 0000000..b1a412f --- /dev/null +++ b/native/docs/build-android.md @@ -0,0 +1,180 @@ +# Building NetDesk for Android + +NetDesk's Android app is a Flutter UI (`ui/`) bound, through +[flutter_rust_bridge](https://cjycode.com/flutter_rust_bridge/) (FRB v2), to a +compiled Rust library. The Rust FFI layer is the **`netdesk-frb`** crate +(`frb/`), a `cdylib` that statically links **`netdesk-core`** (signaling, E2E +crypto, transport, H.264 decode). On Android the library ships inside the +APK/AAB as `lib//libnetdesk_frb.so` and the Dart side `dlopen`s it (the FRB +loader looks for the stem `netdesk_frb`). + +``` +netdesk-frb (+ netdesk-core) --cargo-ndk--> ui/android/app/src/main/jniLibs//libnetdesk_frb.so +Flutter (ui/) --gradle-----> release .apk / split .apk / .aab (signed) +``` + +--- + +## The one command + +From the repo root (`netdesk-native/`), in a shell whose `PATH` contains the +MinGW tools, cargo, and Flutter: + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 +``` + +That single command: + +1. verifies / auto-provisions the toolchain (cargo-ndk, the rustup Android + targets), +2. detects the Android NDK at `C:\dev\android-ndk`, +3. compiles `netdesk-frb` for **arm64-v8a, armeabi-v7a, x86_64** with cargo-ndk + and drops `libnetdesk_frb.so` straight into + `ui/android/app/src/main/jniLibs//`, +4. builds the release **fat APK**, the **per-ABI split APKs**, and the Play + **App Bundle (.aab)**. + +Useful switches: + +| Switch | Effect | +| --- | --- | +| `-Target apk\|split\|aab\|rust\|all` | Build only that deliverable (default `all`; `rust` = just the `.so`). | +| `-SkipRust` | Reuse the `.so` already in `jniLibs`, build Flutter only. | +| `-Abis arm64-v8a` | Restrict the ABIs (default all three). | +| `-GenerateKeystore` | Create the release keystore + `key.properties` first (see below). | +| `-MinSdk 21` | Android API the `.so` links against (must be `<=` Flutter `minSdk`). | +| `-NdkHome ` | Override the NDK location (default `C:\dev\android-ndk`). | + +--- + +## Toolchain + +| Component | Version / location used here | +| --- | --- | +| Rust | stable (cargo 1.99) on `PATH` | +| rustup targets | `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` | +| cargo-ndk | 4.1.2 (`cargo install cargo-ndk`) | +| Android NDK | r26d (`26.3.11579264`) at `C:\dev\android-ndk` (`ANDROID_NDK_HOME`) | +| Android SDK | `%LOCALAPPDATA%\Android\Sdk` | +| Flutter | `C:\dev\flutter\bin\flutter.bat` | +| JDK (keytool + Gradle) | Android Studio JBR (auto-detected) | +| nasm | on `PATH` (needed by `openh264` for the x86_64 ABI) | + +One-time setup (the build script also does the first three automatically): + +```powershell +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +cargo install cargo-ndk +# NDK: unzip the Android NDK r26+ to C:\dev\android-ndk (set ANDROID_NDK_HOME) +``` + +Recommended `PATH` (Git Bash form): + +```bash +export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH" +``` + +### What cargo-ndk runs + +The Rust step is exactly: + +``` +cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \ + -o ui/android/app/src/main/jniLibs --platform 21 \ + build --release --package netdesk-frb --lib +``` + +cargo-ndk sets the per-ABI clang/linker from the NDK, and the `-o` flag copies +each produced `libnetdesk_frb.so` into `jniLibs//`. The `.so` are build +artifacts (git-ignored) and are regenerated on every run. + +--- + +## Release signing + +Signing credentials live in `ui/android/key.properties`, which is **git-ignored** +(so are `*.keystore` / `*.jks`). A committable template is +`ui/android/key.properties.example`. + +`app/build.gradle.kts` loads `key.properties` when present and signs the release +build with it; when it is absent the release build falls back to **debug** +signing so `flutter run --release` still works during development. + +Create the upload keystore once (the password is read from the environment and +is **never** written into a committed file): + +```powershell +$env:NETDESK_KEYSTORE_PASSWORD = '' +powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore +``` + +This writes: + +* `ui/android/app/netdesk-release.keystore` - RSA 4096, alias `netdesk`, 10000-day validity, +* `ui/android/key.properties` - with the alias, store file, and the password you + supplied (local only; keep it out of version control). + +> Back up the keystore and remember the password. If you lose either you can no +> longer push updates to an app already published under that key (unless it uses +> Play App Signing with a separate upload key you can reset). + +--- + +## Outputs + +After a full build: + +| Deliverable | Path (under `ui/`) | +| --- | --- | +| Fat APK (all ABIs) | `build/app/outputs/flutter-apk/app-release.apk` | +| Split APKs (per ABI) | `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`, `-armeabi-v7a-`, `-x86_64-` | +| Play App Bundle | `build/app/outputs/bundle/release/app-release.aab` | + +The fat APK is convenient for side-loading / direct download; the split APKs are +smaller per device; the `.aab` is what Google Play requires. + +--- + +## Publishing to Google Play (AAB) + +1. Produce the signed bundle: + + ```powershell + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -Target aab + ``` + + (or run the default `all` target). The bundle is + `ui/build/app/outputs/bundle/release/app-release.aab`. + +2. In the [Play Console](https://play.google.com/console), create the app, then + upload the `.aab` under a release track (internal testing first). + +3. Enrol in **Play App Signing** (recommended). Google then holds the *app + signing key*; the keystore above becomes your *upload key*. If the upload key + is ever compromised you can ask Google to reset it - which is why App Signing + is preferred over shipping the raw signing key. + +4. `versionCode` / `versionName` come from `ui/pubspec.yaml` + (`version: x.y.z+build`). Bump the `+build` number for every Play upload - the + Console rejects a duplicate `versionCode`. + +--- + +## Troubleshooting + +* **"Android NDK not found"** - the script could not find a complete NDK at + `C:\dev\android-ndk` (it checks for `source.properties` + + `toolchains\llvm\prebuilt`). Finish extracting the NDK, set `ANDROID_NDK_HOME`, + or pass `-NdkHome `. Use `-SkipRust` to build only the Flutter layer + against an already-built `.so`. +* **x86_64 ABI fails in `openh264`** - that ABI needs `nasm` on `PATH`. arm64-v8a + and armeabi-v7a do not. Drop x86_64 with `-Abis arm64-v8a,armeabi-v7a` if you + only ship to real devices. +* **Gradle wants to download an NDK** - `app/build.gradle.kts` points AGP at + `ANDROID_NDK_HOME` when it is set, so the prebuilt (already stripped) `.so` are + packaged without a second NDK download. Make sure the env var is set (the build + script sets it). +* **`UnsatisfiedLinkError: libnetdesk_frb.so`** at runtime - the `.so` for the + device's ABI is missing from `jniLibs`. Re-run the Rust step (`-Target rust`) + for that ABI, or confirm the FRB loader stem is still `netdesk_frb`. diff --git a/native/docs/build-ios.md b/native/docs/build-ios.md new file mode 100644 index 0000000..7c70520 --- /dev/null +++ b/native/docs/build-ios.md @@ -0,0 +1,288 @@ +# NetDesk — iOS build (`.ipa`) + +How to build the signed NetDesk iOS app — the Flutter UI on top of the compiled +Rust `netdesk-core` engine — and ship it to TestFlight / the App Store. + +> **This only builds on a Mac.** iOS toolchains (Xcode, the iOS SDKs, +> `xcodebuild`, code signing) exist on macOS only. Nothing here can run on the +> Windows dev box — Windows is used to *author* these scripts, the Mac runs +> them. Everything below assumes macOS + Xcode. + +--- + +## 1. What gets built + +``` +core crate (netdesk-core, lib netdesk_core) + │ cargo build --release --target aarch64-apple-ios → libnetdesk_core.a (device) + │ cargo build --release --target aarch64-apple-ios-sim → libnetdesk_core.a (simulator) + ▼ +NetDeskCore.xcframework (xcodebuild -create-xcframework, both slices) + │ vendored via ui/ios/netdesk_core.podspec + pod 'netdesk_core' in the Podfile + ▼ +Flutter iOS Runner ── flutter build ipa ──► ui/build/ios/ipa/netdesk.ipa +``` + +The Rust static library is the FFI surface the Flutter UI binds to. Today that +is the workspace **`core`** crate (`crate-type = ["lib","staticlib","cdylib"]`, +lib name `netdesk_core` → `libnetdesk_core.a`). When the Phase-3 +`flutter_rust_bridge` wrapper crate lands under a different package name (e.g. +`netdesk-frb`), build it with the same script by overriding two variables: + +```bash +CORE_CRATE=netdesk-frb LIB_NAME=netdesk_frb packaging/build-ios.sh --team +``` + +--- + +## 2. Prerequisites + +### 2.1 Apple Developer Program — **$99/year** + +A paid **Apple Developer Program** membership (, +US$99/yr) is required to: + +- create a **distribution signing certificate**, +- register an **App ID** (bundle identifier) and a **provisioning profile**, +- upload to **App Store Connect / TestFlight**. + +The free personal team can only sideload to your own device for 7 days — not +enough to ship. Enroll first; enrollment can take a day or two to clear. + +### 2.2 Signing certificate + provisioning profile + +You need, inside your developer account: + +| Thing | Where | Notes | +|-------|-------|-------| +| **Team ID** | developer.apple.com → Membership | 10 chars, e.g. `ABCDE12345`. You pass this to the script. | +| **App ID / bundle id** | Certificates, IDs & Profiles → Identifiers | Must match the app's `PRODUCT_BUNDLE_IDENTIFIER` (default `space.netdesk.netdesk`, set via `--org`). | +| **Distribution certificate** | Certificates, IDs & Profiles → Certificates → *Apple Distribution* | One per team; Xcode can create it for you. | +| **Provisioning profile** | Certificates, IDs & Profiles → Profiles → *App Store* (or Ad Hoc) | Ties the App ID + certificate + devices. | + +**Easiest path — automatic signing.** Open the project once in Xcode, sign in +with your Apple ID, tick *Automatically manage signing*, pick your Team. Xcode +creates the certificate + profile for you and the command-line build reuses +them. `build-ios.sh` writes `DEVELOPMENT_TEAM` + `CODE_SIGN_STYLE = Automatic` +into `ui/ios/Flutter/{Release,Debug}.xcconfig`, so after that one-time Xcode +sign-in the whole build is non-interactive. + +**Manual signing** (CI, locked-down teams): download the distribution +certificate (`.p12`) and the provisioning profile (`.mobileprovision`), install +both into the login keychain / `~/Library/MobileDevice/Provisioning Profiles/`, +then set `signingStyle` to `manual` and add a `provisioningProfiles` dict in +`ui/ios/ExportOptions.plist`. + +### 2.3 Toolchains on the Mac + +```bash +# Xcode + command line tools +xcode-select --install # or install full Xcode from the App Store +sudo xcode-select -s /Applications/Xcode.app + +# Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" + +# Flutter SDK (>= 3.27, per ui/pubspec.yaml) — add its bin/ to PATH +# https://docs.flutter.dev/get-started/install/macos +flutter doctor + +# CocoaPods (Flutter will also install pods during the build) +sudo gem install cocoapods +``` + +> **PATH note.** On Windows the project builds with +> `export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH"`. +> On the **Mac** that line does not apply — just make sure `cargo`, `rustup`, +> `flutter` and `xcrun` are on PATH (`source $HOME/.cargo/env`, Flutter's +> `bin/` in your shell profile). + +--- + +## 3. Generate the Flutter iOS project (one time) + +`ui/` currently has `lib/` + `pubspec.yaml` but **no `ios/` folder** — it was +never generated (it's platform scaffolding, not source). Create it on the Mac: + +```bash +cd ui +flutter create --org space.netdesk --project-name netdesk --platforms=ios . +``` + +This adds `ui/ios/` (the `Runner` Xcode project, `Runner.xcworkspace`, +`Info.plist`, launch assets, the Flutter xcconfig layer) without touching your +`lib/` Dart code. The bundle identifier becomes `.netdesk`, i.e. +`space.netdesk.netdesk` — change `--org` to use your own (e.g. +`--org com.akronlogic` → `com.akronlogic.netdesk`), and register that exact id +as the App ID in your developer account. + +> On the Windows box the equivalent invocation (for reference only — it does not +> produce a buildable iOS app on Windows) is: +> `C:\dev\flutter\bin\flutter.bat create --platforms=ios .` + +**`build-ios.sh` runs this step for you** if `ui/ios/` does not exist, so you +can skip straight to §5. Run it by hand only if you want to pick the `--org` +yourself before the first build. + +--- + +## 4. The VideoToolbox decode path (why the frameworks matter) + +NetDesk's iOS H.264 decode is **hardware** via **VideoToolbox**, implemented in +[`core/src/decode_vt.rs`](../core/src/decode_vt.rs) and gated +`#[cfg(any(target_os = "macos", target_os = "ios"))]`. It feeds Annex-B → +AVCC-repacked access units into a `VTDecompressionSession`, builds the +`CMVideoFormatDescription` from the first keyframe's SPS/PPS, and asks for +32-bit BGRA output (VideoToolbox does the YUV→RGB on the GPU); openh264 is the +software fallback if the session can't start. + +That module links three Apple system frameworks directly with +`#[link(kind = "framework")]`: + +| Framework | Used for | +|-----------|----------| +| **VideoToolbox** | `VTDecompressionSession*` (the decoder itself) | +| **CoreMedia** | `CMVideoFormatDescription` / `CMSampleBuffer` / `CMBlockBuffer` | +| **CoreVideo** | `CVPixelBuffer*` (the decoded BGRA surface) | +| **CoreFoundation** | `CFDictionary`/`CFString`/`CFRelease` (via `core-foundation-sys`, for the pixel-buffer attributes) | + +A **static** Rust library records those `#[link]` directives in its metadata but +does **not** pull the frameworks into the final app link. So the podspec +(`ui/ios/netdesk_core.podspec`) names them explicitly: + +```ruby +s.frameworks = 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'CoreFoundation' +``` + +If you ever see link errors like `Undefined symbol: _VTDecompressionSessionCreate` +or `_CMVideoFormatDescriptionCreateFromH264ParameterSets`, a framework is +missing from that line. At runtime, the log line +`decode: VideoToolbox H.264 (hardware)` confirms the hardware path was selected. + +--- + +## 5. Build the `.ipa` — the exact commands + +### One command (recommended) + +```bash +# from the repo root, on the Mac: +packaging/build-ios.sh --team ABCDE12345 +``` + +Replace `ABCDE12345` with your 10-character Apple **Team ID**. That script does +every step in §6 and prints the path to the finished `.ipa`. Useful variants: + +```bash +packaging/build-ios.sh --team ABCDE12345 --method app-store # default: App Store / TestFlight +packaging/build-ios.sh --team ABCDE12345 --method ad-hoc # ad-hoc distribution +packaging/build-ios.sh --team ABCDE12345 --method development # dev devices +packaging/build-ios.sh --team ABCDE12345 --org com.akronlogic # bundle id com.akronlogic.netdesk (first run only) +packaging/build-ios.sh --skip-ipa # build NetDeskCore.xcframework only, no signing needed +``` + +Output: + +- `ui/build/ios/ipa/netdesk.ipa` — the signed app +- `packaging/dist/ios/NetDeskCore.xcframework` — the Rust core (also staged into `ui/ios/`) + +### The same steps by hand + +If you'd rather run it step by step (this is exactly what the script does): + +```bash +# 0. Rust iOS targets +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +# 1. Build the Rust core static lib for device + simulator +export IPHONEOS_DEPLOYMENT_TARGET=13.0 +cargo build --release -p netdesk-core --target aarch64-apple-ios +cargo build --release -p netdesk-core --target aarch64-apple-ios-sim + +# 2. Assemble the xcframework (delete any stale one first) +rm -rf packaging/dist/ios/NetDeskCore.xcframework +xcodebuild -create-xcframework \ + -library target/aarch64-apple-ios/release/libnetdesk_core.a \ + -library target/aarch64-apple-ios-sim/release/libnetdesk_core.a \ + -output packaging/dist/ios/NetDeskCore.xcframework + +# 3. Generate ui/ios if needed, then wire the xcframework in +cd ui +flutter create --org space.netdesk --project-name netdesk --platforms=ios . # only if ui/ios is missing +flutter pub get +cp -R ../packaging/dist/ios/NetDeskCore.xcframework ios/ +cp ../packaging/ios/netdesk_core.podspec ios/netdesk_core.podspec +# ... add `pod 'netdesk_core', :path => '.'` to ios/Podfile (Runner target), +# ... add DEVELOPMENT_TEAM + CODE_SIGN_STYLE=Automatic to ios/Flutter/*.xcconfig, +# ... write ios/ExportOptions.plist with your teamID + method. +cd ios && pod install && cd .. + +# 4. Build + sign the ipa +flutter build ipa --release --export-options-plist=ios/ExportOptions.plist +# → ui/build/ios/ipa/netdesk.ipa +``` + +### Ship it + +```bash +# TestFlight / App Store (App Store Connect API key): +xcrun altool --upload-app -f ui/build/ios/ipa/netdesk.ipa -t ios \ + --apiKey --apiIssuer +``` + +Or open `ui/build/ios/archive/Runner.xcarchive` in **Xcode → Window → +Organizer → Distribute App** and follow the GUI. + +--- + +## 6. What `build-ios.sh` automates (summary) + +1. **Preflight** — macOS, Xcode, `rustup`/`cargo`, `flutter`, and a Team ID (unless `--skip-ipa`). +2. **`rustup target add`** `aarch64-apple-ios aarch64-apple-ios-sim`. +3. **`cargo build --release`** the core staticlib for both targets (`IPHONEOS_DEPLOYMENT_TARGET=13.0`). +4. **`xcodebuild -create-xcframework`** → `NetDeskCore.xcframework`. +5. **`flutter create`** `ui/ios` if missing, copy the xcframework + podspec in, inject the pod into the Podfile, set the signing team in the Flutter xcconfigs, write `ExportOptions.plist`, `pod install`. +6. **`flutter build ipa`** with your team and export method. + +--- + +## 7. Gotchas + +- **Apple-silicon vs Intel Mac.** The simulator slice defaults to + `aarch64-apple-ios-sim` (Apple silicon). On an Intel Mac build the Intel + simulator slice instead: `SIM_TARGET=x86_64-apple-ios packaging/build-ios.sh …` + (and `rustup target add x86_64-apple-ios`). The device slice is always + `aarch64-apple-ios`. +- **First build compiles openh264 from source** (the software-decode fallback). + That needs the Xcode clang/C++ toolchain (present with Xcode) — the first + `cargo build` is slow; later ones are cached. +- **Deployment target.** Keep `IPHONEOS_DEPLOYMENT_TARGET`, the podspec + `platform :ios`, and the Runner's min iOS in sync (all `13.0` here). Mismatches + cause CocoaPods warnings or link errors. +- **Bundle id must match the App ID.** The provisioning profile is tied to a + specific bundle id; if `--org` doesn't match a registered App ID, signing + fails. Register the id in your developer account first. +- **Phase-3 FFI.** This build links the Rust core but the Flutter UI does not + *call* it yet (the Dart bindings are Phase 3: uncomment `flutter_rust_bridge` + + `ffi` in `ui/pubspec.yaml`, add `core/src/ffi.rs`, generate the header with + `cbindgen`, add `-headers` to the `xcodebuild -create-xcframework` call, and + run `flutter_rust_bridge_codegen generate`). Until then the archive may be + dead-stripped out of the app — harmless; the signed `.ipa` still builds. See + the commented `-force_load` block in `netdesk_core.podspec` to force a link + test before Phase 3. +- **`xcodebuild -create-xcframework` refuses to overwrite** an existing output — + the script `rm -rf`s the old xcframework first; do the same by hand. + +--- + +## 8. Files + +| Path | Role | +|------|------| +| `packaging/build-ios.sh` | the end-to-end Mac build script | +| `packaging/ios/netdesk_core.podspec` | CocoaPods spec vendoring the xcframework + system frameworks (copied to `ui/ios/`) | +| `packaging/ios/Podfile` | Flutter iOS Podfile template with the `netdesk_core` pod (used if `ui/ios/Podfile` is absent) | +| `packaging/dist/ios/NetDeskCore.xcframework` | build output — the compiled Rust core | +| `core/src/decode_vt.rs` | the VideoToolbox hardware-decode implementation | +| `docs/cross-platform-decode.md` | the per-platform decode design (VideoToolbox section) | diff --git a/native/docs/build-linux.md b/native/docs/build-linux.md new file mode 100644 index 0000000..bc480e2 --- /dev/null +++ b/native/docs/build-linux.md @@ -0,0 +1,189 @@ +# NetDesk — Linux build & packaging + +Build the native `netdesk-viewer` for Linux and bundle the two distributions +**NetDesk by Akronlogic** ships on Linux: + +| Edition | Artifact | Install? | +|--------------|-----------------------------------|----------------------------------------| +| **AppImage** | `NetDesk--x86_64.AppImage` | No — `chmod +x` and run anywhere | +| **.deb** | `netdesk__amd64.deb` | Yes — `apt install ./…` (launcher + icon) | + +Both are produced by [`packaging/build-linux.sh`](../packaging/build-linux.sh). +Everything it writes lands in **`packaging/dist/linux/`** (git-ignored). This is +the Linux counterpart of the Windows [`packaging/README.md`](../packaging/README.md). + +> This target must be **built on Linux** (x86_64). The viewer links the system +> FFmpeg/VAAPI libraries for the Linux hardware-decode path +> (`core/src/decode_vaapi.rs`), which are not present when cross-building from +> Windows or macOS. + +--- + +## 1. Build dependencies + +The Linux decode backend pulls in FFmpeg through the `ffmpeg-next` crate, whose +build script (`ffmpeg-sys-next`) locates `libav*` with **pkg-config** and +generates bindings with **libclang**. The GUI (eframe/egui on winit + glow) +needs OpenGL at runtime. Install the toolchain + dev libraries first. + +### Debian / Ubuntu + +```bash +sudo apt update +sudo apt install -y build-essential clang pkg-config nasm \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavfilter-dev libavdevice-dev libswresample-dev \ + libva-dev \ + libgl1-mesa-dev +``` + +- `build-essential` → the C/C++ linker (`cc`/`g++`) Rust and `openh264-sys2` need. +- `clang` → libclang for `ffmpeg-sys-next`'s bindgen. +- `pkg-config` → locates the FFmpeg and VAAPI libraries. +- `nasm` → assembler for the bundled `openh264` software-decode fallback. +- `libav*-dev` → FFmpeg ≥ 4 development libraries (the full set matches the + default features of `ffmpeg-next`). +- `libva-dev` → VAAPI headers for the hardware-decode path. +- `libgl1-mesa-dev` → OpenGL for the glow renderer. + +### Fedora (FFmpeg dev libs come from RPM Fusion) + +```bash +sudo dnf install -y gcc gcc-c++ clang pkgconf-pkg-config nasm \ + ffmpeg-devel libva-devel mesa-libGL-devel +``` + +### Optional — native file-picker + +The viewer's **File transfer → Upload…** button uses `rfd`, which is compiled +with **no Linux backend** in `viewer/Cargo.toml` (`default-features = false`). +The build and the app run fine without it, but the picker is a no-op on Linux +until `rfd` is given a backend feature: + +- **GTK 3** backend → add `features = ["gtk3"]` to `rfd`, then install + `libgtk-3-dev` (build) / `libgtk-3-0` (runtime). +- **XDG portal** backend (no GTK) → add `features = ["xdg-portal", "tokio"]`; + needs a running `xdg-desktop-portal` at runtime, nothing extra at build. + +This is the only reason `libgtk` is ever involved on Linux — it is **not** +required to build or run NetDesk as shipped. + +--- + +## 2. The one command + +```bash +export PATH="$HOME/.cargo/bin:$PATH" # ensure cargo/rustup are reachable +packaging/build-linux.sh +``` + +That runs `cargo build --release -p netdesk-viewer` +(target `x86_64-unknown-linux-gnu`) and then produces **both** the `.deb` and +the AppImage in `packaging/dist/linux/`: + +``` +packaging/dist/linux/ +├── netdesk_0.1.0_amd64.deb ← dpkg package (launcher + hicolor icons) +├── NetDesk-0.1.0-x86_64.AppImage ← portable single file +├── com.akronlogic.netdesk.desktop (generated launcher, shared by both) +├── AppDir/ (AppImage staging) +└── deb/ (.deb staging tree) +``` + +> The underlying build command on its own: +> ```bash +> cargo build --release -p netdesk-viewer --target x86_64-unknown-linux-gnu +> ``` + +### Flags + +| Flag | Effect | +|-----------------------|----------------------------------------------------| +| `--skip-build` | reuse an existing `target/.../release/netdesk-viewer` | +| `--no-appimage` | build the `.deb` only | +| `--no-deb` | build the AppImage only | +| `--target ` | override the Rust target (default `x86_64-unknown-linux-gnu`) | +| `LINUXDEPLOY=/path` | env: use a local `linuxdeploy` instead of downloading it | + +The AppImage step downloads **linuxdeploy** once (cached in +`packaging/dist/.tools/`) and runs it with `APPIMAGE_EXTRACT_AND_RUN=1`, so it +works in containers/CI without FUSE. linuxdeploy `ldd`-scans the binary and +bundles its shared libraries (`libavcodec`, `libavutil`, `libswscale`, …) into +the AppImage; `libGL`, `libX11`/`xcb`, Wayland and `libva` are intentionally +**not** bundled — they are `dlopen()`ed and must come from the host graphics +stack. The `.deb` instead declares those as package `Depends:`. + +--- + +## 3. VAAPI hardware-decode note + +NetDesk hardware-decodes the host's H.264 stream on Linux through +**VAAPI via FFmpeg/libavcodec** (`core/src/decode_vaapi.rs`): it opens the +libavcodec H.264 decoder with a VAAPI `hw_device_ctx` on the default DRI render +node (`/dev/dri/renderD128`), decodes on the GPU, pulls the surface to system +memory (NV12), crops to the true display size, and swscales to the packed RGB8 +`Frame` the viewer paints — see [`cross-platform-decode.md`](cross-platform-decode.md) +for the full backend matrix. + +**Hardware decode is optional at runtime.** `best_decoder()` tries VAAPI first +and **falls back to the openh264 software decoder** if no usable VAAPI device is +present, so headless / GPU-less boxes still work. VAAPI is a build dependency +(the libraries must link) but not a runtime requirement. + +To get the hardware path at runtime on the target machine: + +1. **Install a VAAPI driver** (the `.deb` lists these as `Recommends`): + - Intel (Broadwell+): `intel-media-va-driver` (iHD) + - Intel (older): `i965-va-driver` + - AMD / others (Mesa): `mesa-va-drivers` (radeonsi) +2. **Verify it:** `vainfo` should list an `H264` decode profile (e.g. + `VAProfileH264High : VAEntrypointVLD`). Install `vainfo` from `libva-utils`. +3. **Render-node access:** the user must be able to read + `/dev/dri/renderD128` — typically by membership of the `render` (or `video`) + group. +4. **Driver override (if needed):** `export LIBVA_DRIVER_NAME=iHD` (or `i965`, + `radeonsi`) when autodetection picks the wrong driver. + +Confirm which path is live from the viewer's log: + +``` +decode: VAAPI H.264 (FFmpeg hardware) ← hardware path selected +VAAPI init failed (...); using openh264 ... ← fell back to software +``` + +If it fell back, check `vainfo`, the `/dev/dri/renderD128` permissions, and +`LIBVA_DRIVER_NAME`. + +--- + +## 4. Troubleshooting + +- **`libavcodec` not found via pkg-config / bindgen errors** — the FFmpeg dev + libraries or `clang` are missing. Re-run the install command in §1. For FFmpeg + in a non-standard prefix, set `PKG_CONFIG_PATH`. +- **`openh264` build fails for want of an assembler** — install `nasm`. +- **AppImage not produced** (linuxdeploy download/FUSE error) — the script warns + and still writes the `.deb`. Supply a local tool with `LINUXDEPLOY=/path`, or + run with `--no-appimage`. `APPIMAGE_EXTRACT_AND_RUN=1` is already exported for + FUSE-less hosts. +- **`.deb` won't install — unmet FFmpeg dependency** — soname package names + differ per release; the control file lists `libavcodec60 | 59 | 58 | ffmpeg` + (and likewise for `libavutil`/`libswscale`). On an unusual release, installing + the distro `ffmpeg` package satisfies the catch-all. +- **Wrong window/taskbar icon on GNOME/Wayland** — the launcher file name and + its `Icon=` key both use the app-id `com.akronlogic.netdesk`, matching the + viewer's `with_app_id(...)`; for the `.deb`, `gtk-update-icon-cache` runs in + `postinst`. Log out/in if the shell cached the old icon. + +--- + +## 5. Test + +```bash +vainfo # confirm a VAAPI driver + H264 profile (optional) +cargo test -p netdesk-core # core decode/transport tests +./packaging/dist/linux/NetDesk-*.AppImage # smoke-test the viewer +``` + +Smoke-test end to end against a live host; the log line +`decode: VAAPI H.264 (FFmpeg hardware)` confirms the hardware path was selected. diff --git a/native/docs/build-macos.md b/native/docs/build-macos.md new file mode 100644 index 0000000..9093f1e --- /dev/null +++ b/native/docs/build-macos.md @@ -0,0 +1,293 @@ +# Building the NetDesk desktop app for macOS + +This builds the **same** egui viewer/host-panel binary the Windows editions +ship (crate `netdesk-viewer`) as a signed, notarized **universal** macOS app +(`NetDesk.app`) and a distributable `.dmg`. + +- One binary, two roles: the viewer window **and** the host panel, identical to + the Windows build — only the packaging here is Mac-specific. +- **Decode path:** on macOS the host H.264 (Annex-B) stream is decoded on the + Apple video engine through **VideoToolbox** — `core/src/decode_vt.rs` + (`VTDecompressionSession`, 32-bit BGRA output repacked to RGB8), gated + `#[cfg(any(target_os = "macos", target_os = "ios"))]`. It falls back to the + portable openh264 software decoder if the hardware path can't initialize. See + [`cross-platform-decode.md`](./cross-platform-decode.md). No VideoToolbox- + specific flags are needed at build time: the CoreMedia / CoreVideo / + VideoToolbox framework `#[link]`s resolve against the active SDK. + +> **Where this runs:** on the Mac (the M4), **not** the Windows dev box. Cross- +> compiling a macOS `.app` from Windows is not supported — codesign, notarytool, +> iconutil, lipo and hdiutil are all macOS-only tools. + +The script is [`packaging/build-macos.sh`](../packaging/build-macos.sh). All +outputs land in `packaging/dist/`. + +--- + +## TL;DR — the Mac build command + +Once the one-time Apple Developer setup below is done (Developer ID cert in the +keychain + a stored `notarytool` profile named `netdesk`), the whole signed + +notarized build is: + +```bash +# on the Mac, from the repo root: +export PATH="$HOME/.cargo/bin:$PATH" +bash packaging/build-macos.sh --notary-profile netdesk +``` + +That produces, in `packaging/dist/`: + +| Artifact | What it is | +|----------------------------------------|---------------------------------------------| +| `NetDesk.app` | signed + notarized + **stapled** universal app | +| `NetDesk-macOS-universal.dmg` | signed + notarized + stapled disk image (ship this) | + +Useful flags: `--skip-build` (reuse the per-target binaries), `--no-sign` +(local unsigned test build), `--no-notarize` (sign but skip the Apple round- +trip), `--identity "…"`, `--icon `, `--version `, `--entitlements +`. Run `bash packaging/build-macos.sh --help` for the full list. + +--- + +## Prerequisites (on the Mac) + +1. **Xcode command-line tools** (gives `clang`, the SDKs, `lipo`, `iconutil`, + `sips`, `hdiutil`, `codesign`, `xcrun`): + + ```bash + xcode-select --install + ``` + + A full Xcode install from the App Store also works. + +2. **Rust** via rustup. The script adds the two Apple-silicon/Intel std targets + for you, but you can pre-add them: + + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + rustup target add aarch64-apple-darwin x86_64-apple-darwin + ``` + +3. An **Apple Developer Program** membership (needed only for signing + + notarization — a `--no-sign` build needs none). Set that up once, below. + +--- + +## One-time Apple Developer setup + +You need a **Developer ID Application** certificate (this is the identity that +lets an app run on *any* Mac after notarization — distinct from the "Apple +Development" / "Apple Distribution" / Mac App Store certs) and a way for +`notarytool` to authenticate. + +### 1. Enrol in the Apple Developer Program + + — \$99/yr. You must be the **Account +Holder** (or have the Admin role) to create a Developer ID certificate. + +### 2. Create the "Developer ID Application" certificate + +Easiest, via Xcode: + +1. Xcode → **Settings… → Accounts**, sign in with the Apple ID on the team. +2. Select the team → **Manage Certificates…** +3. Click **+** → **Developer ID Application**. +4. Xcode creates the private key + cert and installs both into your **login + keychain**. (Back up the private key — it cannot be re-downloaded.) + +Or manually at → +**+** → *Developer ID → Developer ID Application*, upload a CSR made with +**Keychain Access → Certificate Assistant → Request a Certificate from a +Certificate Authority**, then double-click the downloaded `.cer` to install it. + +Verify it is present and usable for signing: + +```bash +security find-identity -v -p codesigning +# look for a line like: +# 1) ABCD… "Developer ID Application: Akronlogic LLC (TEAMID1234)" +``` + +The script auto-detects this identity; override with `--identity "Developer ID +Application: Akronlogic LLC (TEAMID1234)"` if you have more than one. + +### 3. Find your Team ID + + → **Membership details** → *Team ID* (a +10-character string, also shown in the parentheses of the identity name above). + +### 4. Create credentials for notarytool + +Two supported options — pick one: + +**A. App-specific password (simplest).** At → +*Sign-In and Security → App-Specific Passwords* → **+**, label it e.g. +`netdesk-notarytool`. Then store it once in the keychain as a named profile: + +```bash +xcrun notarytool store-credentials netdesk \ + --apple-id "you@akronlogic.com" \ + --team-id "TEAMID1234" \ + --password "abcd-efgh-ijkl-mnop" # the app-specific password +``` + +`netdesk` is the profile name you pass to `--notary-profile netdesk`. + +**B. App Store Connect API key.** Create a key at *App Store Connect → Users and +Access → Integrations → App Store Connect API* (role: *Developer*), download the +`AuthKey_XXXX.p8`, and store it: + +```bash +xcrun notarytool store-credentials netdesk \ + --key "/path/AuthKey_XXXXXXXX.p8" \ + --key-id "XXXXXXXXXX" \ + --issuer "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +``` + +> Instead of a stored profile you can pass credentials inline each run: +> `--apple-id you@akronlogic.com --team-id TEAMID1234` **plus** the app-specific +> password in the environment: `export NETDESK_NOTARY_PASSWORD='abcd-efgh-…'`. + +--- + +## What the script does (step by step) + +`packaging/build-macos.sh`: + +1. **rustup target add** `aarch64-apple-darwin` `x86_64-apple-darwin`. +2. **cargo build --release -p netdesk-viewer** once per target — producing + `target/aarch64-apple-darwin/release/netdesk-viewer` and the `x86_64` one. + (Cross-building the Intel slice on an Apple-silicon Mac uses the same SDK and + needs no extra tooling.) +3. **lipo -create** the two into one universal Mach-O at + `NetDesk.app/Contents/MacOS/NetDesk` (`lipo -info` prints `arm64 x86_64`). +4. **iconutil** builds `NetDesk.icns` from the largest PNG master it finds + (`assets/icon-1024.png` → `icon-512.png` → `icon.png`, or `--icon `): + `sips` resizes the master into a complete `.iconset` (16→1024 px, @1x/@2x), + then `iconutil -c icns` packs it into `Contents/Resources/NetDesk.icns`. +5. **Assembles `NetDesk.app`** — writes `Contents/Info.plist` with bundle id + **`com.akronlogic.netdesk`**, `CFBundleVersion` / `CFBundleShortVersionString` + = the workspace version (read from `Cargo.toml`, currently **0.1.0**), + `CFBundleExecutable`/`CFBundleIconFile` = `NetDesk`, `LSMinimumSystemVersion` + 11.0, `NSHighResolutionCapable`, plus a `PkgInfo` (`APPL????`). +6. **codesign** the executable and the bundle with the Developer ID Application + identity, **`--options runtime`** (hardened runtime, required to notarize) and + **`--timestamp`** (secure timestamp), then `codesign --verify --deep --strict`. +7. **Notarize + staple the `.app`**: `ditto -c -k --keepParent` it into a zip, + `xcrun notarytool submit … --wait`, then `xcrun stapler staple NetDesk.app` + so the ticket travels with the app offline. (Skip with `--no-staple-app`.) +8. **Build the `.dmg`** (`hdiutil create`, UDZO, with an `/Applications` + symlink for drag-install), **sign** it, **notarize** it + (`notarytool submit … --wait`), and **staple** it. + +If no Developer ID identity is found (or `--no-sign`), it still produces an +**unsigned** `.app` + `.dmg` and tells you so — handy for a local smoke test. +If signing works but no notarization credentials are present, it signs and +prints exactly how to add them, like the Windows installer script does when +NSIS is missing. + +--- + +## Exact commands (equivalent to what the script runs) + +If you want to drive it by hand, this is the full sequence (`VERSION=0.1.0`, +`ID="Developer ID Application: Akronlogic LLC (TEAMID1234)"`): + +```bash +export PATH="$HOME/.cargo/bin:$PATH" + +# 1-2. targets + release builds +rustup target add aarch64-apple-darwin x86_64-apple-darwin +cargo build --release -p netdesk-viewer --target aarch64-apple-darwin +cargo build --release -p netdesk-viewer --target x86_64-apple-darwin + +# 3. universal binary +mkdir -p packaging/dist/NetDesk.app/Contents/MacOS packaging/dist/NetDesk.app/Contents/Resources +lipo -create -output packaging/dist/NetDesk.app/Contents/MacOS/NetDesk \ + target/aarch64-apple-darwin/release/netdesk-viewer \ + target/x86_64-apple-darwin/release/netdesk-viewer + +# 4. icon (ICONSET fully populated 16..1024 as in the script) +iconutil -c icns NetDesk.iconset -o packaging/dist/NetDesk.app/Contents/Resources/NetDesk.icns + +# 5. Info.plist (the script writes com.akronlogic.netdesk + version 0.1.0) + +# 6. sign (hardened runtime + timestamp) +codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app/Contents/MacOS/NetDesk +codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app +codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app + +# 7. notarize + staple the app +ditto -c -k --keepParent packaging/dist/NetDesk.app packaging/dist/NetDesk.zip +xcrun notarytool submit packaging/dist/NetDesk.zip --keychain-profile netdesk --wait +xcrun stapler staple packaging/dist/NetDesk.app + +# 8. dmg -> sign -> notarize -> staple +hdiutil create -volname NetDesk -srcfolder \ + -fs HFS+ -format UDZO -ov packaging/dist/NetDesk-macOS-universal.dmg +codesign --force --timestamp --sign "$ID" packaging/dist/NetDesk-macOS-universal.dmg +xcrun notarytool submit packaging/dist/NetDesk-macOS-universal.dmg --keychain-profile netdesk --wait +xcrun stapler staple packaging/dist/NetDesk-macOS-universal.dmg +``` + +The script wraps all of this (with the full `.iconset` generation, the plist, +auto-detection, and graceful fallbacks) — prefer it over the manual steps. + +--- + +## Verify a finished build + +```bash +# signature valid + hardened runtime + Developer-ID authority +codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app +codesign -dv --verbose=4 packaging/dist/NetDesk.app 2>&1 | grep -E 'Authority|Timestamp|flags|Identifier' + +# Gatekeeper will accept it (Developer ID + notarized) +spctl -a -vvv --type exec packaging/dist/NetDesk.app + +# notarization ticket is stapled (works offline) +xcrun stapler validate packaging/dist/NetDesk.app +xcrun stapler validate packaging/dist/NetDesk-macOS-universal.dmg +``` + +`spctl` should print `source=Notarized Developer ID` and `accepted`. + +--- + +## Runtime permissions (host mode) + +Signing/notarization are **not** the macOS capture permissions. When the binary +runs as the **host** and first tries to capture the screen or inject input, +macOS prompts the user (TCC) to grant, under **System Settings → Privacy & +Security**: + +- **Screen Recording** — to capture the remote screen. +- **Accessibility** — to inject mouse/keyboard input (CGEvent). + +These are user-granted at runtime and are **not** code-signing entitlements, so +the default build needs no entitlements file. (If a future host feature drives +*other* apps via Apple Events, add an entitlements plist with the matching keys +and pass it via `--entitlements`.) + +--- + +## Troubleshooting + +- **`no identity found` / unsigned output** — the Developer ID Application cert + (and its private key) must be in the login keychain of the user running the + build; confirm with `security find-identity -v -p codesigning`. +- **Notarization `Invalid` / rejected** — fetch the log: + `xcrun notarytool log --keychain-profile netdesk`. Almost + always a missing `--options runtime` (hardened runtime) or a missing secure + `--timestamp`; the script sets both. +- **`The binary is not signed with a valid Developer ID certificate`** — an + *Apple Development* cert was used instead of *Developer ID Application*; only + the latter notarizes for distribution outside the App Store. +- **openh264 fails to cross-compile the x86_64 slice** — it builds C++ via the + `cc` crate; the Xcode CLT clang handles `-arch x86_64` on an arm Mac, so a + full `xcode-select --install` (or Xcode) is enough. If it still fails, build + only the native slice with `--target aarch64-apple-darwin` and drop the lipo + step (the app is then arm64-only). +- **Icon looks soft** — add a 1024×1024 `assets/icon-1024.png` master (the repo + currently ships 256 and 512); the script prefers the largest it finds. diff --git a/native/docs/cross-platform-decode.md b/native/docs/cross-platform-decode.md new file mode 100644 index 0000000..9fb1985 --- /dev/null +++ b/native/docs/cross-platform-decode.md @@ -0,0 +1,223 @@ +# Cross-platform hardware H.264 decode + +NetDesk's core decodes the host's H.264 (Annex-B) stream into packed-RGB8 +[`Frame`]s for the UI to paint. Every platform shares one trait and one output +type, so the viewer never knows which backend is running: + +```rust +pub struct Frame { pub w: usize, pub h: usize, pub rgb: Vec } // w*h*3 bytes +pub trait VideoDecoder { fn decode(&mut self, au: &[u8]) -> Result>; } +pub fn best_decoder() -> Box; +``` + +`best_decoder()` selects the fastest decoder for the current OS at runtime and +**always** falls back to the portable openh264 software decoder if the native +hardware path cannot initialize (no GPU, missing runtime, headless box, etc.). + +| Target | Backend | Module | API | Crate(s) | +|---------------|----------------|---------------------------|-----------------------------|---------------------------------| +| Windows | Media Foundation (DXVA2) | `core/src/decode_mf.rs` | `IMFTransform` (MSH264 MFT) | `windows` | +| macOS / iOS | VideoToolbox | `core/src/decode_vt.rs` | `VTDecompressionSession` | `core-foundation(-sys)` + frameworks | +| Android | MediaCodec | `core/src/decode_mediacodec.rs` | `AMediaCodec` (NDK) | `ndk-sys` / `libmediandk` | +| Linux | VAAPI | `core/src/decode_vaapi.rs` | FFmpeg/libavcodec + VAAPI | `ffmpeg-next` | +| All (fallback)| openh264 | `core/src/decode.rs` | software | `openh264` | + +Each native module is `#[cfg]`-gated to its own OS, so building for one target +never compiles (or even links) another target's module or its dependencies. The +per-platform crates are declared under `[target.'cfg(...)'.dependencies]` in +`core/Cargo.toml`; on a non-matching host they are only added to `Cargo.lock` +(resolved), never built, and their build scripts never run. + +All backends emit the **true display dimensions** (e.g. 1920×1080), cropping out +any codec padding / stride / alignment, so frames are never stretched. + +--- + +## Windows — Media Foundation (reference, builds here) + +Already shipping. Build with the portable MinGW toolchain: + +```bash +export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:$PATH" +cargo build -p netdesk-core # or: --target x86_64-pc-windows-gnu +``` + +### Dimension fix (2026-10) + +The MS H.264 decoder MFT reports the **coded** size in `MF_MT_FRAME_SIZE`, which +is padded up to the codec's macroblock alignment (1080p → a 1088+ coded height; +other resolutions pad larger — this is what produced the wrong `1920x18xx` in the +stats). The real picture rectangle is carried separately in a video-aperture +blob. `decode_mf.rs` now: + +1. Reads `MF_MT_FRAME_SIZE` into `coded_w`/`coded_h` — used **only** for the NV12 + plane layout (stride × plane height, and the UV plane offset). +2. Reads the display rectangle from, in priority order, + `MF_MT_MINIMUM_DISPLAY_APERTURE` → `MF_MT_GEOMETRIC_APERTURE` → + `MF_MT_PAN_SCAN_APERTURE` (the last only when `MF_MT_PAN_SCAN_ENABLED`), as an + `MFVideoArea` blob giving offset `(x, y)` and visible `width`×`height`. The + aperture is clamped inside the coded frame so a malformed blob can't over-read. +3. `nv12_to_rgb(..)` copies only the visible `width`×`height` region at + `(crop_x, crop_y)` out of the padded NV12 (system-memory and DXGI/D3D11 + staging paths both), so `Frame.w`/`Frame.h` are the true `1920×1080`. + +When no aperture is present the whole coded frame is treated as the picture +(unchanged behaviour). + +--- + +## macOS / iOS — VideoToolbox + +`decode_vt.rs`, gated `#[cfg(any(target_os = "macos", target_os = "ios"))]`. + +Feeds AUs into a `VTDecompressionSession` on the platform video engine and +requests 32-bit BGRA output (VideoToolbox does YUV→RGB on the GPU), which is +repacked to RGB8. The session is built lazily from the first keyframe's SPS/PPS +(`CMVideoFormatDescriptionCreateFromH264ParameterSets`); Annex-B is repacked to +AVCC for the `CMSampleBuffer`. `CVPixelBufferGetWidth/Height` already return the +cropped display size, so no manual crop is needed. + +Links the system **CoreMedia**, **CoreVideo** and **VideoToolbox** frameworks +directly; `core-foundation` is used for the pixel-buffer attribute dictionary. + +### Build (requires macOS + Xcode) + +```bash +xcode-select --install # or a full Xcode install +rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS +rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS + +cargo build -p netdesk-core --target aarch64-apple-darwin # Apple-silicon mac +cargo build -p netdesk-core --target aarch64-apple-ios # device +cargo build -p netdesk-core --target aarch64-apple-ios-sim # simulator +``` + +The framework `#[link]`s resolve against the SDK the active toolchain selects +(`xcrun --sdk macosx|iphoneos|iphonesimulator --show-sdk-path`). No extra flags. + +### Test + +```bash +cargo test -p netdesk-core --target aarch64-apple-darwin +``` + +Smoke-test end to end by running the viewer against a live host; the log line +`decode: VideoToolbox H.264 (hardware)` confirms the path was selected. + +--- + +## Android — MediaCodec + +`decode_mediacodec.rs`, gated `#[cfg(target_os = "android")]`. + +Drives a byte-buffer-mode `AMediaCodec` AVC decoder (the SoC hardware decoder via +OMX/Codec2). The codec is configured lazily from the first keyframe's SPS/PPS as +`csd-0`/`csd-1`; each `decode` queues the Annex-B AU as one input buffer and +dequeues one output buffer. Output is YUV 4:2:0 — NV12 semi-planar or I420 +planar depending on the device — converted to RGB8 honouring the format's +`stride`, `slice-height`, `color-format`, and `crop-left/top/right/bottom` keys, +so the emitted frame is the true display size. + +Talks to the stable NDK Media C ABI in **`libmediandk`** directly (the same ABI +the `ndk-sys` crate binds), so it is version-independent. + +### Build (requires the Android NDK) + +Install the NDK (via Android Studio's SDK Manager or `sdkmanager "ndk;27.0.x"`), +then use [`cargo-ndk`]: + +```bash +rustup target add aarch64-linux-android armv7-linux-androideabi \ + x86_64-linux-android i686-linux-android +cargo install cargo-ndk +export ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/27.0.12077973 + +# API 24+; links libmediandk / liblog automatically via the NDK sysroot. +cargo ndk -t arm64-v8a -t armeabi-v7a -p 24 build -p netdesk-core --release +``` + +Or without cargo-ndk, point Cargo at the NDK clang linker for each target, e.g.: + +```bash +export CC_aarch64_linux_android=$ANDROID_NDK_HOME/.../aarch64-linux-android24-clang +export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$CC_aarch64_linux_android +cargo build -p netdesk-core --target aarch64-linux-android --release +``` + +### Test + +Run on a device/emulator (the `mediandk` APIs are not present on the host): + +```bash +cargo ndk -t arm64-v8a build -p netdesk-core --tests +# push the test binary with `adb push` and run it on-device, or exercise it +# through the app's instrumented tests. +``` + +The log line `decode: MediaCodec H.264 (hardware)` confirms selection. + +--- + +## Linux — VAAPI (via FFmpeg) + +`decode_vaapi.rs`, gated `#[cfg(target_os = "linux")]`. + +Opens the libavcodec H.264 decoder with a VAAPI `hw_device_ctx` (default DRI +render node). libavcodec does all bitstream parsing; decode runs on the GPU, the +surface is pulled to system memory with `av_hwframe_transfer_data` (NV12), +cropped to the display rectangle with `av_frame_apply_cropping`, and converted to +RGB8 with swscale. If no VAAPI device is usable, `new()` errors and the core +falls back to openh264, so GPU-less / headless boxes still work. + +Pulls libav* in through the **`ffmpeg-next`** crate and needs the system FFmpeg +(≥ 4) development libraries and a VAAPI driver at build/run time. + +### Build (requires system FFmpeg + VAAPI dev packages) + +Debian/Ubuntu: + +```bash +sudo apt install -y clang pkg-config \ + libavcodec-dev libavutil-dev libswscale-dev libavformat-dev \ + libva-dev vainfo intel-media-va-driver # or mesa-va-drivers for AMD + +cargo build -p netdesk-core # host target +``` + +Fedora: + +```bash +sudo dnf install clang pkgconf-pkg-config \ + ffmpeg-devel libva-devel libva-utils mesa-va-drivers +cargo build -p netdesk-core +``` + +`ffmpeg-sys-next` locates the libraries with `pkg-config`; set +`PKG_CONFIG_PATH` if FFmpeg is in a non-standard prefix. `bindgen` needs +`clang`/`libclang` (the `clang` package above). + +### Test + +```bash +vainfo # confirm a VAAPI driver + H264 profile +cargo test -p netdesk-core +``` + +At runtime the log line `decode: VAAPI H.264 (FFmpeg hardware)` confirms the +hardware path; `VAAPI init failed (...); using openh264 software decode` means it +fell back (check `vainfo`, `/dev/dri/renderD128` permissions, and the +`LIBVA_DRIVER_NAME` driver). + +--- + +## Adding another backend + +1. Add `core/src/decode_.rs` implementing `VideoDecoder` + `unsafe impl + Send`, with a `new() -> Result` that errors when the platform can't + support it. +2. Declare the module in `decode.rs` behind the right `#[cfg(...)]`. +3. Add a `#[cfg(...)]` branch in `best_decoder()` before the openh264 fallback. +4. Add the deps under a matching `[target.'cfg(...)'.dependencies]` in + `core/Cargo.toml`. +5. Emit the **true display size** (crop any codec padding) and BT.601 limited- + range RGB to match the other backends. diff --git a/native/frb/Cargo.toml b/native/frb/Cargo.toml new file mode 100644 index 0000000..5849dd7 --- /dev/null +++ b/native/frb/Cargo.toml @@ -0,0 +1,32 @@ +# NetDesk flutter_rust_bridge (FRB v2) FFI layer. This crate is the Dart-callable +# surface over `netdesk-core`: the Flutter UI (ui/) calls `connect(...)` plus the +# input/control methods here, and receives a typed event stream (video frames, +# chat, clipboard, lifecycle) over a flutter_rust_bridge StreamSink. +# Developed by Akronlogic (https://akronlogic.com). +[package] +name = "netdesk-frb" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "NetDesk flutter_rust_bridge FFI layer — Dart-callable API over netdesk-core for the Flutter UI. Developed by Akronlogic." + +[lib] +name = "netdesk_frb" +# cdylib = the .so/.dll/.dylib the Flutter engine dlopen's on Android/Windows/Linux; +# staticlib = the .a linked into the iOS/macOS app; lib = so the codegen + tests +# can use the crate as a normal Rust library. +crate-type = ["cdylib", "staticlib", "lib"] + +# Silence the cosmetic `unexpected_cfgs` warning the `#[frb(sync)]` attribute +# macro emits (it references an internal `frb_expand` cfg); this is the fix +# flutter_rust_bridge documents. +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(frb_expand)'] } + +[dependencies] +netdesk-core = { path = "../core" } +# Pinned to the installed codegen (flutter_rust_bridge_codegen 2.13.0); FRB +# requires the runtime crate and the codegen to be the same version. +flutter_rust_bridge = "=2.13.0" +tokio = { workspace = true } diff --git a/native/frb/flutter_rust_bridge.yaml b/native/frb/flutter_rust_bridge.yaml new file mode 100644 index 0000000..c3cca2f --- /dev/null +++ b/native/frb/flutter_rust_bridge.yaml @@ -0,0 +1,6 @@ +# flutter_rust_bridge v2 codegen config for the NetDesk FFI layer. +# Run from this directory: flutter_rust_bridge_codegen generate +rust_input: crate::api +rust_root: . +dart_output: ../ui/lib/rust +dart_root: ../ui diff --git a/native/frb/src/api.rs b/native/frb/src/api.rs new file mode 100644 index 0000000..cbc8195 --- /dev/null +++ b/native/frb/src/api.rs @@ -0,0 +1,632 @@ +//! Dart-callable API (flutter_rust_bridge v2) over `netdesk-core`. +//! +//! Lifecycle from Dart: +//! ```dart +//! final handle = await connect(wsUrl: ..., hostId: ..., password: ..., totp: ...); +//! sessionEvents(handle: handle).listen((ev) { /* NdEvent */ }); +//! sendMouse(handle: handle, x: .., y: .., buttons: .., wheel: .., hwheel: ..); +//! disconnect(handle: handle); +//! ``` +//! +//! [`connect`] returns a `u64` **session handle** and starts the Tokio + +//! `netdesk_core::connect` work on a background runtime; it is split from +//! [`session_events`] because a flutter_rust_bridge function that takes a +//! `StreamSink` returns a Dart `Stream` (so it cannot also return the handle). +//! `connect` therefore buffers [`NdEvent`]s into a per-handle channel, and +//! `session_events` taps that channel as the Dart-facing stream. All input / +//! control calls ([`send_mouse`], [`send_key`], …) take the handle so the right +//! [`Session`] is found in the global session table. +//! +//! Threading: a single process-wide multi-threaded Tokio runtime owns every +//! session's network + decode task. The live [`Session`] is parked in a global +//! `Mutex>` so the (cheap, synchronous) input/control +//! functions can reach it from the Dart isolate thread without touching async. + +use crate::frb_generated::StreamSink; +use flutter_rust_bridge::frb; +use netdesk_core::decode::{best_decoder, best_decoder_for}; +use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame}; +use netdesk_core::{connect as core_connect, crypto, ConnectConfig, NdError, Session, SessionEvent}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Events streamed from the Rust core to the Flutter UI (the Dart-facing +/// projection of `netdesk_core`'s richer `SessionEvent` — video access units +/// are decoded to paint-ready RGBA frames here). +#[derive(Clone, Debug)] +pub enum NdEvent { + /// The WebSocket is dialing / the handshake is in flight. + Connecting, + /// Session accepted; the remote screen is about to stream. + Connected { + w: u32, + h: u32, + e2e: bool, + host_name: String, + }, + /// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`). + Frame { + width: u32, + height: u32, + rgba: Vec, + }, + /// The session ended (socket closed, host ended it, connect failed, …). + Ended { reason: String }, + /// The host refused the session (bad password/TOTP, busy, blocked, …). + Denied { reason: String }, + /// A chat message from the host. + ChatMessage { from: String, text: String }, + /// Remote clipboard text pushed by the host (RustDesk-style sync). + Clipboard { text: String }, +} + +/// Process-wide Tokio runtime that owns every session's network + decode task. +fn runtime() -> &'static tokio::runtime::Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("netdesk-frb: build tokio runtime") + }) +} + +/// Live sessions, keyed by the `u64` handle [`connect`] returns. +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); + SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Event receivers parked between [`connect`] (the producer) and +/// [`session_events`] (the Dart-facing stream), keyed by the same handle. The +/// channel is unbounded so events emitted before Dart subscribes are buffered, +/// not dropped. +fn pending() -> &'static Mutex>> { + static PENDING: OnceLock>>> = + OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-handle "the user asked to stop" flags. The reconnect loop checks this to +/// tell a deliberate [`disconnect`] (leave the session) from a network drop +/// (auto-reconnect). Shared as an `Arc` so the loop keeps its own reference even +/// after `disconnect` removes the map entry. +fn cancels() -> &'static Mutex>> { + static CANCELS: OnceLock>>> = OnceLock::new(); + CANCELS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Monotonic source of session handles. Starts at 1 so `0` can mean "none". +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); + +/// Expand tightly-packed RGB8 (what the decoders emit) to RGBA8 (what Flutter's +/// `ui.decodeImageFromPixels` / a texture wants), forcing full alpha. +fn rgb_to_rgba(rgb: &[u8], w: usize, h: usize) -> Vec { + let mut out = vec![0u8; w * h * 4]; + for (src, dst) in rgb.chunks_exact(3).zip(out.chunks_exact_mut(4)) { + dst[0] = src[0]; + dst[1] = src[1]; + dst[2] = src[2]; + dst[3] = 0xff; + } + out +} + +/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and +/// return its **handle**. The actual connect + handshake + H.264 decode run on +/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with +/// [`session_events`] (passing this handle), and drive input / control with the +/// other functions here. A failed connect still returns a valid handle and +/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream. +pub fn connect(ws_url: String, host_id: String, password: String, totp: String) -> u64 { + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + let cfg = ConnectConfig { + ws_url, + host_id, + password, + totp, + takeover: false, + }; + // UI-facing event channel; `session_events` drains the receiver into a sink. + let (ev_tx, ev_rx) = mpsc::unbounded_channel::(); + pending().lock().unwrap().insert(handle, ev_rx); + + // Cancel flag: `disconnect` sets it so the reconnect loop stops instead of + // re-dialing. Kept as an Arc so the loop holds its own reference even after + // `disconnect` removes the map entry. + let cancel = Arc::new(AtomicBool::new(false)); + cancels().lock().unwrap().insert(handle, cancel.clone()); + + runtime().spawn(async move { + // Resilient session: keep the remote alive across network drops, host + // restarts and roaming. On an unexpected disconnect we re-dial the SAME + // host under the SAME handle (so send_mouse/key keep routing) with a + // short backoff, surfacing a `Connecting` blip instead of ending. We + // stop only on a deliberate `disconnect` (cancel) or an auth `Denied`. + let mut backoff: u64 = 1; + let mut first = true; + let mut was_denied = false; + loop { + if cancel.load(Ordering::Relaxed) { + break; + } + if !first { + // reconnecting: tell the UI the link is being re-established. + let _ = ev_tx.send(NdEvent::Connecting); + } + let (tx, mut rx) = mpsc::unbounded_channel::(); + let session = match core_connect(cfg.clone(), tx).await { + Ok(s) => s, + Err(NdError::Denied(reason)) => { + // Bad password / TOTP / blocked / host offline: never + // succeeds on retry. Surface the real reason and stop. + let _ = ev_tx.send(NdEvent::Denied { reason }); + was_denied = true; + break; + } + Err(_) => { + // Transient (server/host not reachable yet): back off + retry. + if cancel.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + first = false; + continue; + } + }; + // Fresh connection: immediate keyframe + park under the handle. A new + // decoder per connection avoids carrying stale reference frames over + // the gap. + session.request_keyframe(); + sessions().lock().unwrap().insert(handle, session); + backoff = 1; + first = false; + + let mut decoder = best_decoder(); + let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec) + while let Some(ev) = rx.recv().await { + match ev { + SessionEvent::Connecting => { + let _ = ev_tx.send(NdEvent::Connecting); + } + SessionEvent::Connected { + host_name, + e2e, + w, + h, + } => { + let _ = ev_tx.send(NdEvent::Connected { w, h, e2e, host_name }); + } + SessionEvent::Video { au, keyframe, codec, .. } => { + // Follow the host's per-frame codec tag (H.264 / VP9), + // swapping the decoder only on a keyframe. + if codec != cur_codec { + if keyframe { + decoder = best_decoder_for(codec); + cur_codec = codec; + } else { + continue; + } + } + // Feed the access unit to the active decoder; emit a frame + // only when it produces a picture. + if let Ok(Some(frame)) = decoder.decode(&au) { + let rgba = rgb_to_rgba(&frame.rgb, frame.w, frame.h); + let _ = ev_tx.send(NdEvent::Frame { + width: frame.w as u32, + height: frame.h as u32, + rgba, + }); + } + } + SessionEvent::ChatMessage { from, text } => { + let _ = ev_tx.send(NdEvent::ChatMessage { from, text }); + } + SessionEvent::Clipboard { text } => { + let _ = ev_tx.send(NdEvent::Clipboard { text }); + } + SessionEvent::Denied { reason } => { + let _ = ev_tx.send(NdEvent::Denied { reason }); + was_denied = true; + break; + } + SessionEvent::Ended { .. } => { + // Connection dropped: leave the inner loop and reconnect + // (unless the user asked to stop, checked below). + break; + } + // Challenged / Audio / Info / SysResult / file-transfer events + // are not surfaced to the mobile UI in this phase. + _ => {} + } + } + sessions().lock().unwrap().remove(&handle); + if was_denied || cancel.load(Ordering::Relaxed) { + break; + } + // Unexpected drop: brief pause, then reconnect (top emits Connecting). + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } + // Finished for good (user left or auth denied): clean up + close the UI + // stream so Dart's `.listen` onDone fires. + sessions().lock().unwrap().remove(&handle); + cancels().lock().unwrap().remove(&handle); + // Only emit a generic Ended when the user left (cancel). A Denied already + // carried its real reason (wrong password / host offline / busy) - don't + // overwrite it with "disconnected". + if !was_denied { + let _ = ev_tx.send(NdEvent::Ended { + reason: "disconnected".into(), + }); + } + }); + + handle +} + +/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`]. +/// In Dart this is `Stream sessionEvents({required BigInt handle})`. +/// Call it once per handle, right after `connect`. +pub fn session_events(handle: u64, sink: StreamSink) { + let rx = pending().lock().unwrap().remove(&handle); + if let Some(mut rx) = rx { + runtime().spawn(async move { + while let Some(ev) = rx.recv().await { + if sink.add(ev).is_err() { + break; // Dart dropped the subscription + } + } + }); + } +} + +/// Run a helper against the live session for `handle`, if it still exists. +fn with_session(handle: u64, f: impl FnOnce(&Session) -> R) -> Option { + sessions().lock().unwrap().get(&handle).map(f) +} + +/// Send a pointer event (coordinates in the remote screen's pixel space). +/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas. +#[frb(sync)] +pub fn send_mouse(handle: u64, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) { + with_session(handle, |s| s.mouse(x, y, buttons, wheel, hwheel)); +} + +/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter"). +#[frb(sync)] +pub fn send_key(handle: u64, code: String, down: bool) { + with_session(handle, |s| s.key(&code, down)); +} + +/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`. +#[frb(sync)] +pub fn set_quality(handle: u64, fps: u32, bitrate: u32, scale: f32) { + with_session(handle, |s| s.set_quality(fps, bitrate, scale)); +} + +/// Switch the captured monitor to index `i` (as advertised by +/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the +/// new display paints without waiting for the next natural IDR — the messages +/// travel the same WebSocket in order, so the keyframe is of the *new* monitor. +#[frb(sync)] +pub fn monitor_switch(handle: u64, i: i32) { + with_session(handle, |s| { + s.monitor_switch(i); + s.request_keyframe(); + }); +} + +/// A display the host advertised at accept time (index + pixel size). +#[derive(Clone, Debug)] +pub struct NdMonitor { + pub i: i32, + pub w: u32, + pub h: u32, +} + +/// The real list of displays the host reported (its `info.monitors`). The UI +/// uses this to show exactly the monitors that exist — never a hardcoded count. +/// Returns empty until the session is accepted, or if the host sent none. +#[frb(sync)] +pub fn session_monitors(handle: u64) -> Vec { + with_session(handle, |s| { + s.monitors() + .iter() + .map(|m| NdMonitor { + i: m.i, + w: m.w, + h: m.h, + }) + .collect() + }) + .unwrap_or_default() +} + +/// The monitor index the host is currently capturing (as reported at accept). +#[frb(sync)] +pub fn session_current_monitor(handle: u64) -> i32 { + with_session(handle, |s| s.current_monitor()).unwrap_or(0) +} + +/// Send Ctrl+Alt+Del on the host. +#[frb(sync)] +pub fn ctrl_alt_del(handle: u64) { + with_session(handle, |s| s.ctrl_alt_del()); +} + +/// Send a chat message to the host. +#[frb(sync)] +pub fn send_chat(handle: u64, text: String) { + with_session(handle, |s| s.chat(&text)); +} + +/// Push text to the remote clipboard (and announce our local clipboard). +#[frb(sync)] +pub fn set_clipboard(handle: u64, text: String) { + with_session(handle, |s| s.clipboard(&text)); +} + +/// End the session: tell the host, then drop the handle. The event stream +/// receives a final [`NdEvent::Ended`] when the network task unwinds. +#[frb(sync)] +pub fn disconnect(handle: u64) { + // Tell the reconnect loop to stop re-dialing, then tear down the live + // connection. The loop sees the cancel flag after the socket closes and + // exits instead of auto-reconnecting. + if let Some(c) = cancels().lock().unwrap().get(&handle) { + c.store(true, Ordering::Relaxed); + } + pending().lock().unwrap().remove(&handle); + if let Some(s) = sessions().lock().unwrap().remove(&handle) { + s.close(); + } +} + +// =========================================================================== +// HOST role — let a viewer connect to and control THIS device (Android host). +// Mirrors the viewer API: host_start() returns a handle; host_events() taps the +// input/control stream; host_push_frame() feeds encoded H.264 from the platform +// encoder (MediaProjection+MediaCodec); host_stop() tears it down. The run loop +// auto-reconnects (backoff 1-5s) so the device stays reachable. +// =========================================================================== + +/// Dart-facing projection of `netdesk_core::host::HostEvent`. +#[derive(Clone, Debug)] +pub enum NdHostEvent { + /// Registered with the server; the device is reachable at its host id. + Registered, + /// A viewer authenticated; the session is live (inject input, feed frames). + ViewerConnected { e2e: bool }, + /// The viewer left; the host stays registered for the next one. + ViewerLeft, + /// Pointer in the captured image's pixel space (buttons = bitmask). + Mouse { x: i32, y: i32, buttons: u32, wheel: f32 }, + /// Key: DOM-style code + down/up. + Key { code: String, down: bool }, + /// The viewer asked for a fresh keyframe — request one from the encoder. + KeyframeRequested, + /// The viewer changed quality targets. + Quality { fps: u32, bitrate: u32, scale: f32 }, + /// The server link dropped; auto-reconnecting. + Reconnecting, + /// The host loop stopped for good (host_stop / fatal). + Ended { reason: String }, +} + +fn map_host_event(e: HostEvent) -> Option { + Some(match e { + HostEvent::Registered => NdHostEvent::Registered, + HostEvent::ViewerConnected { e2e, .. } => NdHostEvent::ViewerConnected { e2e }, + HostEvent::ViewerLeft => NdHostEvent::ViewerLeft, + HostEvent::Mouse { x, y, buttons, wheel } => NdHostEvent::Mouse { x, y, buttons, wheel }, + HostEvent::Key { code, down } => NdHostEvent::Key { code, down }, + // Relative/game mouse, Unicode (translate-keyboard) text, and the + // annotation overlay are desktop-host input/overlay modes the native + // engine handles directly; the Android host bridge doesn't surface them. + HostEvent::MouseRel { .. } => return None, + HostEvent::Text { .. } => return None, + HostEvent::Annot { .. } => return None, + HostEvent::KeyframeRequested => NdHostEvent::KeyframeRequested, + HostEvent::Quality { fps, bitrate, scale } => NdHostEvent::Quality { fps, bitrate, scale }, + HostEvent::Ended { reason } => NdHostEvent::Ended { reason }, + // Android host clipboard-to-host / sys / file controls are not wired yet. + HostEvent::Clipboard { .. } => return None, + HostEvent::ClipboardImage { .. } => return None, + HostEvent::Sys { .. } => return None, + HostEvent::File { .. } => return None, + HostEvent::FileChunk { .. } => return None, + // Voice-call playback on the Android host is not wired yet (needs an + // Android AudioTrack sink); the native desktop host handles it. + HostEvent::Voice { .. } => return None, + // Adaptive-QoS telemetry is consumed by the native engine, not the UI. + HostEvent::NetStat { .. } => return None, + // The viewer's audio-source pick is handled by the native capture/audio + // engine; the Android host UI doesn't surface it. + HostEvent::AudioSource { .. } => return None, + // Codec pin is consumed by the native capture engine's codec selector. + HostEvent::PinCodec { .. } => return None, + // Codec capability is consumed by the native engine's codec selector. + HostEvent::ViewerCaps { .. } => return None, + // Monitor switching is handled by the native capture engine (the Android + // host is single-display). + HostEvent::SwitchMonitor { .. } => return None, + // Approve-before-connect is off on Android (cfg.approve=false), so this + // never fires; view-only is enforced inside the core engine already. + HostEvent::ApproveRequest { .. } => return None, + HostEvent::ViewOnly { .. } => return None, + // TCP tunneling is a desktop-host feature; the Android host doesn't offer it. + HostEvent::TunnelOpen { .. } => return None, + HostEvent::TunnelClose { .. } => return None, + HostEvent::TunnelData { .. } => return None, + // Interactive terminal is a desktop-host feature; not on the Android host. + HostEvent::TermOpen { .. } => return None, + HostEvent::TermInput { .. } => return None, + HostEvent::TermResize { .. } => return None, + HostEvent::TermClose { .. } => return None, + }) +} + +type FrameSlot = Arc>>>; + +/// Per-handle current frame sender (swapped each (re)connect so pushed frames go +/// to the live run, or are dropped between runs). +fn host_frames() -> &'static Mutex> { + static M: OnceLock>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-handle Dart-facing event receivers, parked until host_events() taps them. +fn host_pending() -> &'static Mutex>> { + static M: OnceLock>>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-handle "stop" flags for the host reconnect loop. +fn host_cancels() -> &'static Mutex>> { + static M: OnceLock>>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A fresh 9-digit host id to persist on the device (shown to viewers). +#[frb(sync)] +pub fn host_gen_id() -> String { + crypto::gen_host_id() +} + +/// A fresh friendly unattended password (no look-alike chars). +#[frb(sync)] +pub fn host_gen_password() -> String { + crypto::gen_password(8) +} + +/// Start hosting: register `host_id` with the server and serve one viewer at a +/// time, authenticating against `password`. Returns a handle; drive input/ +/// control via [`host_events`], feed video via [`host_push_frame`], stop with +/// [`host_stop`]. Credentials are derived fresh from `password` each run (the +/// challenge carries the salt, so the viewer only needs the password). +pub fn host_start( + ws_url: String, + host_id: String, + name: String, + password: String, + width: u32, + height: u32, +) -> u64 { + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&password); + let cfg = HostConfig { + ws_url, + host_id, + name, + salt_b64, + iters, + verifier_hex, + width, + height, + audio: false, + monitors: Vec::new(), + monitor: 0, + // Android host: all channels allowed (the app's own permission flow — + // MediaProjection consent + accessibility — is the gate), no approve + // prompt (the consent dialog already is one). + perms: Default::default(), + approve: false, + totp_secret: String::new(), + }; + + let frame_slot: FrameSlot = Arc::new(Mutex::new(None)); + host_frames().lock().unwrap().insert(handle, frame_slot.clone()); + let (ev_tx, ev_rx) = mpsc::unbounded_channel::(); + host_pending().lock().unwrap().insert(handle, ev_rx); + let cancel = Arc::new(AtomicBool::new(false)); + host_cancels().lock().unwrap().insert(handle, cancel.clone()); + + runtime().spawn(async move { + // One core-event channel for the whole host lifetime; the mapper forwards + // (and translates) to the Dart-facing stream across reconnects. + let (core_tx, mut core_rx) = mpsc::unbounded_channel::(); + let ev_tx_map = ev_tx.clone(); + let mapper = runtime().spawn(async move { + while let Some(e) = core_rx.recv().await { + if let Some(m) = map_host_event(e) { + if ev_tx_map.send(m).is_err() { + break; + } + } + } + }); + + let mut backoff: u64 = 1; + loop { + if cancel.load(Ordering::Relaxed) { + break; + } + let (ftx, frx) = mpsc::unbounded_channel::(); + *frame_slot.lock().unwrap() = Some(ftx); + // Android host does not push outbound control yet; drop the sender so + // run_host disables that arm. + let (_ctl_tx, ctl_rx) = mpsc::unbounded_channel::(); + let _ = run_host(cfg.clone(), frx, core_tx.clone(), ctl_rx).await; + *frame_slot.lock().unwrap() = None; // drop frames while offline + if cancel.load(Ordering::Relaxed) { + break; + } + let _ = ev_tx.send(NdHostEvent::Reconnecting); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } + + mapper.abort(); + host_frames().lock().unwrap().remove(&handle); + host_cancels().lock().unwrap().remove(&handle); + let _ = ev_tx.send(NdHostEvent::Ended { + reason: "stopped".into(), + }); + }); + + handle +} + +/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`]. +pub fn host_events(handle: u64, sink: StreamSink) { + let rx = host_pending().lock().unwrap().remove(&handle); + if let Some(mut rx) = rx { + runtime().spawn(async move { + while let Some(ev) = rx.recv().await { + if sink.add(ev).is_err() { + break; + } + } + }); + } +} + +/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap +/// + non-blocking; dropped when no viewer is connected. +#[frb(sync)] +pub fn host_push_frame(handle: u64, data: Vec, keyframe: bool) { + if let Some(slot) = host_frames().lock().unwrap().get(&handle) { + if let Some(tx) = slot.lock().unwrap().as_ref() { + // Android host encodes H.264 (MediaCodec) -> codec id 0. + let _ = tx.send(HostFrame { data, keyframe, codec: 0 }); + } + } +} + +/// Stop hosting: end the reconnect loop and drop the handle. +#[frb(sync)] +pub fn host_stop(handle: u64) { + if let Some(c) = host_cancels().lock().unwrap().get(&handle) { + c.store(true, Ordering::Relaxed); + } + // dropping the frame sender unblocks run_host so the loop can see `cancel` + if let Some(slot) = host_frames().lock().unwrap().get(&handle) { + *slot.lock().unwrap() = None; + } + host_pending().lock().unwrap().remove(&handle); +} diff --git a/native/frb/src/frb_generated.rs b/native/frb/src/frb_generated.rs new file mode 100644 index 0000000..8e88a58 --- /dev/null +++ b/native/frb/src/frb_generated.rs @@ -0,0 +1,1350 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow, + mismatched_lifetime_syntaxes +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.13.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 389194750; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__connect_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "connect", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_ws_url = ::sse_decode(&mut deserializer); + let api_host_id = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + let api_totp = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::connect( + api_ws_url, + api_host_id, + api_password, + api_totp, + ))?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__ctrl_alt_del_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "ctrl_alt_del", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::ctrl_alt_del(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__disconnect_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "disconnect", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::disconnect(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_events_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_events", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_events(api_handle, api_sink); + })?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__host_gen_id_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_gen_id", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_gen_id())?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_gen_password_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_gen_password", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_gen_password())?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_push_frame_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_push_frame", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_data = >::sse_decode(&mut deserializer); + let api_keyframe = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_push_frame(api_handle, api_data, api_keyframe); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__host_start_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_start", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_ws_url = ::sse_decode(&mut deserializer); + let api_host_id = ::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + let api_width = ::sse_decode(&mut deserializer); + let api_height = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::host_start( + api_ws_url, + api_host_id, + api_name, + api_password, + api_width, + api_height, + ))?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__host_stop_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "host_stop", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::host_stop(api_handle); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__monitor_switch_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "monitor_switch", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_i = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::monitor_switch(api_handle, api_i); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_chat_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_chat", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_chat(api_handle, api_text); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_key_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_key", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_code = ::sse_decode(&mut deserializer); + let api_down = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_key(api_handle, api_code, api_down); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_mouse_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_mouse", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_x = ::sse_decode(&mut deserializer); + let api_y = ::sse_decode(&mut deserializer); + let api_buttons = ::sse_decode(&mut deserializer); + let api_wheel = ::sse_decode(&mut deserializer); + let api_hwheel = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::send_mouse( + api_handle, + api_x, + api_y, + api_buttons, + api_wheel, + api_hwheel, + ); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__session_current_monitor_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_current_monitor", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::session_current_monitor(api_handle))?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__session_events_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_events", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::session_events(api_handle, api_sink); + })?; + std::result::Result::Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__session_monitors_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "session_monitors", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::session_monitors(api_handle))?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__set_clipboard_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_clipboard", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::set_clipboard(api_handle, api_text); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__set_quality_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_quality", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_handle = ::sse_decode(&mut deserializer); + let api_fps = ::sse_decode(&mut deserializer); + let api_bitrate = ::sse_decode(&mut deserializer); + let api_scale = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ + crate::api::set_quality(api_handle, api_fps, api_bitrate, api_scale); + })?; + std::result::Result::Ok(output_ok) + })()) + }, + ) +} + +// Section: dart2rust + +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode + for StreamSink +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_f32::().unwrap() + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for crate::api::NdEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::NdEvent::Connecting; + } + 1 => { + let mut var_w = ::sse_decode(deserializer); + let mut var_h = ::sse_decode(deserializer); + let mut var_e2E = ::sse_decode(deserializer); + let mut var_hostName = ::sse_decode(deserializer); + return crate::api::NdEvent::Connected { + w: var_w, + h: var_h, + e2e: var_e2E, + host_name: var_hostName, + }; + } + 2 => { + let mut var_width = ::sse_decode(deserializer); + let mut var_height = ::sse_decode(deserializer); + let mut var_rgba = >::sse_decode(deserializer); + return crate::api::NdEvent::Frame { + width: var_width, + height: var_height, + rgba: var_rgba, + }; + } + 3 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdEvent::Ended { reason: var_reason }; + } + 4 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdEvent::Denied { reason: var_reason }; + } + 5 => { + let mut var_from = ::sse_decode(deserializer); + let mut var_text = ::sse_decode(deserializer); + return crate::api::NdEvent::ChatMessage { + from: var_from, + text: var_text, + }; + } + 6 => { + let mut var_text = ::sse_decode(deserializer); + return crate::api::NdEvent::Clipboard { text: var_text }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::NdHostEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::NdHostEvent::Registered; + } + 1 => { + let mut var_e2E = ::sse_decode(deserializer); + return crate::api::NdHostEvent::ViewerConnected { e2e: var_e2E }; + } + 2 => { + return crate::api::NdHostEvent::ViewerLeft; + } + 3 => { + let mut var_x = ::sse_decode(deserializer); + let mut var_y = ::sse_decode(deserializer); + let mut var_buttons = ::sse_decode(deserializer); + let mut var_wheel = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Mouse { + x: var_x, + y: var_y, + buttons: var_buttons, + wheel: var_wheel, + }; + } + 4 => { + let mut var_code = ::sse_decode(deserializer); + let mut var_down = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Key { + code: var_code, + down: var_down, + }; + } + 5 => { + return crate::api::NdHostEvent::KeyframeRequested; + } + 6 => { + let mut var_fps = ::sse_decode(deserializer); + let mut var_bitrate = ::sse_decode(deserializer); + let mut var_scale = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Quality { + fps: var_fps, + bitrate: var_bitrate, + scale: var_scale, + }; + } + 7 => { + return crate::api::NdHostEvent::Reconnecting; + } + 8 => { + let mut var_reason = ::sse_decode(deserializer); + return crate::api::NdHostEvent::Ended { reason: var_reason }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::NdMonitor { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_i = ::sse_decode(deserializer); + let mut var_w = ::sse_decode(deserializer); + let mut var_h = ::sse_decode(deserializer); + return crate::api::NdMonitor { + i: var_i, + w: var_w, + h: var_h, + }; + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__host_events_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__host_start_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__session_events_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 2 => wire__crate__api__ctrl_alt_del_impl(ptr, rust_vec_len, data_len), + 3 => wire__crate__api__disconnect_impl(ptr, rust_vec_len, data_len), + 5 => wire__crate__api__host_gen_id_impl(ptr, rust_vec_len, data_len), + 6 => wire__crate__api__host_gen_password_impl(ptr, rust_vec_len, data_len), + 7 => wire__crate__api__host_push_frame_impl(ptr, rust_vec_len, data_len), + 9 => wire__crate__api__host_stop_impl(ptr, rust_vec_len, data_len), + 10 => wire__crate__api__monitor_switch_impl(ptr, rust_vec_len, data_len), + 11 => wire__crate__api__send_chat_impl(ptr, rust_vec_len, data_len), + 12 => wire__crate__api__send_key_impl(ptr, rust_vec_len, data_len), + 13 => wire__crate__api__send_mouse_impl(ptr, rust_vec_len, data_len), + 14 => wire__crate__api__session_current_monitor_impl(ptr, rust_vec_len, data_len), + 16 => wire__crate__api__session_monitors_impl(ptr, rust_vec_len, data_len), + 17 => wire__crate__api__set_clipboard_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__set_quality_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::NdEvent::Connecting => [0.into_dart()].into_dart(), + crate::api::NdEvent::Connected { + w, + h, + e2e, + host_name, + } => [ + 1.into_dart(), + w.into_into_dart().into_dart(), + h.into_into_dart().into_dart(), + e2e.into_into_dart().into_dart(), + host_name.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Frame { + width, + height, + rgba, + } => [ + 2.into_dart(), + width.into_into_dart().into_dart(), + height.into_into_dart().into_dart(), + rgba.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Ended { reason } => { + [3.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + crate::api::NdEvent::Denied { reason } => { + [4.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + crate::api::NdEvent::ChatMessage { from, text } => [ + 5.into_dart(), + from.into_into_dart().into_dart(), + text.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdEvent::Clipboard { text } => { + [6.into_dart(), text.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdEvent {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdEvent { + fn into_into_dart(self) -> crate::api::NdEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdHostEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::NdHostEvent::Registered => [0.into_dart()].into_dart(), + crate::api::NdHostEvent::ViewerConnected { e2e } => { + [1.into_dart(), e2e.into_into_dart().into_dart()].into_dart() + } + crate::api::NdHostEvent::ViewerLeft => [2.into_dart()].into_dart(), + crate::api::NdHostEvent::Mouse { + x, + y, + buttons, + wheel, + } => [ + 3.into_dart(), + x.into_into_dart().into_dart(), + y.into_into_dart().into_dart(), + buttons.into_into_dart().into_dart(), + wheel.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::Key { code, down } => [ + 4.into_dart(), + code.into_into_dart().into_dart(), + down.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::KeyframeRequested => [5.into_dart()].into_dart(), + crate::api::NdHostEvent::Quality { + fps, + bitrate, + scale, + } => [ + 6.into_dart(), + fps.into_into_dart().into_dart(), + bitrate.into_into_dart().into_dart(), + scale.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::NdHostEvent::Reconnecting => [7.into_dart()].into_dart(), + crate::api::NdHostEvent::Ended { reason } => { + [8.into_dart(), reason.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdHostEvent {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdHostEvent { + fn into_into_dart(self) -> crate::api::NdHostEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::NdMonitor { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.i.into_into_dart().into_dart(), + self.w.into_into_dart().into_dart(), + self.h.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::NdMonitor {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::NdMonitor { + fn into_into_dart(self) -> crate::api::NdMonitor { + self + } +} + +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode for StreamSink { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode + for StreamSink +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_f32::(self).unwrap(); + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for crate::api::NdEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::NdEvent::Connecting => { + ::sse_encode(0, serializer); + } + crate::api::NdEvent::Connected { + w, + h, + e2e, + host_name, + } => { + ::sse_encode(1, serializer); + ::sse_encode(w, serializer); + ::sse_encode(h, serializer); + ::sse_encode(e2e, serializer); + ::sse_encode(host_name, serializer); + } + crate::api::NdEvent::Frame { + width, + height, + rgba, + } => { + ::sse_encode(2, serializer); + ::sse_encode(width, serializer); + ::sse_encode(height, serializer); + >::sse_encode(rgba, serializer); + } + crate::api::NdEvent::Ended { reason } => { + ::sse_encode(3, serializer); + ::sse_encode(reason, serializer); + } + crate::api::NdEvent::Denied { reason } => { + ::sse_encode(4, serializer); + ::sse_encode(reason, serializer); + } + crate::api::NdEvent::ChatMessage { from, text } => { + ::sse_encode(5, serializer); + ::sse_encode(from, serializer); + ::sse_encode(text, serializer); + } + crate::api::NdEvent::Clipboard { text } => { + ::sse_encode(6, serializer); + ::sse_encode(text, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::NdHostEvent { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::NdHostEvent::Registered => { + ::sse_encode(0, serializer); + } + crate::api::NdHostEvent::ViewerConnected { e2e } => { + ::sse_encode(1, serializer); + ::sse_encode(e2e, serializer); + } + crate::api::NdHostEvent::ViewerLeft => { + ::sse_encode(2, serializer); + } + crate::api::NdHostEvent::Mouse { + x, + y, + buttons, + wheel, + } => { + ::sse_encode(3, serializer); + ::sse_encode(x, serializer); + ::sse_encode(y, serializer); + ::sse_encode(buttons, serializer); + ::sse_encode(wheel, serializer); + } + crate::api::NdHostEvent::Key { code, down } => { + ::sse_encode(4, serializer); + ::sse_encode(code, serializer); + ::sse_encode(down, serializer); + } + crate::api::NdHostEvent::KeyframeRequested => { + ::sse_encode(5, serializer); + } + crate::api::NdHostEvent::Quality { + fps, + bitrate, + scale, + } => { + ::sse_encode(6, serializer); + ::sse_encode(fps, serializer); + ::sse_encode(bitrate, serializer); + ::sse_encode(scale, serializer); + } + crate::api::NdHostEvent::Reconnecting => { + ::sse_encode(7, serializer); + } + crate::api::NdHostEvent::Ended { reason } => { + ::sse_encode(8, serializer); + ::sse_encode(reason, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::NdMonitor { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.i, serializer); + ::sse_encode(self.w, serializer); + ::sse_encode(self.h, serializer); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u64::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.13.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.13.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/native/frb/src/lib.rs b/native/frb/src/lib.rs new file mode 100644 index 0000000..5b03352 --- /dev/null +++ b/native/frb/src/lib.rs @@ -0,0 +1,6 @@ +//! NetDesk flutter_rust_bridge FFI layer. The Dart-facing API lives in [`api`]; +//! `frb_generated` is produced by `flutter_rust_bridge_codegen generate` and +//! wires the C ABI the Flutter engine calls. Developed by Akronlogic. + +pub mod api; +mod frb_generated; diff --git a/native/host/Cargo.toml b/native/host/Cargo.toml new file mode 100644 index 0000000..d7d9ad7 --- /dev/null +++ b/native/host/Cargo.toml @@ -0,0 +1,81 @@ +# netdesk-host — the NATIVE RUST host (screen capture + H.264 encode + input +# injection) that feeds netdesk_core::host::run_host. This replaces the Python +# host engine so the Windows app is one unified Rust binary (host + viewer). +# Developed by Akronlogic. +[package] +name = "netdesk-host" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +netdesk-core = { path = "../core" } +tokio = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } +log = "0.4" +# OS clipboard for viewer->host clipboard sync (RustDesk-style). +arboard = "3" +# Encode extracted custom-cursor bitmaps to PNG for cursor.shape (the viewers +# render data:image/png). Base64 of the bytes is done in core on send. +png = "0.17" +env_logger = "0.11" +tracing = "0.1" +tracing-subscriber = { workspace = true } +openh264 = { workspace = true } + +[target.'cfg(windows)'.dependencies] +# VP9 software encode (libvpx) for constrained links — the constrained-link +# codec alongside the hardware H.264 path. +netdesk-vpx = { path = "../vpx" } +# Opus encode for system-audio capture (WASAPI loopback -> Opus -> CHAN_AUDIO). +netdesk-opus = { path = "../opus" } +windows = { version = "0.58", features = [ + "Win32_Media_Audio", + "Win32_Media_KernelStreaming", + "Win32_System_Com_StructuredStorage", + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Threading", + "Win32_System_Console", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_Security", + "Win32_System_Power", + "Win32_Storage_FileSystem", + "Win32_System_SystemInformation", + "Win32_System_RemoteDesktop", + "Win32_System_Services", + "Win32_System_StationsAndDesktops", + "Win32_Security_Authentication_Identity", + "Win32_System_ProcessStatus", + "Win32_System_Diagnostics_ToolHelp", + "Win32_Media_MediaFoundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Graphics_Gdi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_HiDpi", +] } + +[[bin]] +name = "netdesk-host-probe" +path = "src/bin/probe.rs" + +[[bin]] +name = "netdesk-host-encbench" +path = "src/bin/encbench.rs" + +[[bin]] +name = "netdesk-host-engine" +path = "src/bin/engine.rs" + +# Privacy-mode feasibility probe: does WDA_EXCLUDEFROMCAPTURE hide a window from +# this host's own DXGI + GDI capture? (determines the privacy-mode mechanism) +[[bin]] +name = "netdesk-privacy-probe" +path = "src/bin/privacy_probe.rs" diff --git a/native/host/src/annot.rs b/native/host/src/annot.rs new file mode 100644 index 0000000..17010e7 --- /dev/null +++ b/native/host/src/annot.rs @@ -0,0 +1,261 @@ +//! Collaborative annotation overlay. A fullscreen, topmost, click-through, +//! color-keyed LAYERED window on which the remote viewer's ink is drawn, so the +//! person AT the host sees the arrows/circles the operator draws (RustDesk-style +//! "annotate"). Only the strokes are opaque; the rest of the window is a magenta +//! color-key that is fully transparent, so the real desktop shows through and +//! the host user's mouse/keyboard pass straight to it (WS_EX_TRANSPARENT). +//! +//! Marked `WDA_EXCLUDEFROMCAPTURE` so the overlay does NOT appear in the host's +//! own capture — the drawing viewer already renders its ink locally, so this +//! prevents a double image on that viewer. (Relaying strokes to OTHER viewers so +//! they also see the ink is a follow-on.) Modeled on `privacy.rs`; same +//! own-thread + message-pump lifecycle, dropped to clear + remove the overlay. + +#![cfg(windows)] + +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::JoinHandle; +use std::time::Duration; + +use windows::core::w; +use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Graphics::Gdi::{ + BeginPaint, CreatePen, CreateSolidBrush, DeleteObject, EndPaint, FillRect, InvalidateRect, + LineTo, MoveToEx, SelectObject, HGDIOBJ, PAINTSTRUCT, PS_SOLID, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics, + PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow, + TranslateMessage, LWA_COLORKEY, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WM_PAINT, WNDCLASSW, + WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP, + WS_VISIBLE, +}; + +/// One pen stroke in VIRTUAL-DESKTOP pixel coordinates. +#[derive(Clone)] +pub struct Stroke { + pub color: u32, // 0xRRGGBB + pub width: i32, + pub points: Vec<(i32, i32)>, +} + +// The magenta transparent color-key (COLORREF is 0x00BBGGRR: B=FF, G=00, R=FF). +const KEY_BGR: u32 = 0x00FF_00FF; + +static STROKES: OnceLock>> = OnceLock::new(); +static ORIGIN: OnceLock<(i32, i32)> = OnceLock::new(); // virtual-desktop top-left + +fn strokes() -> &'static Mutex> { + STROKES.get_or_init(|| Mutex::new(Vec::new())) +} + +#[inline] +fn to_bgr(rgb: u32) -> u32 { + let r = (rgb >> 16) & 0xFF; + let g = (rgb >> 8) & 0xFF; + let b = rgb & 0xFF; + (b << 16) | (g << 8) | r +} + +unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT { + if m == WM_PAINT { + let mut ps = PAINTSTRUCT::default(); + let hdc = BeginPaint(h, &mut ps); + // Paint the whole client area with the transparent color-key first. + let keybrush = CreateSolidBrush(COLORREF(KEY_BGR)); + FillRect(hdc, &ps.rcPaint, keybrush); + let _ = DeleteObject(HGDIOBJ(keybrush.0)); + let (ox, oy) = *ORIGIN.get().unwrap_or(&(0, 0)); + if let Ok(list) = strokes().lock() { + for st in list.iter() { + if st.points.is_empty() { + continue; + } + let pen = CreatePen(PS_SOLID, st.width.max(1), COLORREF(to_bgr(st.color))); + let old = SelectObject(hdc, HGDIOBJ(pen.0)); + let p0 = st.points[0]; + let _ = MoveToEx(hdc, p0.0 - ox, p0.1 - oy, None); + for p in &st.points[1..] { + let _ = LineTo(hdc, p.0 - ox, p.1 - oy); + } + if st.points.len() == 1 { + // a single tap: a 1px nub so it is visible + let _ = LineTo(hdc, p0.0 - ox + 1, p0.1 - oy + 1); + } + SelectObject(hdc, old); + let _ = DeleteObject(HGDIOBJ(pen.0)); + } + } + let _ = EndPaint(h, &ps); + return LRESULT(0); + } + DefWindowProcW(h, m, w, l) +} + +/// A live annotation overlay. Drop it to clear + remove the overlay. +pub struct Annotations { + stop: Arc, + dirty: Arc, + hwnd: Arc, + thread: Option>, +} + +impl Annotations { + /// Raise the (empty, invisible) overlay; strokes appear as they are added. + pub fn start() -> Annotations { + if let Ok(mut v) = strokes().lock() { + v.clear(); + } + let stop = Arc::new(AtomicBool::new(false)); + let dirty = Arc::new(AtomicBool::new(true)); + let hwnd = Arc::new(AtomicIsize::new(0)); + let (s2, d2, h2) = (stop.clone(), dirty.clone(), hwnd.clone()); + let thread = std::thread::Builder::new() + .name("netdesk-annot".into()) + .spawn(move || unsafe { run(s2, d2, h2) }) + .ok(); + Annotations { stop, dirty, hwnd, thread } + } + + /// Append a stroke (virtual-desktop coords) and request a repaint. + pub fn add_stroke(&self, s: Stroke) { + if let Ok(mut v) = strokes().lock() { + v.push(s); + } + self.dirty.store(true, Ordering::Relaxed); + } + + /// Erase all strokes. + pub fn clear(&self) { + if let Ok(mut v) = strokes().lock() { + v.clear(); + } + self.dirty.store(true, Ordering::Relaxed); + } + + /// The overlay window handle (0 until created) — for tests. + pub fn hwnd(&self) -> isize { + self.hwnd.load(Ordering::Relaxed) + } +} + +impl Drop for Annotations { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + if let Ok(mut v) = strokes().lock() { + v.clear(); + } + } +} + +unsafe fn run(stop: Arc, dirty: Arc, hwnd_out: Arc) { + let hinst = HINSTANCE::default(); + let keybrush = CreateSolidBrush(COLORREF(KEY_BGR)); + let class = w!("NdAnnotOverlay"); + let wc = WNDCLASSW { + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: class, + hbrBackground: keybrush, + ..Default::default() + }; + let _ = RegisterClassW(&wc); + + let x = GetSystemMetrics(SM_XVIRTUALSCREEN); + let y = GetSystemMetrics(SM_YVIRTUALSCREEN); + let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1); + let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1); + let _ = ORIGIN.set((x, y)); + + let hwnd = match CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE, + class, + w!("nd-annot"), + WS_POPUP | WS_VISIBLE, + x, + y, + cx, + cy, + None, + None, + hinst, + None, + ) { + Ok(h) => h, + Err(e) => { + log::warn!("annot: window create failed: {e}"); + return; + } + }; + // Magenta is the transparent color-key: only the strokes are opaque/visible. + let _ = SetLayeredWindowAttributes(hwnd, COLORREF(KEY_BGR), 0, LWA_COLORKEY); + // Keep the overlay out of OUR OWN capture (the drawing viewer renders ink + // locally, so this prevents a double image on it). + if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) { + log::warn!("annot: SetWindowDisplayAffinity failed: {e}"); + } + let _ = ShowWindow(hwnd, SW_SHOWNA); + hwnd_out.store(hwnd.0 as isize, Ordering::Relaxed); + log::info!("annot: overlay up ({cx}x{cy} @ {x},{y}); click-through, excluded from capture"); + + let mut msg = MSG::default(); + while !stop.load(Ordering::Relaxed) { + if dirty.swap(false, Ordering::Relaxed) { + let _ = InvalidateRect(hwnd, None, false); + } + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + std::thread::sleep(Duration::from_millis(16)); + } + let _ = DestroyWindow(hwnd); + hwnd_out.store(0, Ordering::Relaxed); + log::info!("annot: overlay down"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + use windows::Win32::UI::WindowsAndMessaging::{GetWindowLongPtrW, GWL_EXSTYLE}; + + /// The overlay must be a LAYERED + click-through (TRANSPARENT) + TOPMOST + /// window and must accept strokes + clear without panicking. Proves the + /// collaborative-overlay mechanism (pixel rendering is verified live after + /// the capstone redeploy). + #[test] + fn overlay_is_layered_clickthrough_topmost_and_takes_strokes() { + let a = Annotations::start(); + let end = Instant::now() + Duration::from_millis(1500); + while a.hwnd() == 0 && Instant::now() < end { + std::thread::sleep(Duration::from_millis(20)); + } + let h = a.hwnd(); + assert!(h != 0, "overlay window did not create"); + let ex = unsafe { GetWindowLongPtrW(HWND(h as *mut core::ffi::c_void), GWL_EXSTYLE) } as u32; + assert!(ex & WS_EX_LAYERED.0 != 0, "overlay must be layered"); + assert!(ex & WS_EX_TRANSPARENT.0 != 0, "overlay must be click-through"); + assert!(ex & WS_EX_TOPMOST.0 != 0, "overlay must be topmost"); + a.add_stroke(Stroke { + color: 0xFF0000, + width: 4, + points: vec![(100, 100), (400, 300)], + }); + a.add_stroke(Stroke { + color: 0x00AAFF, + width: 2, + points: vec![(50, 50)], + }); + std::thread::sleep(Duration::from_millis(120)); + a.clear(); + std::thread::sleep(Duration::from_millis(60)); + println!("ANNOT_OVERLAY_OK hwnd={h:#x} exstyle={ex:#x}"); + drop(a); + } +} diff --git a/native/host/src/audio.rs b/native/host/src/audio.rs new file mode 100644 index 0000000..ba097ac --- /dev/null +++ b/native/host/src/audio.rs @@ -0,0 +1,551 @@ +//! System-audio capture for the native host: WASAPI loopback (capture "what you +//! hear" from the default render endpoint) -> downmix to 48 kHz stereo i16 -> +//! Opus (20 ms frames) -> `on_packet`. The host engine seals each packet and +//! sends it as a `CHAN_AUDIO` frame. +//! +//! Off by default (NETDESK_AUDIO=1 to enable), so it can never affect a normal +//! session. Entirely best-effort: any WASAPI error logs once and ends the +//! thread — audio just will not stream, video/input are unaffected. +//! +//! Stock cpal/soundio cannot do WASAPI loopback (RustDesk pins a cpal fork for +//! it), so we drive WASAPI directly through the `windows` crate — no extra +//! dependency and full control over the format path. +//! +//! Developed by Akronlogic. + +#![cfg(windows)] + +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use netdesk_opus::{Decoder, Encoder, FRAME_SIZE, SAMPLE_RATE}; + +use std::collections::VecDeque; + +use windows::core::Interface; +use windows::Win32::Media::Audio::{ + eCapture, eConsole, eRender, IAudioCaptureClient, IAudioClient, IAudioRenderClient, + IMMDeviceEnumerator, MMDeviceEnumerator, AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, WAVEFORMATEX, WAVEFORMATEXTENSIBLE, +}; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CLSCTX_ALL, COINIT_MULTITHREADED, +}; + +const WAVE_FORMAT_PCM: u16 = 1; +const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; +/// 1 second loopback buffer (100-ns units) — generous so draining can lag while +/// idle without overrunning. +const BUFFER_HNS: i64 = 10_000_000; + +/// Capture loopback audio until `stop`, encoding Opus only while `active` +/// (a viewer is connected). Each Opus packet is handed to `on_packet`. +/// +/// Returns (ending the thread) on any fatal WASAPI/Opus error. +pub fn run_loopback_opus( + stop: Arc, + active: impl Fn() -> bool, + mut on_packet: impl FnMut(Vec), +) { + // Back-compat shim: always-on system loopback. + let src = Arc::new(AtomicU8::new(1)); + run_audio(stop, active, src, move |p| on_packet(p)) +} + +/// On-demand host audio. `source` selects the live source and may change at +/// runtime (the viewer picks it): 0 = off, 1 = SYSTEM (WASAPI render loopback, +/// "what you hear"), 2 = MICROPHONE (default input). Opus packets go to +/// `on_packet` only while `active` (a viewer is streaming). Changing the source +/// re-opens the correct device. +pub fn run_audio( + stop: Arc, + active: impl Fn() -> bool, + source: Arc, + mut on_packet: impl FnMut(Vec), +) { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + while !stop.load(Ordering::Relaxed) { + let src = source.load(Ordering::Relaxed); + if src == 0 { + std::thread::sleep(Duration::from_millis(200)); + continue; + } + if let Err(e) = capture(&stop, &active, &source, src, &mut on_packet) { + // DEVICE_INVALIDATED (0x88890004), a device format change, or a + // default-endpoint switch all land here. Re-open the (possibly new) + // default endpoint after a short backoff — non-fatal, video/input + // are unaffected and the source selection is preserved. + log::warn!("audio: capture ended (src={src}): {e} — re-opening endpoint"); + std::thread::sleep(Duration::from_millis(300)); + } + } +} + +unsafe fn format_params(mix: *const WAVEFORMATEX) -> (u32, u16, u16, bool) { + // (sample_rate, channels, bits_per_sample, is_float) + let base = &*mix; + let mut is_float = base.wFormatTag == WAVE_FORMAT_IEEE_FLOAT; + if base.wFormatTag == WAVE_FORMAT_EXTENSIBLE { + // SubFormat GUID Data1: 3 = IEEE float, 1 = PCM. + let ext = &*(mix as *const WAVEFORMATEXTENSIBLE); + is_float = ext.SubFormat.data1 == WAVE_FORMAT_IEEE_FLOAT as u32; + } + ( + base.nSamplesPerSec, + base.nChannels, + base.wBitsPerSample, + is_float, + ) +} + +fn capture( + stop: &AtomicBool, + active: &impl Fn() -> bool, + source: &AtomicU8, + src: u8, + on_packet: &mut impl FnMut(Vec), +) -> anyhow::Result<()> { + unsafe { + // COM: ignore the HRESULT — a non-S_OK (already initialized on this + // thread with a different mode) is fine for our dedicated thread. + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + // src 2 = microphone: default CAPTURE device, no loopback flag. + // else (system): default RENDER device captured in loopback mode. + let (flow, stream_flags) = if src == 2 { + (eCapture, 0u32) + } else { + (eRender, AUDCLNT_STREAMFLAGS_LOOPBACK) + }; + let device = enumerator.GetDefaultAudioEndpoint(flow, eConsole)?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?; + + let mix = client.GetMixFormat()?; + let (rate, channels, bits, is_float) = format_params(mix); + let channels = channels as usize; + // Resample to 48 kHz only when the device mix rate differs. On Windows the + // shared-mode mix is usually already 48000, so the common path is a no-op. + // Previously a 44100 Hz device (many USB headsets / Bluetooth / capture + // cards) DISABLED audio entirely via a hard bail; now we capture at the + // device's native rate and resample the stereo stream to the 48 kHz the + // Opus encoder and the web AudioDecoder both require. + let need_resample = rate != SAMPLE_RATE; + let mut resampler = LinearResampler::new(rate.max(1), SAMPLE_RATE); + if need_resample { + log::info!("audio: device mix {rate} Hz, {channels}ch -> resampling to 48000 stereo (src={src})"); + } + + client.Initialize( + AUDCLNT_SHAREMODE_SHARED, + stream_flags, + BUFFER_HNS, + 0, + mix, + None, + )?; + CoTaskMemFree(Some(mix as *const _)); + + let capture: IAudioCaptureClient = client.GetService()?; + client.Start()?; + + let mut enc = Encoder::new(SAMPLE_RATE, 2)?; + let mut accum: Vec = Vec::with_capacity(FRAME_SIZE * 2 * 4); + let frame_len = FRAME_SIZE * 2; // interleaved stereo samples per 20ms + + // Exit (so run_audio re-opens) when the viewer changes the source. + while !stop.load(Ordering::Relaxed) && source.load(Ordering::Relaxed) == src { + let mut got_any = false; + loop { + let packet = capture.GetNextPacketSize()?; + if packet == 0 { + break; + } + got_any = true; + let mut pdata: *mut u8 = std::ptr::null_mut(); + let mut nframes: u32 = 0; + let mut flags: u32 = 0; + capture.GetBuffer(&mut pdata, &mut nframes, &mut flags, None, None)?; + let nframes = nframes as usize; + + if active() && nframes > 0 { + let silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT.0 as u32) != 0; + if silent || pdata.is_null() { + if need_resample { + for _ in 0..nframes { + resampler.push(0.0, 0.0, &mut accum); + } + } else { + for _ in 0..nframes { + accum.push(0); + accum.push(0); + } + } + } else if need_resample { + for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| { + resampler.push(l, r, &mut accum); + }); + } else { + for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| { + accum.push(to_i16(l)); + accum.push(to_i16(r)); + }); + } + } + capture.ReleaseBuffer(nframes as u32)?; + } + + // Emit whole 20 ms Opus frames. + while accum.len() >= frame_len { + match enc.encode(&accum[..frame_len], FRAME_SIZE) { + Ok(pkt) => on_packet(pkt), + Err(e) => log::warn!("audio: opus encode: {e}"), + } + accum.drain(..frame_len); + } + + if !got_any { + // Silence / idle: nothing to drain, back off briefly. + std::thread::sleep(Duration::from_millis(10)); + } + } + + let _ = client.Stop(); + Ok(()) + } +} + +/// Decode one WASAPI buffer (`nframes` frames of `channels` samples) to stereo +/// f32 pairs, calling `emit(l, r)` once per frame. Takes the first two channels +/// (duplicating channel 0 for mono); extra channels are ignored. Yielding f32 +/// pairs lets the caller either quantise straight to i16 (native 48 kHz path) or +/// feed them through the resampler (non-48 kHz devices) without a second pass. +unsafe fn for_each_stereo( + pdata: *mut u8, + nframes: usize, + channels: usize, + bits: u16, + is_float: bool, + mut emit: impl FnMut(f32, f32), +) { + if is_float && bits == 32 { + let src = std::slice::from_raw_parts(pdata as *const f32, nframes * channels); + for f in 0..nframes { + let base = f * channels; + let l = src[base]; + let r = if channels >= 2 { src[base + 1] } else { l }; + emit(l, r); + } + } else if !is_float && bits == 16 { + let src = std::slice::from_raw_parts(pdata as *const i16, nframes * channels); + for f in 0..nframes { + let base = f * channels; + let l = src[base] as f32 / 32768.0; + let r = if channels >= 2 { src[base + 1] as f32 / 32768.0 } else { l }; + emit(l, r); + } + } else if !is_float && bits == 32 { + // 32-bit integer PCM (some pro/USB interfaces present this). + let src = std::slice::from_raw_parts(pdata as *const i32, nframes * channels); + for f in 0..nframes { + let base = f * channels; + let l = src[base] as f32 / 2_147_483_648.0; + let r = if channels >= 2 { src[base + 1] as f32 / 2_147_483_648.0 } else { l }; + emit(l, r); + } + } else if is_float && bits == 64 { + let src = std::slice::from_raw_parts(pdata as *const f64, nframes * channels); + for f in 0..nframes { + let base = f * channels; + let l = src[base] as f32; + let r = if channels >= 2 { src[base + 1] as f32 } else { l }; + emit(l, r); + } + } else { + // Unknown bit depth: emit silence to keep timing rather than garbage. + for _ in 0..nframes { + emit(0.0, 0.0); + } + } +} + +/// Minimal stateful linear resampler: stereo f32 in at `src_rate`, stereo i16 out +/// at `dst_rate`, appended to `out`. The phase and previous sample persist across +/// calls so consecutive WASAPI buffers resample seamlessly (no per-buffer clicks). +/// No anti-alias filter — but transparent for speech/UI/media at the 44100->48000 +/// upsample that is by far the common non-48 kHz case. +struct LinearResampler { + step: f64, // source samples to advance per output sample (src/dst) + t: f64, // phase in [0,1) between prev and cur + prev_l: f32, + prev_r: f32, + primed: bool, +} + +impl LinearResampler { + fn new(src_rate: u32, dst_rate: u32) -> Self { + Self { + step: src_rate as f64 / dst_rate as f64, + t: 0.0, + prev_l: 0.0, + prev_r: 0.0, + primed: false, + } + } + + #[inline] + fn push(&mut self, cur_l: f32, cur_r: f32, out: &mut Vec) { + if !self.primed { + self.prev_l = cur_l; + self.prev_r = cur_r; + self.primed = true; + } + // Emit every output sample whose source position falls in [prev, cur). + while self.t < 1.0 { + let ft = self.t as f32; + out.push(to_i16(self.prev_l + (cur_l - self.prev_l) * ft)); + out.push(to_i16(self.prev_r + (cur_r - self.prev_r) * ft)); + self.t += self.step; + } + self.t -= 1.0; + self.prev_l = cur_l; + self.prev_r = cur_r; + } +} + +#[inline] +fn to_i16(f: f32) -> i16 { + (f.clamp(-1.0, 1.0) * 32767.0) as i16 +} + +/// 200 ms shared-mode render buffer for voice playback. +const VOICE_BUFFER_HNS: i64 = 2_000_000; + +/// Play the viewer's microphone (two-way voice call) on the host's default render +/// endpoint. Reads Opus packets from `rx`, decodes to 48 kHz stereo, converts to +/// the device mix format (resampling when the device isn't 48 kHz), and writes to +/// the shared-mode render buffer. Best-effort: any WASAPI/Opus error ends the +/// current attempt and re-opens; capture + video are unaffected. +pub fn run_voice_playback(stop: Arc, rx: std::sync::mpsc::Receiver>) { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + while !stop.load(Ordering::Relaxed) { + match unsafe { voice_playback(&stop, &rx) } { + Ok(()) => break, // sender dropped / stop requested + Err(e) => { + log::warn!("voice: playback ended: {e} — re-opening"); + std::thread::sleep(Duration::from_millis(400)); + } + } + } +} + +unsafe fn voice_playback( + stop: &AtomicBool, + rx: &std::sync::mpsc::Receiver>, +) -> anyhow::Result<()> { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?; + let mix = client.GetMixFormat()?; + let (rate, channels, bits, is_float) = format_params(mix); + let channels = channels as usize; + client.Initialize(AUDCLNT_SHAREMODE_SHARED, 0, VOICE_BUFFER_HNS, 0, mix, None)?; + CoTaskMemFree(Some(mix as *const _)); + let buf_frames = client.GetBufferSize()?; + let render: IAudioRenderClient = client.GetService()?; + let mut dec = Decoder::new(SAMPLE_RATE, 2)?; + client.Start()?; + + let need_resample = rate != SAMPLE_RATE; + let mut rs = LinearResampler::new(SAMPLE_RATE, rate.max(1)); + // Decoded, device-rate, interleaved-stereo i16 awaiting the render buffer. + let mut pcm: VecDeque = VecDeque::with_capacity(buf_frames as usize * 2); + let mut scratch: Vec = Vec::new(); + log::info!("voice: render ready ({rate} Hz, {channels}ch) — awaiting viewer mic"); + let mut rx_pkts: u64 = 0; + let mut rx_peak: i32 = 0; + + while !stop.load(Ordering::Relaxed) { + // Drain queued Opus packets -> PCM (bounded so a burst can't balloon RAM). + loop { + if pcm.len() > (rate as usize) * 2 { + break; // ~1 s already buffered; let it drain first + } + match rx.try_recv() { + Ok(opus) => { + if let Ok(frame) = dec.decode(&opus) { + rx_pkts += 1; + for &s in frame.iter().step_by(64) { + let a = (s as i32).abs(); + if a > rx_peak { rx_peak = a; } + } + if rx_pkts % 250 == 0 { + log::info!("voice: rx {rx_pkts} pkts from viewer mic, recent peak {rx_peak}"); + rx_peak = 0; + } + if need_resample { + scratch.clear(); + let n = frame.len() / 2; + for i in 0..n { + let l = frame[i * 2] as f32 / 32768.0; + let r = frame[i * 2 + 1] as f32 / 32768.0; + rs.push(l, r, &mut scratch); + } + pcm.extend(scratch.iter().copied()); + } else { + pcm.extend(frame.iter().copied()); + } + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), + } + } + // Write as much PCM as the render buffer currently has room for. + let padding = client.GetCurrentPadding()?; + let avail = buf_frames.saturating_sub(padding); + let have = (pcm.len() / 2) as u32; + let to_write = avail.min(have); + if to_write > 0 { + let data = render.GetBuffer(to_write)?; + write_render(data, to_write as usize, channels, bits, is_float, &mut pcm); + render.ReleaseBuffer(to_write, 0)?; + } + std::thread::sleep(Duration::from_millis(8)); + } + let _ = client.Stop(); + Ok(()) +} + +/// Write `nframes` of interleaved stereo i16 (popped from `pcm`) to a WASAPI +/// render buffer in the device's mix format. Channel 0=L, 1=R, extra = silence; +/// a mono device gets the L/R average. +unsafe fn write_render( + data: *mut u8, + nframes: usize, + channels: usize, + bits: u16, + is_float: bool, + pcm: &mut VecDeque, +) { + if is_float && bits == 32 { + let dst = std::slice::from_raw_parts_mut(data as *mut f32, nframes * channels); + for f in 0..nframes { + let l = pcm.pop_front().unwrap_or(0) as f32 / 32768.0; + let r = pcm.pop_front().unwrap_or(0) as f32 / 32768.0; + let base = f * channels; + if channels == 1 { + dst[base] = (l + r) * 0.5; + } else { + dst[base] = l; + dst[base + 1] = r; + for c in 2..channels { + dst[base + c] = 0.0; + } + } + } + } else if !is_float && bits == 16 { + let dst = std::slice::from_raw_parts_mut(data as *mut i16, nframes * channels); + for f in 0..nframes { + let l = pcm.pop_front().unwrap_or(0); + let r = pcm.pop_front().unwrap_or(0); + let base = f * channels; + if channels == 1 { + dst[base] = ((l as i32 + r as i32) / 2) as i16; + } else { + dst[base] = l; + dst[base + 1] = r; + for c in 2..channels { + dst[base + c] = 0; + } + } + } + } else { + // Unsupported mix format: consume + emit silence to keep the clock. + for _ in 0..nframes { + pcm.pop_front(); + pcm.pop_front(); + } + let bytes = nframes * channels * (bits as usize / 8).max(1); + let dst = std::slice::from_raw_parts_mut(data, bytes); + for b in dst.iter_mut() { + *b = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Exercises the real WASAPI loopback init + drain/stop path on this machine + // (a Windows box with audio devices). Tolerant: a box with no render + // endpoint makes capture() bail early, which is still a clean return. The + // point is that init + stop never panic and the thread exits promptly. + // The non-48 kHz path that used to be a hard bail. Feed a 44100 Hz stereo + // sine through the resampler and prove it (a) yields ~48000/44100x as many + // output frames and (b) is not silent — i.e. real audio comes out, so a + // 44.1 kHz device now streams instead of being disabled. + #[test] + fn resampler_44100_to_48000_is_audible() { + let mut rs = LinearResampler::new(44100, 48000); + let mut out: Vec = Vec::new(); + let n_in = 44100usize; // 1 second of source + for i in 0..n_in { + let t = i as f32 / 44100.0; + let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 0.8; + rs.push(s, s, &mut out); + } + let out_frames = out.len() / 2; + // Expect ~48000 output frames (±1%). + let expected = 48000i64; + let diff = (out_frames as i64 - expected).abs(); + assert!( + diff < 480, + "resampled frame count {out_frames} not ~{expected} (diff {diff})" + ); + let peak = out.iter().map(|&s| (s as i32).abs()).max().unwrap_or(0); + assert!(peak > 10_000, "resampled audio is silent (peak={peak})"); + // Mono-fed => L and R must stay identical through the resampler. + assert!( + out.chunks(2).all(|c| c[0] == c[1]), + "resampler desynced L/R channels" + ); + println!("RESAMPLER_44K_OK out_frames={out_frames} peak={peak}"); + } + + #[test] + fn loopback_inits_and_stops_cleanly() { + let stop = Arc::new(AtomicBool::new(false)); + let got = Arc::new(std::sync::Mutex::new(0usize)); + let s2 = stop.clone(); + let g2 = got.clone(); + let h = std::thread::spawn(move || { + run_loopback_opus( + s2, + || true, + move |_pkt| { + *g2.lock().unwrap() += 1; + }, + ); + }); + std::thread::sleep(Duration::from_millis(300)); + stop.store(true, Ordering::Relaxed); + let start = std::time::Instant::now(); + while !h.is_finished() && start.elapsed() < Duration::from_secs(3) { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(h.is_finished(), "audio capture thread did not stop within 3s"); + h.join().unwrap(); + println!("AUDIO_LOOPBACK_OK packets={}", *got.lock().unwrap()); + } +} diff --git a/native/host/src/bin/encbench.rs b/native/host/src/bin/encbench.rs new file mode 100644 index 0000000..506fd37 --- /dev/null +++ b/native/host/src/bin/encbench.rs @@ -0,0 +1,203 @@ +//! Encode correctness + honest timing on synthetic MOVING content (independent +//! of whether the live desktop is active). Encodes N frames, decodes them back +//! with the real viewer decoder, and asserts the roundtrip reproduces the right +//! dimensions — proving the Annex-B the host emits is valid for the viewer. +//! +//! Usage: netdesk-host-encbench [sw|mf] (default sw = openh264 software) + +use std::time::Instant; + +fn make_frame(w: usize, h: usize, t: usize) -> Vec { + let mut buf = vec![0u8; w * h * 4]; + let bx = (t * 13) % (w.saturating_sub(120).max(1)); + let by = (t * 7) % (h.saturating_sub(120).max(1)); + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 4; + let inbox = x >= bx && x < bx + 120 && y >= by && y < by + 120; + if inbox { + buf[i] = 255; + buf[i + 1] = 255; + buf[i + 2] = 255; + buf[i + 3] = 255; + } else { + buf[i] = ((x + t * 3) & 0xff) as u8; + buf[i + 1] = ((y + t * 2) & 0xff) as u8; + buf[i + 2] = ((x + y + t) & 0xff) as u8; + buf[i + 3] = 255; + } + } + } + buf +} + +/// Mean (R,G,B) of a decoded row. +fn row_means(rgb: &[u8], w: usize, row: usize) -> (f64, f64, f64) { + let base = row * w * 3; + let (mut r, mut g, mut b) = (0u64, 0u64, 0u64); + for col in 0..w { + let o = base + col * 3; + r += rgb[o] as u64; + g += rgb[o + 1] as u64; + b += rgb[o + 2] as u64; + } + let n = w as f64; + (r as f64 / n, g as f64 / n, b as f64 / n) +} + +/// Solid gray frame (128,128,128) with a moving white box, so a correct decode +/// is gray everywhere and any green row stands out immediately. +fn gray_frame(w: usize, h: usize, t: usize) -> Vec { + let mut buf = vec![128u8; w * h * 4]; + for p in buf.chunks_exact_mut(4) { + p[3] = 255; + } + let bx = (t * 11) % (w.saturating_sub(100).max(1)); + for y in 0..100.min(h) { + for x in bx..(bx + 100).min(w) { + let i = (y * w + x) * 4; + buf[i] = 255; + buf[i + 1] = 255; + buf[i + 2] = 255; + } + } + buf +} + +fn main() -> anyhow::Result<()> { + let mode = std::env::args().nth(1).unwrap_or_else(|| "sw".into()); + let (w, h) = (1920usize, 1080usize); + let n = 120usize; + println!("encode roundtrip [{}]: {}x{}, {} frames", mode, w, h, n); + + // Green-line hunt: QSV-encode gray frames, decode through the HARDWARE + // decoder (best_decoder), and scan every decoded row for anomalous green. + #[cfg(windows)] + if mode == "mfdec" { + let _ = tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).try_init(); + let w = std::env::var("ND_W").ok().and_then(|s| s.parse().ok()).unwrap_or(w); + let h = std::env::var("ND_H").ok().and_then(|s| s.parse().ok()).unwrap_or(h); + println!(" mfdec dims: {}x{}", w, h); + let mut enc = netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000)?; + let mut dec = netdesk_core::decode::best_decoder(); + println!("encoder: {}", enc.name()); + let mut aus: Vec> = Vec::new(); + for t in 0..30 { + for ef in enc.encode(&gray_frame(w, h, t))? { + aus.push(ef.data); + } + } + for ef in enc.drain()? { + aus.push(ef.data); + } + let mut frames_checked = 0usize; + let mut green_rows_total = 0usize; + let mut first_report = String::new(); + for au in &aus { + if let Some(fr) = dec.decode(au)? { + frames_checked += 1; + let mut green_here = 0usize; + let mut first = None; + for row in 0..fr.h { + let (r, g, b) = row_means(&fr.rgb, fr.w, row); + // "green" = G clearly above both R and B (gray/white have R≈G≈B). + if g - r > 40.0 && g - b > 40.0 { + green_here += 1; + if first.is_none() { + first = Some((row, r, g, b)); + } + } + } + green_rows_total += green_here; + if green_here > 0 && first_report.is_empty() { + let (row, r, g, b) = first.unwrap(); + first_report = format!( + "frame {}x{}: {} green rows; first row {} rgb=({:.0},{:.0},{:.0})", + fr.w, fr.h, green_here, row, r, g, b + ); + } + } + } + println!("decoded {} frames; total green rows: {}", frames_checked, green_rows_total); + if green_rows_total == 0 { + println!("NO GREEN LINES via hardware decode"); + } else { + println!("GREEN DETECTED: {}", first_report); + } + return Ok(()); + } + + let mut dec = netdesk_core::decode::H264Decoder::new()?; + let mut keyframes = 0usize; + let mut decoded_ok = 0usize; + let mut total_bytes = 0usize; + let mut enc_ns = 0u128; + let mut emitted = 0usize; + + // Collect all AUs (with timing) then decode, so the two encoders share one path. + let mut aus: Vec<(Vec, bool)> = Vec::new(); + + #[cfg(windows)] + if mode == "mf" { + let mut enc = match netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000) { + Ok(e) => e, + Err(e) => { + println!("no hardware encoder ({e}); skipping mf test"); + return Ok(()); + } + }; + println!("hardware encoder: {} (hw={})", enc.name(), enc.is_hardware()); + for t in 0..n { + let frame = make_frame(w, h, t); + let t0 = Instant::now(); + let outs = enc.encode(&frame)?; + enc_ns += t0.elapsed().as_nanos(); + for ef in outs { + aus.push((ef.data, ef.keyframe)); + } + } + for ef in enc.drain()? { + aus.push((ef.data, ef.keyframe)); + } + } + + if mode != "mf" { + let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?; + for t in 0..n { + let frame = make_frame(w, h, t); + let t0 = Instant::now(); + let out = enc.encode(&frame)?; + enc_ns += t0.elapsed().as_nanos(); + if let Some(ef) = out { + aus.push((ef.data, ef.keyframe)); + } + } + } + + for (data, keyframe) in &aus { + emitted += 1; + total_bytes += data.len(); + if *keyframe { + keyframes += 1; + } + if let Some(fr) = dec.decode(data)? { + if fr.w == w && fr.h == h { + decoded_ok += 1; + } + } + } + + let enc_ms = enc_ns as f64 / n as f64 / 1e6; + println!(" encode avg: {:.2} ms/frame (~{:.0} fps)", enc_ms, 1000.0 / enc_ms); + println!(" AUs emitted: {} keyframes: {} decoded_ok: {}", emitted, keyframes, decoded_ok); + println!(" avg AU: {:.1} KB", total_bytes as f64 / emitted.max(1) as f64 / 1024.0); + + if keyframes == 0 { + anyhow::bail!("no keyframe emitted"); + } + if decoded_ok < emitted.saturating_sub(2) { + anyhow::bail!("roundtrip decode failed: {}/{} decoded", decoded_ok, emitted); + } + println!("ROUNDTRIP OK"); + Ok(()) +} diff --git a/native/host/src/bin/engine.rs b/native/host/src/bin/engine.rs new file mode 100644 index 0000000..2585cc7 --- /dev/null +++ b/native/host/src/bin/engine.rs @@ -0,0 +1,76 @@ +//! Standalone NetDesk native host engine. Captures the screen and serves +//! viewers over the NetDesk protocol with the best available H.264 encoder and +//! SendInput injection. Configured via environment: +//! ND_HOST_ID (required) the id viewers connect to +//! ND_PASSWORD (required) session password (never leaves this machine) +//! ND_WS_URL default wss://netdesk.space/ws +//! ND_NAME default "NetDesk (Rust)" +//! ND_MONITOR default 0 +//! ND_FPS default 60 +//! ND_BITRATE default 8000000 + +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + #[cfg(windows)] + { + let host_id = std::env::var("ND_HOST_ID").map_err(|_| anyhow::anyhow!("set ND_HOST_ID"))?; + let password = + std::env::var("ND_PASSWORD").map_err(|_| anyhow::anyhow!("set ND_PASSWORD"))?; + let cfg = netdesk_host::run::EngineConfig { + ws_url: std::env::var("ND_WS_URL").unwrap_or_else(|_| "wss://netdesk.space/ws".into()), + host_id, + name: std::env::var("ND_NAME").unwrap_or_else(|_| "NetDesk (Rust)".into()), + password, + monitor: envnum("ND_MONITOR", 0), + fps: envnum("ND_FPS", 60), + bitrate: envnum("ND_BITRATE", 8_000_000), + // Host-enforced permissions + approve-before-connect (see run.rs): + // ND_PERM_INPUT / ND_PERM_CLIPBOARD / ND_PERM_FILES / ND_PERM_AUDIO + // (default 1) and ND_APPROVE (default 0). + perms: netdesk_core::host::HostPerms { + input: envflag("ND_PERM_INPUT", true), + clipboard: envflag("ND_PERM_CLIPBOARD", true), + files: envflag("ND_PERM_FILES", true), + audio: envflag("ND_PERM_AUDIO", true), + }, + approve: envflag("ND_APPROVE", false), + totp_secret: std::env::var("ND_TOTP").unwrap_or_default(), + // Encrypted direct-IP: ND_DIRECT_PORT makes the host listen on that + // TCP port for a viewer that dials it directly (no relay); + // ND_ALLOW_IPS is an optional CIDR allow-list. + direct_port: std::env::var("ND_DIRECT_PORT").ok().and_then(|s| s.parse().ok()), + allow_ips: std::env::var("ND_ALLOW_IPS").unwrap_or_default(), + // ND_MAX_VIEWERS>1 allows multiple simultaneous direct-IP viewers + // (1 controller + N observers of the one capture). + max_viewers: std::env::var("ND_MAX_VIEWERS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1) + .max(1), + }; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let handle = netdesk_host::run::EngineHandle::new(); + rt.block_on(netdesk_host::run::run_engine(cfg, handle))?; + } + #[cfg(not(windows))] + println!("engine is Windows-only"); + Ok(()) +} + +#[cfg(windows)] +fn envnum(key: &str, default: T) -> T { + std::env::var(key).ok().and_then(|s| s.parse().ok()).unwrap_or(default) +} + +/// "1"/"true"/"yes" = true, "0"/"false"/"no" = false, unset = `default`. +#[cfg(windows)] +fn envflag(key: &str, default: bool) -> bool { + match std::env::var(key).as_deref().map(str::trim) { + Ok("1") | Ok("true") | Ok("yes") => true, + Ok("0") | Ok("false") | Ok("no") => false, + _ => default, + } +} diff --git a/native/host/src/bin/privacy_probe.rs b/native/host/src/bin/privacy_probe.rs new file mode 100644 index 0000000..54b7fe8 --- /dev/null +++ b/native/host/src/bin/privacy_probe.rs @@ -0,0 +1,146 @@ +//! Privacy-mode feasibility probe. Determines whether a window marked +//! `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` is hidden from THIS host's +//! own screen capture — both DXGI Desktop Duplication (`grab`) and the GDI +//! BitBlt fallback (`grab_seed`). If it is, a fullscreen black excluded window +//! is a real, driver-free privacy mode: the local monitor shows black while the +//! remote operator still captures the desktop behind it. +//! +//! Method: show a small solid-magenta popup at a known spot, capture with the +//! affinity OFF (control — magenta must appear) then ON (magenta must vanish), +//! and count magenta pixels in each frame. Safe: the window is small and shown +//! for ~2s, then destroyed. Developed by Akronlogic. + +#![cfg(windows)] + +use std::time::{Duration, Instant}; +use windows::core::w; +use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Graphics::Gdi::CreateSolidBrush; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, RegisterClassW, + SetWindowDisplayAffinity, ShowWindow, TranslateMessage, MSG, PM_REMOVE, SW_SHOW, + WDA_EXCLUDEFROMCAPTURE, WDA_NONE, WNDCLASSW, WS_EX_TOPMOST, WS_POPUP, WS_VISIBLE, +}; + +use netdesk_host::capture::DxgiCapturer; + +unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT { + DefWindowProcW(h, m, w, l) +} + +fn pump(ms: u64) { + let end = Instant::now() + Duration::from_millis(ms); + while Instant::now() < end { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + std::thread::sleep(Duration::from_millis(15)); + } +} + +/// Count magenta (B=255,G=0,R=255 with tolerance) pixels in a BGRA buffer. +fn count_magenta(buf: &[u8]) -> usize { + buf.chunks_exact(4) + .filter(|p| p[0] >= 250 && p[1] <= 6 && p[2] >= 250) + .count() +} + +/// Freshest DXGI frame over `ms` (None if the duplication reported no change). +fn freshest_dxgi(cap: &mut DxgiCapturer, ms: u64) -> Option> { + let end = Instant::now() + Duration::from_millis(ms); + let mut last = None; + while Instant::now() < end { + if let Ok(Some(f)) = cap.grab(80) { + last = Some(f); + } + } + last +} + +fn main() { + unsafe { + let hinst = HINSTANCE::default(); + let brush = CreateSolidBrush(COLORREF(0x00FF_00FF)); // B=FF,G=00,R=FF = magenta + let class = w!("NdPrivacyProbe"); + let wc = WNDCLASSW { + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: class, + hbrBackground: brush, + ..Default::default() + }; + let atom = RegisterClassW(&wc); + if atom == 0 { + eprintln!("RegisterClassW failed"); + std::process::exit(1); + } + + let hwnd = match CreateWindowExW( + WS_EX_TOPMOST, + class, + w!("nd-privacy-probe"), + WS_POPUP | WS_VISIBLE, + 120, + 120, + 520, + 380, + None, + None, + hinst, + None, + ) { + Ok(h) => h, + Err(e) => { + eprintln!("CreateWindowExW failed: {e}"); + std::process::exit(1); + } + }; + let _ = ShowWindow(hwnd, SW_SHOW); + pump(400); + + let mut cap = match DxgiCapturer::new(0) { + Ok(c) => c, + Err(e) => { + eprintln!("DxgiCapturer::new failed: {e}"); + let _ = DestroyWindow(hwnd); + std::process::exit(1); + } + }; + + // CONTROL: affinity OFF — the window must be visible to both captures. + let _ = SetWindowDisplayAffinity(hwnd, WDA_NONE); + pump(300); + let ctl_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX); + let ctl_dxgi = freshest_dxgi(&mut cap, 700).map(|b| count_magenta(&b)); + + // EXCLUDE: affinity ON — the window must vanish from capture. + let _ = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE); + pump(400); + let exc_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX); + let exc_dxgi = freshest_dxgi(&mut cap, 900).map(|b| count_magenta(&b)); + + let _ = DestroyWindow(hwnd); + + println!("PRIVACY_PROBE control_gdi_magenta={ctl_gdi} exclude_gdi_magenta={exc_gdi}"); + println!( + "PRIVACY_PROBE control_dxgi_magenta={:?} exclude_dxgi_magenta={:?}", + ctl_dxgi, exc_dxgi + ); + + let gdi_ok = ctl_gdi > 1000 && exc_gdi == 0; + // DXGI: control should show magenta; exclude should show none. If the + // duplication reported no change in a phase (None), note it. + let dxgi_ok = matches!((ctl_dxgi, exc_dxgi), (Some(c), Some(e)) if c > 1000 && e == 0); + if gdi_ok && dxgi_ok { + println!("VERDICT: PRIVACY_WDA_WORKS — WDA_EXCLUDEFROMCAPTURE hides the window from BOTH DXGI and GDI capture"); + } else if gdi_ok { + println!("VERDICT: PRIVACY_WDA_GDI_ONLY — GDI excludes it; DXGI result {:?}/{:?} (inconclusive or not excluded)", ctl_dxgi, exc_dxgi); + } else { + println!("VERDICT: PRIVACY_WDA_FAILS — the window was still captured; privacy needs an IDD virtual display"); + } + } +} diff --git a/native/host/src/bin/probe.rs b/native/host/src/bin/probe.rs new file mode 100644 index 0000000..afe94f7 --- /dev/null +++ b/native/host/src/bin/probe.rs @@ -0,0 +1,75 @@ +//! Capture + encode benchmark: open DXGI duplication on monitor 0, encode each +//! frame to H.264, and report per-stage timing + bitstream sizes. Proves the +//! native capture->encode pipeline works and shows where we stand on latency. + +#[cfg(windows)] +use std::time::Instant; + +fn main() -> anyhow::Result<()> { + #[cfg(windows)] + { + netdesk_host::set_dpi_aware(); + let mut cap = netdesk_host::capture::DxgiCapturer::new(0)?; + let (w, h) = (cap.width() as usize, cap.height() as usize); + println!("capture: {}x{}", w, h); + + let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?; + + let mut last: Option> = None; + let mut frames = 0usize; + let mut keyframes = 0usize; + let mut cap_ns = 0u128; + let mut enc_ns = 0u128; + let mut total_bytes = 0usize; + let mut encoded = 0usize; + + // warm up + run ~150 iterations + for i in 0..150 { + let t0 = Instant::now(); + let frame = match cap.grab(50)? { + Some(buf) => { + last = Some(buf); + last.as_ref().unwrap() + } + None => match last.as_ref() { + Some(b) => b, // screen unchanged: re-encode last (keeps fps up) + None => continue, + }, + }; + let t1 = Instant::now(); + let out = enc.encode(frame)?; + let t2 = Instant::now(); + + if i >= 10 { + // skip warmup + frames += 1; + cap_ns += (t1 - t0).as_nanos(); + enc_ns += (t2 - t1).as_nanos(); + if let Some(ef) = &out { + encoded += 1; + total_bytes += ef.data.len(); + if ef.keyframe { + keyframes += 1; + } + } + } + } + + if frames == 0 { + anyhow::bail!("no frames"); + } + let cap_ms = cap_ns as f64 / frames as f64 / 1e6; + let enc_ms = enc_ns as f64 / frames as f64 / 1e6; + println!("measured over {} frames ({} produced output, {} keyframes)", frames, encoded, keyframes); + println!(" capture avg: {:.2} ms", cap_ms); + println!(" encode avg: {:.2} ms", enc_ms); + println!(" pipeline: {:.2} ms (~{:.0} fps ceiling)", cap_ms + enc_ms, 1000.0 / (cap_ms + enc_ms)); + if encoded > 0 { + let avg_kb = total_bytes as f64 / encoded as f64 / 1024.0; + println!(" avg AU size: {:.1} KB (~{:.1} Mbit/s @60fps)", avg_kb, avg_kb * 1024.0 * 8.0 * 60.0 / 1e6); + } + } + #[cfg(not(windows))] + println!("probe is Windows-only"); + Ok(()) +} diff --git a/native/host/src/capture.rs b/native/host/src/capture.rs new file mode 100644 index 0000000..c26d976 --- /dev/null +++ b/native/host/src/capture.rs @@ -0,0 +1,489 @@ +//! Windows screen capture via DXGI Desktop Duplication — the same GPU path +//! dxcam / RustDesk use: the OS hands us the latest desktop frame as a GPU +//! texture, we copy it to a CPU-readable staging texture and read BGRA out. +//! `grab` returns `None` when the desktop is unchanged (no new frame), so an +//! idle screen costs almost nothing. + +use anyhow::{anyhow, Result}; +use windows::core::Interface; +use windows::Win32::Foundation::{HMODULE, E_ACCESSDENIED}; +use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0, +}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, + D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_FLAG, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, + D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING, +}; +use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; +use windows::Win32::Graphics::Dxgi::{ + IDXGIAdapter, IDXGIDevice, IDXGIOutput, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, + DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_FRAME_INFO, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, +}; + +/// Geometry of one display output (flat DXGI output index + its desktop rect). +#[derive(Clone, Copy, Debug)] +pub struct MonitorDesc { + pub index: u32, + pub width: u32, + pub height: u32, + pub left: i32, + pub top: i32, +} + +/// A live Desktop Duplication session for one monitor. +pub struct DxgiCapturer { + device: ID3D11Device, + context: ID3D11DeviceContext, + dupl: IDXGIOutputDuplication, + width: u32, + height: u32, + left: i32, + top: i32, + staging: Option, +} + +impl DxgiCapturer { + /// Enumerate all display outputs on the default adapter, in the same flat + /// index order `new(index)` uses. Best-effort: returns an empty vec on any + /// failure (callers fall back to a single monitor). The index is what the + /// host advertises as `monitors[].i` and what a `monitor.switch{mon}` sends. + pub fn enumerate() -> Vec { + let mut out = Vec::new(); + unsafe { + let mut device: Option = None; + let levels = [D3D_FEATURE_LEVEL_11_0]; + if D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_FLAG(0), + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + None, + None, + ) + .is_err() + { + return out; + } + let device = match device { + Some(d) => d, + None => return out, + }; + let dxgi_device: IDXGIDevice = match device.cast() { + Ok(d) => d, + Err(_) => return out, + }; + let adapter: IDXGIAdapter = match dxgi_device.GetAdapter() { + Ok(a) => a, + Err(_) => return out, + }; + let mut i = 0u32; + loop { + match adapter.EnumOutputs(i) { + Ok(output) => { + if let Ok(desc) = output.GetDesc() { + let c = desc.DesktopCoordinates; + out.push(MonitorDesc { + index: i, + width: (c.right - c.left) as u32, + height: (c.bottom - c.top) as u32, + left: c.left, + top: c.top, + }); + } + i += 1; + } + Err(_) => break, // DXGI_ERROR_NOT_FOUND: no more outputs + } + } + } + out + } + + /// Open duplication for `output_index` (0 = first monitor). + pub fn new(output_index: u32) -> Result { + unsafe { + let mut device: Option = None; + let mut context: Option = None; + let levels = [D3D_FEATURE_LEVEL_11_0]; + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_FLAG(0), + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + )?; + let device = device.ok_or_else(|| anyhow!("D3D11 device is null"))?; + let context = context.ok_or_else(|| anyhow!("D3D11 context is null"))?; + + let dxgi_device: IDXGIDevice = device.cast()?; + let adapter: IDXGIAdapter = dxgi_device.GetAdapter()?; + let output: IDXGIOutput = adapter.EnumOutputs(output_index)?; + let desc = output.GetDesc()?; + let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left) as u32; + let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top) as u32; + let left = desc.DesktopCoordinates.left; + let top = desc.DesktopCoordinates.top; + + let output1: IDXGIOutput1 = output.cast()?; + let dupl = output1.DuplicateOutput(&device)?; + + Ok(Self { + device, + context, + dupl, + width, + height, + left, + top, + staging: None, + }) + } + } + + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + /// Top-left of this monitor in virtual-desktop coordinates (for mapping the + /// global cursor position into frame-pixel space). + pub fn origin(&self) -> (i32, i32) { + (self.left, self.top) + } + + /// Grab the next frame as tightly-packed BGRA (`width*height*4`). Returns + /// `Ok(None)` when nothing changed within `timeout_ms`. On a desktop switch + /// the duplication is lost (`access lost`) and the caller re-creates us. + pub fn grab(&mut self, timeout_ms: u32) -> Result>> { + unsafe { + let mut info = DXGI_OUTDUPL_FRAME_INFO::default(); + let mut resource: Option = None; + match self.dupl.AcquireNextFrame(timeout_ms, &mut info, &mut resource) { + Ok(()) => {} + Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => return Ok(None), + Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST || e.code() == E_ACCESSDENIED => { + return Err(anyhow!("duplication access lost")); + } + Err(e) => return Err(e.into()), + } + let resource = resource.ok_or_else(|| anyhow!("acquired null resource"))?; + let tex: ID3D11Texture2D = resource.cast()?; + + let mut tdesc = D3D11_TEXTURE2D_DESC::default(); + tex.GetDesc(&mut tdesc); + + if self.staging.is_none() { + let sdesc = D3D11_TEXTURE2D_DESC { + Width: tdesc.Width, + Height: tdesc.Height, + MipLevels: 1, + ArraySize: 1, + Format: tdesc.Format, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut st: Option = None; + self.device.CreateTexture2D(&sdesc, None, Some(&mut st))?; + self.staging = st; + } + let staging = self.staging.clone().unwrap(); + self.context.CopyResource(&staging, &tex); + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + self.context + .Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; + + let w = tdesc.Width as usize; + let h = tdesc.Height as usize; + let pitch = mapped.RowPitch as usize; + let src = mapped.pData as *const u8; + let mut out = vec![0u8; w * h * 4]; + for y in 0..h { + std::ptr::copy_nonoverlapping( + src.add(y * pitch), + out.as_mut_ptr().add(y * w * 4), + w * 4, + ); + } + self.context.Unmap(&staging, 0); + let _ = self.dupl.ReleaseFrame(); + Ok(Some(out)) + } + } + + /// Return the CURRENT desktop image even when nothing has changed. A + /// long-running duplication yields `WAIT_TIMEOUT` on a static screen (so + /// `grab` returns `Ok(None)` indefinitely), which would leave a freshly + /// connected viewer black because the keyframe flush has no pixels to + /// encode. This gives DXGI a brief chance to produce the frame and otherwise + /// falls back to a GDI BitBlt of the monitor rect, which always returns live + /// pixels. Used only to seed a keyframe (viewer join / refresh), never in + /// the per-frame hot path. + pub fn grab_seed(&mut self) -> Result> { + for _ in 0..2 { + match self.grab(150) { + Ok(Some(buf)) => return Ok(buf), + Ok(None) => continue, + Err(e) => return Err(e), + } + } + gdi_capture(self.left, self.top, self.width, self.height) + } +} + +impl Drop for DxgiCapturer { + fn drop(&mut self) { + unsafe { + let _ = self.dupl.ReleaseFrame(); + } + } +} + +/// Capture a monitor rect via GDI BitBlt as top-down BGRA. Unlike DXGI this +/// always returns the CURRENT pixels (no "a frame changed" requirement), so it +/// can seed the first keyframe for an idle/static screen. The host is +/// per-monitor-DPI-aware, so `left/top/width/height` are physical virtual- +/// desktop pixels that match the DXGI frame layout (BGRA, 4 bytes/pixel). +pub(crate) fn gdi_capture(left: i32, top: i32, width: u32, height: u32) -> Result> { + use windows::Win32::Graphics::Gdi::{ + BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, + GetDIBits, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + HGDIOBJ, SRCCOPY, + }; + let (w, h) = (width as i32, height as i32); + unsafe { + let screen = GetDC(None); + if screen.is_invalid() { + return Err(anyhow!("GetDC(screen) failed")); + } + let mem = CreateCompatibleDC(screen); + let bmp = CreateCompatibleBitmap(screen, w, h); + if mem.is_invalid() || bmp.is_invalid() { + ReleaseDC(None, screen); + return Err(anyhow!("GDI DC/bitmap alloc failed")); + } + let old = SelectObject(mem, HGDIOBJ(bmp.0)); + let blt = BitBlt(mem, 0, 0, w, h, screen, left, top, SRCCOPY); + let mut bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: w, + biHeight: -h, // negative => top-down, matching the DXGI buffer + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0 as u32, + ..Default::default() + }, + ..Default::default() + }; + let mut out = vec![0u8; (width * height * 4) as usize]; + let lines = GetDIBits( + mem, + bmp, + 0, + height, + Some(out.as_mut_ptr() as *mut core::ffi::c_void), + &mut bi, + DIB_RGB_COLORS, + ); + let _ = SelectObject(mem, old); + let _ = DeleteObject(HGDIOBJ(bmp.0)); + let _ = DeleteDC(mem); + ReleaseDC(None, screen); + if blt.is_err() || lines == 0 { + return Err(anyhow!("GDI BitBlt/GetDIBits failed")); + } + Ok(out) + } +} + +/// Attach the calling (capture) thread to the desktop currently receiving input +/// — the normal Default desktop, or the secure Winlogon desktop when the machine +/// is locked, at the sign-in screen, or showing a UAC prompt. Running as SYSTEM +/// (via the service), this lets GDI capture + SendInput reach the secure desktop +/// so a remote viewer can sign in and unlock. Keeps exactly one desktop handle +/// open, closing the previous one on each switch. +static mut PREV_DESK: isize = 0; +pub(crate) fn attach_input_desktop() -> bool { + use windows::Win32::System::StationsAndDesktops::{ + CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, + DESKTOP_CONTROL_FLAGS, HDESK, + }; + unsafe { + // GENERIC_ALL (0x10000000) → the kernel maps it to all desktop rights. + match OpenInputDesktop(DESKTOP_CONTROL_FLAGS(0), false, DESKTOP_ACCESS_FLAGS(0x1000_0000)) { + Ok(h) => { + if SetThreadDesktop(h).is_ok() { + if PREV_DESK != 0 { + let _ = CloseDesktop(HDESK(PREV_DESK as *mut core::ffi::c_void)); + } + PREV_DESK = h.0 as isize; + true + } else { + let _ = CloseDesktop(h); + false + } + } + Err(_) => false, + } + } +} + +/// The all-monitors sentinel: `Capturer::open(MON_ALL)` captures the whole +/// virtual desktop (every monitor stitched) via GDI. On the wire it is +/// advertised + switched as monitor index `-1`. +pub const MON_ALL: u32 = u32::MAX; + +/// Bounding rect of the whole virtual desktop (all monitors), physical pixels: +/// `(left, top, width, height)`. Width/height are rounded DOWN to even (the +/// encoder needs even dimensions). +pub fn virtual_desktop_rect() -> (i32, i32, u32, u32) { + unsafe { + let left = GetSystemMetrics(SM_XVIRTUALSCREEN); + let top = GetSystemMetrics(SM_YVIRTUALSCREEN); + let w = (GetSystemMetrics(SM_CXVIRTUALSCREEN).max(0) as u32) & !1; + let h = (GetSystemMetrics(SM_CYVIRTUALSCREEN).max(0) as u32) & !1; + (left, top, w, h) + } +} + +/// All-monitors capturer: a GDI BitBlt of the whole virtual desktop each grab. +/// Slower than DXGI (no GPU path, no "changed-only" wait), so it is an opt-in +/// overview source, not the per-monitor default. Always returns live pixels. +pub struct VirtualGdiCapturer { + left: i32, + top: i32, + width: u32, + height: u32, +} + +impl VirtualGdiCapturer { + pub fn new() -> Result { + let (left, top, width, height) = virtual_desktop_rect(); + if width == 0 || height == 0 { + return Err(anyhow!("virtual desktop has zero size")); + } + Ok(Self { left, top, width, height }) + } + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + pub fn origin(&self) -> (i32, i32) { + (self.left, self.top) + } + /// GDI has no "unchanged" signal, so every grab returns the current frame. + pub fn grab(&mut self, _timeout_ms: u32) -> Result>> { + Ok(Some(gdi_capture(self.left, self.top, self.width, self.height)?)) + } + pub fn grab_seed(&mut self) -> Result> { + gdi_capture(self.left, self.top, self.width, self.height) + } +} + +/// A capture source: one DXGI-duplicated monitor, or the whole virtual desktop +/// (GDI). The capture loop holds this and never branches on the variant — the +/// enum delegates grab/seed/geometry, so `MON_ALL` "just works" as a monitor. +pub enum Capturer { + Dxgi(DxgiCapturer), + VirtualAll(VirtualGdiCapturer), +} + +impl Capturer { + /// Open monitor `mon` (`MON_ALL` => the whole virtual desktop via GDI). + pub fn open(mon: u32) -> Result { + if mon == MON_ALL { + Ok(Capturer::VirtualAll(VirtualGdiCapturer::new()?)) + } else { + Ok(Capturer::Dxgi(DxgiCapturer::new(mon)?)) + } + } + pub fn grab(&mut self, timeout_ms: u32) -> Result>> { + match self { + Capturer::Dxgi(c) => c.grab(timeout_ms), + Capturer::VirtualAll(c) => c.grab(timeout_ms), + } + } + pub fn grab_seed(&mut self) -> Result> { + match self { + Capturer::Dxgi(c) => c.grab_seed(), + Capturer::VirtualAll(c) => c.grab_seed(), + } + } + pub fn width(&self) -> u32 { + match self { + Capturer::Dxgi(c) => c.width(), + Capturer::VirtualAll(c) => c.width(), + } + } + pub fn height(&self) -> u32 { + match self { + Capturer::Dxgi(c) => c.height(), + Capturer::VirtualAll(c) => c.height(), + } + } + pub fn origin(&self) -> (i32, i32) { + match self { + Capturer::Dxgi(c) => c.origin(), + Capturer::VirtualAll(c) => c.origin(), + } + } +} + +#[cfg(test)] +mod gdi_seed_tests { + use super::*; + + /// The GDI fallback must return a full-size buffer of live (non-black) + /// desktop pixels — this is what rescues an idle screen from a black + /// keyframe. Runs only with a real desktop session. + #[test] + fn gdi_capture_returns_live_pixels() { + // A small rect at the virtual-desktop origin; every machine has pixels + // here (taskbar / wallpaper), so a correct capture is never all-black. + let w = 64u32; + let h = 64u32; + match gdi_capture(0, 0, w, h) { + Ok(buf) => { + assert_eq!(buf.len(), (w * h * 4) as usize, "wrong buffer size"); + let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count(); + assert!(lit > 0, "GDI capture returned an all-black buffer"); + } + Err(e) => panic!("gdi_capture failed: {e}"), + } + } + + /// The all-monitors capturer reports an even-sized virtual desktop and + /// grabs a full-size live frame. Runs only with a real desktop session. + #[test] + fn virtual_capturer_grabs_full_desktop() { + let (_l, _t, vw, vh) = virtual_desktop_rect(); + assert!(vw > 0 && vh > 0, "virtual desktop size is zero"); + assert!(vw % 2 == 0 && vh % 2 == 0, "virtual desktop size must be even"); + let mut cap = Capturer::open(MON_ALL).expect("open all-monitors capturer"); + assert_eq!((cap.width(), cap.height()), (vw, vh)); + let buf = cap.grab_seed().expect("grab virtual desktop"); + assert_eq!(buf.len(), (vw * vh * 4) as usize, "wrong buffer size"); + let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count(); + assert!(lit > 0, "all-monitors capture was all black"); + println!("VIRTUAL_CAP_OK {}x{} bytes={}", vw, vh, buf.len()); + } +} diff --git a/native/host/src/color.rs b/native/host/src/color.rs new file mode 100644 index 0000000..fff3abb --- /dev/null +++ b/native/host/src/color.rs @@ -0,0 +1,99 @@ +//! BGRA -> NV12 color conversion (BT.601 limited range), the input most +//! hardware H.264 encoders want. We sample chroma at the top-left of each 2x2 +//! block (fast; chroma is half-res anyway). Integer coefficients match the +//! viewer decoder's NV12->RGB (BT.601 limited) so a capture->encode->decode +//! roundtrip is color-stable. +//! +//! Output is tightly packed NV12: Y plane (w*h) then interleaved UV (w*h/2), +//! total w*h*3/2 bytes, stride == width (callers keep width 2-aligned). + +/// Convert a tightly-packed BGRA frame (`w*h*4`) into `dst` as NV12. `dst` must +/// be at least `w*h*3/2` bytes. `w` and `h` must be even. +pub fn bgra_to_nv12(bgra: &[u8], w: usize, h: usize, dst: &mut [u8]) { + debug_assert!(w % 2 == 0 && h % 2 == 0); + debug_assert!(bgra.len() >= w * h * 4); + debug_assert!(dst.len() >= w * h * 3 / 2); + + let (y_plane, uv_plane) = dst.split_at_mut(w * h); + + // Luma: every pixel. + for y in 0..h { + let row = y * w; + for x in 0..w { + let p = (row + x) * 4; + let b = bgra[p] as i32; + let g = bgra[p + 1] as i32; + let r = bgra[p + 2] as i32; + // Y = 16 + (66R + 129G + 25B)/256 + let yy = (66 * r + 129 * g + 25 * b + 128) >> 8; + y_plane[row + x] = (yy + 16).clamp(0, 255) as u8; + } + } + + // Chroma: one U,V per 2x2 block, sampled at the block's top-left pixel. + let cw = w / 2; + for by in 0..h / 2 { + let srow = (by * 2) * w; + let drow = by * cw * 2; + for bx in 0..cw { + let p = (srow + bx * 2) * 4; + let b = bgra[p] as i32; + let g = bgra[p + 1] as i32; + let r = bgra[p + 2] as i32; + // U = 128 + (-38R - 74G + 112B)/256 ; V = 128 + (112R - 94G - 18B)/256 + let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; + let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; + uv_plane[drow + bx * 2] = u.clamp(0, 255) as u8; + uv_plane[drow + bx * 2 + 1] = v.clamp(0, 255) as u8; + } + } +} + +/// Bilinear downscale of a tightly-packed BGRA image `sw`x`sh` into `dst` +/// (`dw`x`dh`, also tightly packed BGRA). Used for the quality "scale" lever so +/// a constrained link can trade resolution for bandwidth. Only called when +/// dw (u32, u32) { + let s = scale.clamp(0.1, 1.0); + let sw = (((w as f32 * s) as u32) & !1).max(64).min(w); + let sh = (((h as f32 * s) as u32) & !1).max(64).min(h); + (sw, sh) +} diff --git a/native/host/src/cursor.rs b/native/host/src/cursor.rs new file mode 100644 index 0000000..6259c58 --- /dev/null +++ b/native/host/src/cursor.rs @@ -0,0 +1,282 @@ +//! Remote cursor tracking (RustDesk-style overlay): report the host cursor's +//! position and shape to the viewer, sent on the control channel separately +//! from video so an idle screen still shows a live, correctly-shaped cursor. +//! +//! Shapes are reported as CSS names for the standard system cursors (arrow, +//! text, hand, resize, …) — which the viewer renders as crisp OS-native +//! pointers — so there is no fragile per-frame bitmap extraction. Non-standard +//! (app-custom) cursors fall back to the viewer's default pointer. + +#![cfg(windows)] + +use windows::Win32::UI::WindowsAndMessaging::{ + GetCursorInfo, LoadCursorW, CURSORINFO, CURSOR_SHOWING, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, + IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, + IDC_SIZEWE, IDC_WAIT, +}; + +/// `(hcursor_or_0, global_x, global_y)`. `0` means the cursor is hidden. +pub fn cursor_state() -> Option<(u64, i32, i32)> { + unsafe { + let mut ci = CURSORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetCursorInfo(&mut ci).is_err() { + return None; + } + let showing = (ci.flags.0 & CURSOR_SHOWING.0) != 0 && !ci.hCursor.is_invalid(); + let h = if showing { ci.hCursor.0 as u64 } else { 0 }; + Some((h, ci.ptScreenPos.x, ci.ptScreenPos.y)) + } +} + +/// Map a cursor handle to a CSS cursor name by comparing it against the loaded +/// standard system cursors. Returns `None` for app-custom cursors. +pub fn cursor_css(hcursor: u64) -> Option<&'static str> { + if hcursor == 0 { + return None; + } + const STD: &[(windows::core::PCWSTR, &str)] = &[ + (IDC_ARROW, "default"), + (IDC_IBEAM, "text"), + (IDC_HAND, "pointer"), + (IDC_CROSS, "crosshair"), + (IDC_WAIT, "wait"), + (IDC_APPSTARTING, "progress"), + (IDC_SIZEALL, "move"), + (IDC_SIZENS, "ns-resize"), + (IDC_SIZEWE, "ew-resize"), + (IDC_SIZENWSE, "nwse-resize"), + (IDC_SIZENESW, "nesw-resize"), + (IDC_NO, "not-allowed"), + (IDC_HELP, "help"), + ]; + unsafe { + for (idc, css) in STD { + if let Ok(h) = LoadCursorW(None, *idc) { + if h.0 as u64 == hcursor { + return Some(css); + } + } + } + } + None +} + +/// A decoded custom-cursor bitmap ready for `cursor.shape` (PNG + hotspot), so +/// app-specific cursors render on the viewer instead of falling back to the +/// default pointer. RustDesk sends the raw cursor image the same way. +pub struct CursorBitmap { + pub png: Vec, + pub w: u32, + pub h: u32, + pub hx: i32, + pub hy: i32, +} + +/// Extract a (color) cursor's RGBA bitmap + hotspot and PNG-encode it. Returns +/// None for monochrome cursors (rare for app-custom shapes; the standard mono +/// ones are already mapped to CSS) and on any GDI failure, so the caller simply +/// keeps the default pointer. Mirrors capture.rs's GetDIBits usage. +pub fn cursor_bitmap(hcursor: u64) -> Option { + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Graphics::Gdi::{ + DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HGDIOBJ, + }; + use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, HICON, ICONINFO}; + + if hcursor == 0 { + return None; + } + unsafe { + let mut ii = ICONINFO::default(); + if GetIconInfo(HICON(hcursor as *mut std::ffi::c_void), &mut ii).is_err() { + return None; + } + // Always release the bitmaps GetIconInfo created for us. + let cleanup = |ii: &ICONINFO| { + if !ii.hbmColor.is_invalid() { + let _ = DeleteObject(HGDIOBJ(ii.hbmColor.0)); + } + if !ii.hbmMask.is_invalid() { + let _ = DeleteObject(HGDIOBJ(ii.hbmMask.0)); + } + }; + if ii.hbmColor.is_invalid() { + // Monochrome cursor — fall back to the default pointer. + cleanup(&ii); + return None; + } + // Dimensions from the color bitmap. + let mut bm = BITMAP::default(); + let got = GetObjectW( + HGDIOBJ(ii.hbmColor.0), + std::mem::size_of::() as i32, + Some(&mut bm as *mut _ as *mut std::ffi::c_void), + ); + if got == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 512 { + cleanup(&ii); + return None; + } + let (w, h) = (bm.bmWidth as u32, bm.bmHeight as u32); + let read_bgra = |hbm: HBITMAP| -> Option> { + let screen = GetDC(None); + let mut bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: w as i32, + biHeight: -(h as i32), // top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut buf = vec![0u8; (w * h * 4) as usize]; + let lines = GetDIBits( + screen, + hbm, + 0, + h, + Some(buf.as_mut_ptr() as *mut std::ffi::c_void), + &mut bi, + DIB_RGB_COLORS, + ); + ReleaseDC(None, screen); + if lines == 0 { + None + } else { + Some(buf) + } + }; + let color = read_bgra(ii.hbmColor); + // The AND mask (same 32bpp read): nonzero means "transparent" where the + // color bitmap carries no alpha of its own. + let mask = read_bgra(ii.hbmMask); + let hotspot = (ii.xHotspot as i32, ii.yHotspot as i32); + cleanup(&ii); + let color = color?; + + // BGRA -> RGBA. Use the color bitmap's own alpha if it has any; otherwise + // derive it from the AND mask (mask pixel != 0 => transparent). + let has_alpha = color.chunks_exact(4).any(|px| px[3] != 0); + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for (i, px) in color.chunks_exact(4).enumerate() { + let (b, g, r) = (px[0], px[1], px[2]); + let a = if has_alpha { + px[3] + } else if let Some(m) = &mask { + // AND mask: a "set" (white) pixel is transparent. + let mp = &m[i * 4..i * 4 + 4]; + if mp[0] | mp[1] | mp[2] != 0 { + 0 + } else { + 255 + } + } else { + 255 + }; + let o = i * 4; + rgba[o] = r; + rgba[o + 1] = g; + rgba[o + 2] = b; + rgba[o + 3] = a; + } + let _ = HANDLE::default(); // keep the HANDLE import used across windows versions + let png = encode_png(&rgba, w, h)?; + Some(CursorBitmap { png, w, h, hx: hotspot.0, hy: hotspot.1 }) + } +} + +/// Encode a top-down RGBA buffer to PNG bytes. +pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option> { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header().ok()?; + writer.write_image_data(rgba).ok()?; + } + Some(out) +} + +/// Decode PNG bytes to top-down RGBA8 + (w, h). Used for clipboard images that +/// arrive from a viewer, to hand arboard an `ImageData`. +pub(crate) fn decode_png(bytes: &[u8]) -> Option<(Vec, u32, u32)> { + let dec = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = dec.read_info().ok()?; + let mut buf = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).ok()?; + buf.truncate(info.buffer_size()); + // Normalize to RGBA8. + let rgba = match (info.color_type, info.bit_depth) { + (png::ColorType::Rgba, png::BitDepth::Eight) => buf, + (png::ColorType::Rgb, png::BitDepth::Eight) => { + let mut out = Vec::with_capacity((info.width * info.height * 4) as usize); + for px in buf.chunks_exact(3) { + out.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + out + } + _ => return None, // uncommon formats -> skip (clipboard images are RGB/RGBA8) + }; + Some((rgba, info.width, info.height)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Extract a real system cursor's bitmap (IDC_HAND is a color cursor on + // modern Windows). In production cursor_css() catches the standard ones + // first; here we call cursor_bitmap directly to prove the GDI extraction + + // PNG encode produce a valid, sensibly-sized image. + #[test] + fn extracts_a_real_cursor_to_png() { + let h = unsafe { LoadCursorW(None, IDC_HAND) }; + let h = match h { + Ok(h) => h.0 as u64, + Err(_) => return, // no interactive cursor resource (headless CI) — skip + }; + if let Some(bmp) = cursor_bitmap(h) { + assert!(bmp.w >= 8 && bmp.w <= 512, "width sane: {}", bmp.w); + assert!(bmp.h >= 8 && bmp.h <= 512, "height sane: {}", bmp.h); + // Valid PNG signature. + assert_eq!(&bmp.png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + // Round-trips through a decoder back to w*h*4 RGBA. + let dec = png::Decoder::new(std::io::Cursor::new(&bmp.png)); + let mut reader = dec.read_info().expect("png header"); + let mut buf = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).expect("png frame"); + assert_eq!(info.width, bmp.w); + assert_eq!(info.height, bmp.h); + } + // If extraction returned None (e.g. a monochrome IDC_HAND on some + // builds), that's the documented safe fallback — not a failure. + } +} + +#[cfg(test)] +mod clip_png_tests { + use super::{decode_png, encode_png}; + + #[test] + fn png_rgba_roundtrip_for_clipboard_images() { + // A 2x2 RGBA image round-trips encode -> decode byte-for-byte. + let w = 2u32; + let h = 2u32; + let rgba: Vec = vec![ + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 128, 10, 20, 30, 40, + ]; + let png = encode_png(&rgba, w, h).expect("encode"); + assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + let (got, gw, gh) = decode_png(&png).expect("decode"); + assert_eq!((gw, gh), (w, h)); + assert_eq!(got, rgba, "clipboard image pixels survive the round-trip"); + } +} diff --git a/native/host/src/encode.rs b/native/host/src/encode.rs new file mode 100644 index 0000000..209f792 --- /dev/null +++ b/native/host/src/encode.rs @@ -0,0 +1,74 @@ +//! H.264 encode via openh264 (pure-Rust-friendly, statically linked C core) — +//! the portable software path that works on every Windows PC with no driver or +//! GPU dependency. BGRA (from DXGI capture) is converted to I420 and encoded as +//! Annex-B access units: keyframes carry SPS+PPS+IDR, deltas carry a P slice, +//! exactly what the viewer's openh264/Media Foundation decoder expects. +//! +//! Hardware encoders (NVENC / QSV / AMF) layer in later behind this same +//! `EncodedFrame` output; software-first guarantees the host always works. + +use anyhow::{anyhow, Result}; +use openh264::encoder::{Encoder, EncoderConfig, FrameType, RateControlMode, UsageType}; +use openh264::formats::{BgraSliceU8, YUVBuffer}; +use openh264::OpenH264API; + +/// One encoded access unit ready to hand to `core::host`, which prefixes the +/// video channel + keyframe byte and encrypts it. +pub struct EncodedFrame { + pub data: Vec, + pub keyframe: bool, +} + +/// A live H.264 encoder for one resolution. Resolution changes re-init the +/// encoder automatically (openh264 does this on dimension change). +pub struct H264Encoder { + enc: Encoder, + width: usize, + height: usize, +} + +impl H264Encoder { + pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result { + let config = EncoderConfig::new() + .set_bitrate_bps(bitrate_bps) + .max_frame_rate(fps as f32) + .rate_control_mode(RateControlMode::Bitrate) + .usage_type(UsageType::ScreenContentRealTime) + .enable_skip_frame(true) + .set_multiple_thread_idc(0); // auto: use all cores + let enc = Encoder::with_api_config(OpenH264API::from_source(), config) + .map_err(|e| anyhow!("openh264 encoder init: {e}"))?; + Ok(Self { enc, width, height }) + } + + /// Encode one tightly-packed BGRA frame (`width*height*4`). Returns `None` + /// when rate control skipped this frame (nothing to send this tick). + pub fn encode(&mut self, bgra: &[u8]) -> Result> { + let src = BgraSliceU8::new(bgra, (self.width, self.height)); + let yuv = YUVBuffer::from_rgb_source(src); + let bs = self.enc.encode(&yuv).map_err(|e| anyhow!("encode: {e}"))?; + let ft = bs.frame_type(); + if ft == FrameType::Skip || ft == FrameType::Invalid { + return Ok(None); + } + let data = bs.to_vec(); + if data.is_empty() { + return Ok(None); + } + let keyframe = matches!(ft, FrameType::IDR | FrameType::I); + Ok(Some(EncodedFrame { data, keyframe })) + } + + /// Force the next encoded frame to be a keyframe (viewer asked for a refresh + /// after a drop, or a new viewer joined). + pub fn request_keyframe(&mut self) { + self.enc.force_intra_frame(); + } + + pub fn width(&self) -> usize { + self.width + } + pub fn height(&self) -> usize { + self.height + } +} diff --git a/native/host/src/encode_mf.rs b/native/host/src/encode_mf.rs new file mode 100644 index 0000000..101c9c3 --- /dev/null +++ b/native/host/src/encode_mf.rs @@ -0,0 +1,486 @@ +//! Windows hardware H.264 encode via Media Foundation. +//! +//! Enumerates the PC's hardware H.264 encoder MFTs (NVIDIA NVENC, Intel Quick +//! Sync, AMD VCE/AMF — whatever is present) and drives the async MFT with a +//! deadlock-free event pump. BGRA from DXGI capture is converted to NV12 and +//! fed in; Annex-B access units come out, with SPS/PPS guaranteed on keyframes. +//! +//! This is the "use any hardware encoder and any GPU accelerator on any Windows +//! PC" path. If no hardware encoder is present or init fails, the caller falls +//! back to the portable openh264 software encoder. + +#![cfg(windows)] + +use crate::color::bgra_to_nv12; +use crate::encode::EncodedFrame; +use anyhow::{anyhow, Result}; +use std::ffi::c_void; + +use windows::core::{Interface, VARIANT, GUID, PWSTR}; +use windows::Win32::Media::MediaFoundation::*; +use windows::Win32::System::Com::{CoInitializeEx, CoTaskMemFree, COINIT_MULTITHREADED}; + +/// MF_MT_FRAME_SIZE / FRAME_RATE / PIXEL_ASPECT_RATIO are packed UINT64s +/// ((hi << 32) | lo). windows-rs does not expose the inline MFSetAttribute* +/// helpers, so we pack them ourselves. +unsafe fn set_size(mt: &IMFMediaType, key: &GUID, w: u32, h: u32) -> Result<()> { + mt.SetUINT64(key, ((w as u64) << 32) | (h as u64))?; + Ok(()) +} +unsafe fn set_ratio(mt: &IMFMediaType, key: &GUID, num: u32, den: u32) -> Result<()> { + mt.SetUINT64(key, ((num as u64) << 32) | (den as u64))?; + Ok(()) +} + +/// ~60 fps in 100-ns units; the stream is low-latency so stamps only need to be +/// monotonic. +const HNS_PER_FRAME: i64 = 166_667; + +pub struct MfEncoder { + transform: IMFTransform, + events: IMFMediaEventGenerator, + width: usize, + height: usize, + nv12: Vec, + seq_header: Vec, + provides_samples: bool, + output_size: usize, + need_credits: u32, + pts: i64, + hardware: bool, + name: String, +} + +impl MfEncoder { + pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(|e| anyhow!("MFStartup: {e}"))?; + + // Enumerate hardware H.264 encoders: NV12 in, H264 out. + let input_info = MFT_REGISTER_TYPE_INFO { + guidMajorType: MFMediaType_Video, + guidSubtype: MFVideoFormat_NV12, + }; + let output_info = MFT_REGISTER_TYPE_INFO { + guidMajorType: MFMediaType_Video, + guidSubtype: MFVideoFormat_H264, + }; + let flags = + MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_ASYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER; + + let mut pp: *mut Option = std::ptr::null_mut(); + let mut count: u32 = 0; + MFTEnumEx( + MFT_CATEGORY_VIDEO_ENCODER, + flags, + Some(&input_info), + Some(&output_info), + &mut pp, + &mut count, + ) + .map_err(|e| anyhow!("MFTEnumEx: {e}"))?; + + if count == 0 || pp.is_null() { + return Err(anyhow!("no hardware H.264 encoder MFT found")); + } + let activations = std::slice::from_raw_parts(pp, count as usize); + + let mut chosen: Option<(IMFTransform, String)> = None; + for act in activations.iter().flatten() { + let mut pw = PWSTR::null(); + let mut len = 0u32; + let name = if act + .GetAllocatedString(&MFT_FRIENDLY_NAME_Attribute, &mut pw, &mut len) + .is_ok() + && !pw.is_null() + { + let s = + String::from_utf16_lossy(std::slice::from_raw_parts(pw.0, len as usize)); + CoTaskMemFree(Some(pw.0 as *const c_void)); + s + } else { + String::new() + }; + if let Ok(t) = act.ActivateObject::() { + chosen = Some((t, name)); + break; + } + } + CoTaskMemFree(Some(pp as *const c_void)); + + let (transform, name) = + chosen.ok_or_else(|| anyhow!("failed to activate any encoder MFT"))?; + + // Async MFT: unlock before use, request low latency (1-in-1-out). + if let Ok(attrs) = transform.GetAttributes() { + let _ = attrs.SetUINT32(&MF_TRANSFORM_ASYNC_UNLOCK, 1); + let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); + } + + // OUTPUT type first (required for encoders): H.264. + let out_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(out): {e}"))?; + out_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + out_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?; + out_t.SetUINT32(&MF_MT_AVG_BITRATE, bitrate_bps)?; + out_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + out_t.SetUINT32(&MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Main.0 as u32)?; + set_size(&out_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?; + set_ratio(&out_t, &MF_MT_FRAME_RATE, fps, 1)?; + set_ratio(&out_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?; + transform + .SetOutputType(0, &out_t, 0) + .map_err(|e| anyhow!("SetOutputType(H264): {e}"))?; + + // INPUT type: NV12. + let in_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(in): {e}"))?; + in_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + in_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; + in_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + set_size(&in_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?; + set_ratio(&in_t, &MF_MT_FRAME_RATE, fps, 1)?; + set_ratio(&in_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?; + transform + .SetInputType(0, &in_t, 0) + .map_err(|e| anyhow!("SetInputType(NV12): {e}"))?; + + // Prefer ON-DEMAND keyframes (viewer join / refresh / QoS step) over a + // tight periodic GOP: set a long GOP as a safety net and drive IDRs via + // ICodecAPI force-keyframe (see request_keyframe), so the bitstream stays + // lean on a static screen and a join gets a crisp keyframe without the + // old whole-encoder re-init hitch. Best-effort (ignored if unsupported). + if let Ok(api) = transform.cast::() { + let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(120u32)); + } + + // SPS/PPS sequence header, to guarantee on keyframes. + let mut seq_header = Vec::new(); + if let Ok(cur) = transform.GetOutputCurrentType(0) { + if let Ok(sz) = cur.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) { + if sz > 0 { + let mut buf = vec![0u8; sz as usize]; + if cur + .GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None) + .is_ok() + { + seq_header = buf; + } + } + } + } + + let info = transform + .GetOutputStreamInfo(0) + .map_err(|e| anyhow!("GetOutputStreamInfo: {e}"))?; + let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 + | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32; + let provides_samples = (info.dwFlags & provides) != 0; + let output_size = info.cbSize as usize; + + let events: IMFMediaEventGenerator = transform + .cast() + .map_err(|e| anyhow!("MFT is not an event generator (not async?): {e}"))?; + + transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)?; + transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)?; + + Ok(Self { + transform, + events, + width, + height, + nv12: vec![0u8; width * height * 3 / 2], + seq_header, + provides_samples, + output_size, + need_credits: 0, + pts: 0, + hardware: true, + name, + }) + } + } + + pub fn name(&self) -> &str { + &self.name + } + pub fn is_hardware(&self) -> bool { + self.hardware + } + pub fn width(&self) -> usize { + self.width + } + pub fn height(&self) -> usize { + self.height + } + + /// Encode one BGRA frame. Returns 0+ encoded AUs (an async encoder may run a + /// frame or two of latency; outputs arrive in order). + pub fn encode(&mut self, bgra: &[u8]) -> Result> { + unsafe { + bgra_to_nv12(bgra, self.width, self.height, &mut self.nv12); + let sample = self.make_input_sample()?; + let mut out = Vec::new(); + + // Wait (blocking) until the MFT has asked for input at least once. + while self.need_credits == 0 { + let ev = self + .events + .GetEvent(MF_EVENT_FLAG_NONE) + .map_err(|e| anyhow!("GetEvent(block): {e}"))?; + self.handle_event(&ev, &mut out)?; + } + self.transform + .ProcessInput(0, &sample, 0) + .map_err(|e| anyhow!("ProcessInput: {e}"))?; + self.need_credits -= 1; + + // Collect whatever output is ready right now, without blocking. + self.drain_ready(&mut out)?; + Ok(out) + } + } + + /// Flush the pipeline tail (used by the bench / on shutdown). + pub fn drain(&mut self) -> Result> { + unsafe { + let mut out = Vec::new(); + self.transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0)?; + self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0)?; + loop { + let ev = self + .events + .GetEvent(MF_EVENT_FLAG_NONE) + .map_err(|e| anyhow!("GetEvent(drain): {e}"))?; + let t = ev.GetType()?; + if t == METransformDrainComplete.0 as u32 { + break; + } + self.handle_event(&ev, &mut out)?; + } + Ok(out) + } + } + + unsafe fn drain_ready(&mut self, out: &mut Vec) -> Result<()> { + loop { + match self.events.GetEvent(MF_EVENT_FLAG_NO_WAIT) { + Ok(ev) => self.handle_event(&ev, out)?, + Err(e) if e.code() == MF_E_NO_EVENTS_AVAILABLE => return Ok(()), + Err(e) => return Err(anyhow!("GetEvent(nowait): {e}")), + } + } + } + + unsafe fn handle_event( + &mut self, + ev: &IMFMediaEvent, + out: &mut Vec, + ) -> Result<()> { + let t = ev.GetType()?; + if t == METransformNeedInput.0 as u32 { + self.need_credits += 1; + } else if t == METransformHaveOutput.0 as u32 { + if let Some(f) = self.pull_output()? { + out.push(f); + } + } + Ok(()) + } + + unsafe fn make_input_sample(&mut self) -> Result { + let sample = MFCreateSample()?; + let buffer = MFCreateMemoryBuffer(self.nv12.len() as u32)?; + let mut ptr: *mut u8 = std::ptr::null_mut(); + buffer.Lock(&mut ptr, None, None)?; + std::ptr::copy_nonoverlapping(self.nv12.as_ptr(), ptr, self.nv12.len()); + buffer.Unlock()?; + buffer.SetCurrentLength(self.nv12.len() as u32)?; + sample.AddBuffer(&buffer)?; + let _ = sample.SetSampleTime(self.pts); + let _ = sample.SetSampleDuration(HNS_PER_FRAME); + self.pts += HNS_PER_FRAME; + Ok(sample) + } + + /// Pick the H.264 output type (re)offered by the MFT and refresh the stored + /// SPS/PPS. Hardware encoders finalize their output type after the first + /// input and signal MF_E_TRANSFORM_STREAM_CHANGE; this renegotiates it. + unsafe fn renegotiate_output(&mut self) -> Result<()> { + let t = self + .transform + .GetOutputAvailableType(0, 0) + .map_err(|e| anyhow!("GetOutputAvailableType(renegotiate): {e}"))?; + self.transform + .SetOutputType(0, &t, 0) + .map_err(|e| anyhow!("SetOutputType(renegotiate): {e}"))?; + if let Ok(sz) = t.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) { + if sz > 0 { + let mut buf = vec![0u8; sz as usize]; + if t.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { + self.seq_header = buf; + } + } + } + if let Ok(info) = self.transform.GetOutputStreamInfo(0) { + let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 + | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32; + self.provides_samples = (info.dwFlags & provides) != 0; + self.output_size = info.cbSize as usize; + } + Ok(()) + } + + unsafe fn pull_output(&mut self) -> Result> { + let sample_slot: Option = if self.provides_samples { + None + } else { + let s = MFCreateSample()?; + let b = MFCreateMemoryBuffer(self.output_size.max(self.width * self.height) as u32)?; + s.AddBuffer(&b)?; + Some(s) + }; + + let mut buffers = [MFT_OUTPUT_DATA_BUFFER { + dwStreamID: 0, + pSample: std::mem::ManuallyDrop::new(sample_slot), + dwStatus: 0, + pEvents: std::mem::ManuallyDrop::new(None), + }]; + let mut status = 0u32; + let res = self.transform.ProcessOutput(0, &mut buffers, &mut status); + let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample); + let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents); + + match res { + Ok(()) => { + let s = out_sample.ok_or_else(|| anyhow!("ProcessOutput gave no sample"))?; + let keyframe = s.GetUINT32(&MFSampleExtension_CleanPoint).unwrap_or(0) == 1; + let buf = s.GetBufferByIndex(0)?; + let mut ptr: *mut u8 = std::ptr::null_mut(); + let mut len = 0u32; + buf.Lock(&mut ptr, None, Some(&mut len))?; + let mut data = std::slice::from_raw_parts(ptr, len as usize).to_vec(); + buf.Unlock()?; + + if keyframe && !self.seq_header.is_empty() && !starts_with_sps(&data) { + let mut with_hdr = self.seq_header.clone(); + with_hdr.extend_from_slice(&data); + data = with_hdr; + } + Ok(Some(EncodedFrame { data, keyframe })) + } + Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => Ok(None), + Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { + // Encoder finalized its output format. Renegotiate; the pending + // output arrives on the next METransformHaveOutput event (QSV + // catastrophes on a synchronous retry here). + self.renegotiate_output()?; + Ok(None) + } + Err(e) => Err(anyhow!("ProcessOutput: {e}")), + } + } + + /// Force the next encoded frame to be a keyframe (IDR) via ICodecAPI + /// CODECAPI_AVEncVideoForceKeyFrame — NVENC/QSV/AMF all honor it — instead of + /// tearing down + rebuilding the whole MFT (the old approach, which stalled + /// the stream on every viewer join / refresh / QoS bitrate step). + pub fn request_keyframe(&mut self) { + unsafe { + if let Ok(api) = self.transform.cast::() { + let _ = api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Prove the ICodecAPI force-keyframe path emits an IDR ON DEMAND without a + /// rebuild. With the long (120) GOP set at init, the only natural keyframe in + /// a short run is the initial IDR at frame 0; a keyframe appearing shortly + /// after request_keyframe() (and well before frame 120) is therefore the + /// forced one. Skips cleanly if the box has no hardware H.264 MFT. + #[test] + fn forced_keyframe_emits_idr_on_demand() { + let (w, h) = (320usize, 240usize); + let mut enc = match MfEncoder::new(w, h, 30, 1_000_000) { + Ok(e) => e, + Err(_) => return, // no hardware encoder on this box — skip + }; + let frame = vec![0x70u8; w * h * 4]; // solid BGRA + let mut kf_idx = Vec::new(); + let mut idx = 0usize; + // Prime ~40 frames: collect keyframe positions. With GOP=120 only frame 0 + // should be a keyframe. + for _ in 0..40 { + if let Ok(frames) = enc.encode(&frame) { + for f in frames { + if f.keyframe { + kf_idx.push(idx); + } + idx += 1; + } + } + } + let before = idx; + enc.request_keyframe(); + for _ in 0..12 { + if let Ok(frames) = enc.encode(&frame) { + for f in frames { + if f.keyframe { + kf_idx.push(idx); + } + idx += 1; + } + } + } + if let Ok(frames) = enc.drain() { + for f in frames { + if f.keyframe { + kf_idx.push(idx); + } + idx += 1; + } + } + // If the encoder emitted essentially no frames (some CI GPUs stall under + // a headless session), don't assert on timing — just ensure no panic. + if before < 5 { + return; + } + assert!( + kf_idx.iter().any(|&p| p >= before), + "forced keyframe should emit an IDR after request_keyframe(); \ + keyframes at {kf_idx:?}, forced after frame {before} (GOP=120)" + ); + } +} + +/// Does this Annex-B buffer begin (after a start code) with an SPS NAL (type 7)? +fn starts_with_sps(d: &[u8]) -> bool { + let mut i = 0; + if d.len() >= 4 && d[0] == 0 && d[1] == 0 && d[2] == 0 && d[3] == 1 { + i = 4; + } else if d.len() >= 3 && d[0] == 0 && d[1] == 0 && d[2] == 1 { + i = 3; + } + d.get(i).map(|b| (b & 0x1f) == 7).unwrap_or(false) +} + +impl Drop for MfEncoder { + fn drop(&mut self) { + unsafe { + let _ = self + .transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + } + } +} + +// The MFT is only ever touched from the one thread that owns this encoder; MF +// objects are agile under MTA. +unsafe impl Send for MfEncoder {} diff --git a/native/host/src/encoder.rs b/native/host/src/encoder.rs new file mode 100644 index 0000000..15a5c09 --- /dev/null +++ b/native/host/src/encoder.rs @@ -0,0 +1,218 @@ +//! Unified video encoder: prefer the platform hardware encoder (Media +//! Foundation NVENC/QSV/AMF on Windows), fall back to the portable openh264 +//! software encoder, or — on constrained links — VP9 software (libvpx), which +//! is far better per-bitrate than H.264. One `encode` call returns 0+ coded +//! frames (the async hardware path can run a frame of latency; software is 1:1). + +use crate::encode::{EncodedFrame, H264Encoder}; +use anyhow::Result; + +pub enum VideoEncoder { + #[cfg(windows)] + Hardware(crate::encode_mf::MfEncoder), + Software(H264Encoder), + #[cfg(windows)] + Vp9(Vp9State), +} + +/// VP9 encoder state. libvpx is stateless about wall-clock, so we track a start +/// instant (ms PTS) and a pending-keyframe flag (the encode loop asks for a +/// keyframe on viewer join / refresh / re-init). +#[cfg(windows)] +pub struct Vp9State { + enc: netdesk_vpx::Encoder, + want_key: bool, + start: std::time::Instant, +} + +impl VideoEncoder { + /// Build the best available H.264 encoder for this machine (hardware MF, + /// else openh264 software). This is the default fast-link path. + pub fn best(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result { + #[cfg(windows)] + { + match crate::encode_mf::MfEncoder::new(width, height, fps, bitrate_bps) { + Ok(e) => { + log::info!("encode: hardware \"{}\"", e.name()); + return Ok(VideoEncoder::Hardware(e)); + } + Err(e) => { + log::warn!("encode: no hardware encoder ({e}); using openh264 software"); + } + } + } + let e = H264Encoder::new(width, height, fps, bitrate_bps)?; + log::info!("encode: openh264 software"); + Ok(VideoEncoder::Software(e)) + } + + /// Build an encoder for a specific codec (`proto::VideoCodec`): VP9 (1) uses + /// libvpx software; anything else falls back to [`best`] (H.264). VP9 needs + /// even dimensions, so callers must pass even `width`/`height` (the capture + /// loop uses `color::scaled_even`). + pub fn with_codec( + codec_id: u8, + i444: bool, + width: usize, + height: usize, + fps: u32, + bitrate_bps: u32, + ) -> Result { + #[cfg(windows)] + { + if codec_id == 1 { + let kbps = (bitrate_bps / 1000).max(50); + match netdesk_vpx::Encoder::new_i444( + netdesk_vpx::VpxCodec::Vp9, + width as u32, + height as u32, + fps, + kbps, + i444, + ) { + Ok(enc) => { + log::info!( + "encode: VP9 software (libvpx) {width}x{height} @ {kbps} kbps{}", + if i444 { " 4:4:4" } else { "" } + ); + return Ok(VideoEncoder::Vp9(Vp9State { + enc, + want_key: true, // first frame is a keyframe anyway + start: std::time::Instant::now(), + })); + } + Err(e) => { + log::warn!("encode: VP9 init failed ({e}); using H.264"); + } + } + } + } + let _ = codec_id; + Self::best(width, height, fps, bitrate_bps) + } + + pub fn encode(&mut self, bgra: &[u8]) -> Result> { + match self { + #[cfg(windows)] + VideoEncoder::Hardware(e) => e.encode(bgra), + VideoEncoder::Software(e) => Ok(e.encode(bgra)?.into_iter().collect()), + #[cfg(windows)] + VideoEncoder::Vp9(s) => { + let pts = s.start.elapsed().as_millis() as i64; + let force_key = std::mem::take(&mut s.want_key); + let frames = s.enc.encode_bgra(bgra, pts, force_key)?; + Ok(frames + .into_iter() + .map(|f| EncodedFrame { + data: f.data, + keyframe: f.key, + }) + .collect()) + } + } + } + + pub fn request_keyframe(&mut self) { + match self { + #[cfg(windows)] + VideoEncoder::Hardware(e) => e.request_keyframe(), + VideoEncoder::Software(e) => e.request_keyframe(), + #[cfg(windows)] + VideoEncoder::Vp9(s) => s.want_key = true, + } + } + + /// Codec id for the frames this encoder emits (`proto::VideoCodec`): + /// 0 = H.264 (hardware MF / openh264 software), 1 = VP9. + pub fn codec_id(&self) -> u8 { + match self { + #[cfg(windows)] + VideoEncoder::Hardware(_) => 0, + VideoEncoder::Software(_) => 0, + #[cfg(windows)] + VideoEncoder::Vp9(_) => 1, + } + } + + pub fn is_hardware(&self) -> bool { + match self { + #[cfg(windows)] + VideoEncoder::Hardware(_) => true, + VideoEncoder::Software(_) => false, + #[cfg(windows)] + VideoEncoder::Vp9(_) => false, + } + } + + pub fn label(&self) -> String { + match self { + #[cfg(windows)] + VideoEncoder::Hardware(e) => format!("hardware: {}", e.name()), + VideoEncoder::Software(_) => "software: openh264".to_string(), + #[cfg(windows)] + VideoEncoder::Vp9(_) => "software: VP9 (libvpx)".to_string(), + } + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use netdesk_core::decode::best_decoder_for; + + // End-to-end through the real host encoder wrapper + the core decoder + // selector: BGRA -> VideoEncoder(VP9) -> best_decoder_for(1) -> RGB, proving + // the codec_id tag, pts/keyframe handling, and the BGRA<->RGB conversions + // all line up (not just the raw netdesk-vpx roundtrip). + #[test] + fn vp9_encode_decode_via_video_encoder() { + let (w, h) = (128usize, 96usize); + let mut bgra = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let p = (j * w + i) * 4; + let (b, g, r) = if j < h / 2 && i < w / 2 { + (30u8, 30, 200) // red + } else if j < h / 2 { + (30, 200, 30) // green + } else if i < w / 2 { + (200, 30, 30) // blue + } else { + (200, 200, 200) // gray + }; + bgra[p] = b; + bgra[p + 1] = g; + bgra[p + 2] = r; + bgra[p + 3] = 255; + } + } + + let mut enc = VideoEncoder::with_codec(1, false, w, h, 30, 2_000_000).expect("vp9 encoder"); + assert_eq!(enc.codec_id(), 1, "must be VP9"); + let frames = enc.encode(&bgra).expect("encode"); + assert!(!frames.is_empty(), "no VP9 packets"); + assert!(frames[0].keyframe, "first VP9 frame must be a keyframe"); + + let mut dec = best_decoder_for(1); + let mut got = None; + for f in &frames { + if let Ok(Some(fr)) = dec.decode(&f.data) { + got = Some(fr); + } + } + let fr = got.expect("decoded a frame"); + assert_eq!((fr.w, fr.h), (w, h), "decoded dims"); + + let sample = |x: usize, y: usize| { + let p = (y * w + x) * 3; + (fr.rgb[p], fr.rgb[p + 1], fr.rgb[p + 2]) // (R, G, B) + }; + let approx = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 48; + let (r, g, b) = sample(w / 4, h / 4); // red quadrant + assert!( + approx(r, 200) && approx(g, 30) && approx(b, 30), + "red quad decoded as r{r} g{g} b{b}" + ); + println!("VP9_E2E_OK {w}x{h} key_bytes={}", frames[0].data.len()); + } +} diff --git a/native/host/src/files.rs b/native/host/src/files.rs new file mode 100644 index 0000000..74dab02 --- /dev/null +++ b/native/host/src/files.rs @@ -0,0 +1,188 @@ +//! Host-side file manager: enumerate drives/roots and directory contents, and +//! the chunked read/write used by download/upload. Items are reported in the +//! viewer's wire shape `{n:name, t:"d"|"f", s:size, m:mtime}`. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +/// Top-level roots: every present drive on Windows (`C:\`, `D:\`, …), plus the +/// user's home, so the viewer's file panel has somewhere to start. +pub fn roots() -> Vec { + let mut items = Vec::new(); + #[cfg(windows)] + { + for c in b'A'..=b'Z' { + let p = format!("{}:\\", c as char); + if Path::new(&p).exists() { + items.push(json!({ "n": p, "t": "d", "s": 0, "m": 0 })); + } + } + } + #[cfg(not(windows))] + { + items.push(json!({ "n": "/", "t": "d", "s": 0, "m": 0 })); + } + items +} + +/// List a directory. Entries are sorted folders-first then by name; hidden +/// handling is left to the OS (we surface everything `read_dir` returns). +pub fn list_dir(path: &str) -> std::io::Result> { + let mut items = Vec::new(); + for entry in std::fs::read_dir(path)? { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let name = entry.file_name().to_string_lossy().to_string(); + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let is_dir = meta.is_dir(); + let size = if is_dir { 0 } else { meta.len() }; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + items.push(json!({ + "n": name, + "t": if is_dir { "d" } else { "f" }, + "s": size, + "m": mtime, + })); + } + items.sort_by(|a, b| { + let ad = a.get("t").and_then(|x| x.as_str()) == Some("d"); + let bd = b.get("t").and_then(|x| x.as_str()) == Some("d"); + bd.cmp(&ad).then_with(|| { + a.get("n") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_ascii_lowercase() + .cmp(&b.get("n").and_then(|x| x.as_str()).unwrap_or("").to_ascii_lowercase()) + }) + }); + Ok(items) +} + +/// Recursively walk `root` and return every FILE under it as +/// `{ "rel": "", "s": }`, so a viewer can +/// recreate the tree locally and download each entry. Directories are implied +/// by the relative paths (the viewer mkdir -p's each parent). Bounded to avoid +/// a runaway walk: at most `MAX_ENTRIES` files and `MAX_DEPTH` deep; symlinks +/// are not followed. Returns (entries, truncated). +pub fn walk_dir(root: &str) -> std::io::Result<(Vec, bool)> { + const MAX_ENTRIES: usize = 50_000; + const MAX_DEPTH: usize = 40; + let root_path = PathBuf::from(root); + let mut out = Vec::new(); + let mut truncated = false; + // Iterative DFS: (dir, depth). rel paths are built from `root`. + let mut stack = vec![(root_path.clone(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + if depth > MAX_DEPTH { + truncated = true; + continue; + } + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, // unreadable dir -> skip, keep going + }; + for entry in rd { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + // symlink_metadata: do NOT follow symlinks (avoid cycles / escapes). + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let path = entry.path(); + if meta.file_type().is_symlink() { + continue; + } + if meta.is_dir() { + stack.push((path, depth + 1)); + } else if meta.is_file() { + if out.len() >= MAX_ENTRIES { + truncated = true; + break; + } + // Relative path with forward slashes (the viewer joins it under + // the chosen local folder). + let rel = path + .strip_prefix(&root_path) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + out.push(json!({ "rel": rel, "s": meta.len() })); + } + } + if out.len() >= MAX_ENTRIES { + truncated = true; + break; + } + } + out.sort_by(|a, b| { + a.get("rel").and_then(|x| x.as_str()).unwrap_or("") + .cmp(b.get("rel").and_then(|x| x.as_str()).unwrap_or("")) + }); + Ok((out, truncated)) +} + +/// Where an in-progress upload is written before the atomic rename into place. +pub fn part_path(final_path: &str) -> PathBuf { + PathBuf::from(format!("{final_path}.netdesk.part")) +} + +/// Resolve an upload destination: a relative/empty dir drops onto the Desktop +/// (TeamViewer-style default), mirroring the existing host. +pub fn resolve_upload_path(path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + return p.to_path_buf(); + } + // Relative: drop on the Desktop. + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_else(|_| ".".into()); + Path::new(&home).join("Desktop").join(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn walk_dir_recurses_and_relativizes() { + // Build a small temp tree: root/{a.txt, sub/b.txt, sub/deep/c.txt}. + let base = std::env::temp_dir().join(format!("nd_walk_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("sub").join("deep")).unwrap(); + std::fs::write(base.join("a.txt"), b"AA").unwrap(); + std::fs::write(base.join("sub").join("b.txt"), b"BBBB").unwrap(); + std::fs::write(base.join("sub").join("deep").join("c.txt"), b"CCCCCC").unwrap(); + + let (entries, truncated) = walk_dir(&base.to_string_lossy()).unwrap(); + assert!(!truncated); + let rels: Vec = entries + .iter() + .map(|e| e.get("rel").and_then(|x| x.as_str()).unwrap().to_string()) + .collect(); + assert_eq!(rels, vec!["a.txt", "sub/b.txt", "sub/deep/c.txt"]); // sorted, forward slashes + // sizes carried through + let size = |rel: &str| { + entries.iter().find(|e| e.get("rel").and_then(|x| x.as_str()) == Some(rel)) + .and_then(|e| e.get("s").and_then(|x| x.as_u64())).unwrap() + }; + assert_eq!(size("a.txt"), 2); + assert_eq!(size("sub/b.txt"), 4); + assert_eq!(size("sub/deep/c.txt"), 6); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/native/host/src/input.rs b/native/host/src/input.rs new file mode 100644 index 0000000..a18c16f --- /dev/null +++ b/native/host/src/input.rs @@ -0,0 +1,521 @@ +//! Input injection via SendInput — mouse move/buttons/wheel and keyboard, wire +//! compatible with the existing NetDesk viewers (same convention the Python host +//! uses): pointer coordinates arrive in virtual-desktop pixel space and are +//! mapped to the 0..65535 absolute range over the whole virtual desktop; keys +//! arrive as DOM-style codes ("KeyA", "Enter", "ArrowLeft") and are injected as +//! scan codes so games and low-level apps see them correctly. + +#![cfg(windows)] + +use windows::Win32::UI::Input::KeyboardAndMouse::{ + MapVirtualKeyW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, + KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, + KEYEVENTF_UNICODE, MAPVK_VK_TO_VSC, + MOUSEINPUT, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, + MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, + MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, VIRTUAL_KEY, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, +}; + +/// Stateful input injector. Tracks the current mouse-button bitmask so it can +/// emit the correct press/release transitions. +pub struct Injector { + buttons: u32, +} + +impl Default for Injector { + fn default() -> Self { + Self::new() + } +} + +impl Injector { + pub fn new() -> Self { + Self { buttons: 0 } + } + + /// Inject one pointer update: move, then any button transitions, then wheel. + pub fn mouse(&mut self, x: i32, y: i32, buttons: u32, wheel: f32) { + self.mouse_move(x, y); + if buttons != self.buttons { + self.mouse_buttons(buttons, self.buttons); + self.buttons = buttons; + } + if wheel != 0.0 { + self.wheel(wheel); + } + } + + /// Inject a RELATIVE pointer update (game / pointer-lock mode): move by + /// (dx, dy) pixels using MOUSEEVENTF_MOVE WITHOUT ABSOLUTE, then any + /// button transitions, then wheel. Apps that read raw mouse motion (FPS + /// games, 3D/CAD) receive the deltas directly. Subject to the system + /// pointer-speed/accel settings, like any relative motion. + pub fn mouse_rel(&mut self, dx: i32, dy: i32, buttons: u32, wheel: f32) { + if dx != 0 || dy != 0 { + unsafe { send_mouse(dx, dy, 0, MOUSEEVENTF_MOVE) }; + } + if buttons != self.buttons { + self.mouse_buttons(buttons, self.buttons); + self.buttons = buttons; + } + if wheel != 0.0 { + self.wheel(wheel); + } + } + + /// Inject a string as literal Unicode (translate / international-keyboard + /// mode): each UTF-16 code unit is sent via KEYEVENTF_UNICODE, so the + /// exact character is typed regardless of the host's keyboard layout — + /// this is how AltGr combos, dead-key compositions and non-US layouts + /// come through correctly (the viewer's OS already resolved them to a + /// character). No VK/scan is used, so it is layout-independent. + pub fn text(&self, s: &str) { + for u in s.encode_utf16() { + unsafe { + send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE); + send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP); + } + } + } + + /// Release any held buttons (viewer left / session end), so nothing stays + /// stuck down. + pub fn release_buttons(&mut self) { + if self.buttons != 0 { + self.mouse_buttons(0, self.buttons); + self.buttons = 0; + } + } + + fn mouse_move(&self, x: i32, y: i32) { + unsafe { + let vl = GetSystemMetrics(SM_XVIRTUALSCREEN); + let vt = GetSystemMetrics(SM_YVIRTUALSCREEN); + let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1); + let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1); + let nx = ((x - vl) as i64 * 65535 / (vw - 1).max(1) as i64) as i32; + let ny = ((y - vt) as i64 * 65535 / (vh - 1).max(1) as i64) as i32; + send_mouse( + nx, + ny, + 0, + MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, + ); + } + } + + fn mouse_buttons(&self, new_b: u32, old_b: u32) { + let mut evts: Vec<(MOUSE_EVENT_FLAGS, i32)> = Vec::new(); + for (mask, down, up) in [ + (1u32, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), + (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), + (4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), + ] { + if new_b & mask != 0 && old_b & mask == 0 { + evts.push((down, 0)); + } + if new_b & mask == 0 && old_b & mask != 0 { + evts.push((up, 0)); + } + } + // Side buttons: bit 8 = X1 (back), bit 16 = X2 (forward). + for (mask, xb) in [(8u32, 0x0001i32), (16u32, 0x0002i32)] { + if new_b & mask != 0 && old_b & mask == 0 { + evts.push((MOUSEEVENTF_XDOWN, xb)); + } + if new_b & mask == 0 && old_b & mask != 0 { + evts.push((MOUSEEVENTF_XUP, xb)); + } + } + for (flags, data) in evts { + unsafe { send_mouse(0, 0, (data as u32) << 16, flags) }; + } + } + + fn wheel(&self, rows: f32) { + unsafe { + send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_WHEEL); + } + } + + /// Inject a key by DOM code + down/up. Unknown codes are ignored. + pub fn key(&self, code: &str, down: bool) { + let Some(vk) = code_to_vk(code) else { + return; + }; + unsafe { + let scan = MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC) as u16; + let mut flags = KEYEVENTF_SCANCODE; + if is_extended(code) { + flags |= KEYEVENTF_EXTENDEDKEY; + } + if !down { + flags |= KEYEVENTF_KEYUP; + } + if scan != 0 { + send_key(VIRTUAL_KEY(0), scan, flags); + } else { + // No scan code (rare) — fall back to the virtual key. + let mut f = KEYBD_EVENT_FLAGS(0); + if !down { + f |= KEYEVENTF_KEYUP; + } + send_key(VIRTUAL_KEY(vk), 0, f); + } + } + } + + /// Horizontal wheel (not surfaced by the core event yet, but available). + #[allow(dead_code)] + fn hwheel(&self, rows: f32) { + unsafe { + send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_HWHEEL); + } + } +} + +unsafe fn send_mouse(dx: i32, dy: i32, mouse_data: u32, flags: MOUSE_EVENT_FLAGS) { + let input = INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx, + dy, + mouseData: mouse_data, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + SendInput(&[input], std::mem::size_of::() as i32); +} + +unsafe fn send_key(vk: VIRTUAL_KEY, scan: u16, flags: KEYBD_EVENT_FLAGS) { + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: scan, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + SendInput(&[input], std::mem::size_of::() as i32); +} + +/// DOM `KeyboardEvent.code` -> Windows virtual-key. Mirrors the Python host so +/// every existing viewer keeps working unchanged. +fn code_to_vk(code: &str) -> Option { + if let Some(c) = code.strip_prefix("Key") { + let b = c.as_bytes(); + if b.len() == 1 && b[0].is_ascii_uppercase() { + return Some(0x41 + (b[0] - b'A') as u16); + } + } + if let Some(d) = code.strip_prefix("Digit") { + let b = d.as_bytes(); + if b.len() == 1 && b[0].is_ascii_digit() { + return Some(0x30 + (b[0] - b'0') as u16); + } + } + if let Some(d) = code.strip_prefix("Numpad") { + let b = d.as_bytes(); + if b.len() == 1 && b[0].is_ascii_digit() { + return Some(0x60 + (b[0] - b'0') as u16); + } + } + if let Some(n) = code.strip_prefix('F') { + if let Ok(num) = n.parse::() { + if (1..=24).contains(&num) { + return Some(0x70 + num - 1); + } + } + } + Some(match code { + "Backquote" => 0xC0, + "Minus" => 0xBD, + "Equal" => 0xBB, + "BracketLeft" => 0xDB, + "BracketRight" => 0xDD, + "Backslash" => 0xDC, + "Semicolon" => 0xBA, + "Quote" => 0xDE, + "Comma" => 0xBC, + "Period" => 0xBE, + "Slash" => 0xBF, + "IntlBackslash" => 0xE2, + "CapsLock" => 0x14, + "Space" => 0x20, + "Enter" | "NumpadEnter" => 0x0D, + "Backspace" => 0x08, + "Tab" => 0x09, + "Escape" => 0x1B, + "Delete" => 0x2E, + "Insert" => 0x2D, + "Home" => 0x24, + "End" => 0x23, + "PageUp" => 0x21, + "PageDown" => 0x22, + "ArrowLeft" => 0x25, + "ArrowUp" => 0x26, + "ArrowRight" => 0x27, + "ArrowDown" => 0x28, + "ControlLeft" => 0xA2, + "ControlRight" => 0xA3, + "AltLeft" => 0xA4, + "AltRight" => 0xA5, + "ShiftLeft" => 0xA0, + "ShiftRight" => 0xA1, + "MetaLeft" => 0x5B, + "MetaRight" => 0x5C, + "ContextMenu" => 0x5D, + "PrintScreen" => 0x2C, + "ScrollLock" => 0x91, + "Pause" => 0x13, + "NumLock" => 0x90, + "NumpadMultiply" => 0x6A, + "NumpadAdd" => 0x6B, + "NumpadSubtract" => 0x6D, + "NumpadDecimal" => 0x6E, + "NumpadDivide" => 0x6F, + // Media / volume keys (DOM UI Events code values) -> VK_* media codes, so + // a viewer that grabs them (system-key hook) drives the host's volume, + // playback and browser the same as sitting at the machine. + "AudioVolumeMute" => 0xAD, + "AudioVolumeDown" => 0xAE, + "AudioVolumeUp" => 0xAF, + "MediaTrackNext" => 0xB0, + "MediaTrackPrevious" => 0xB1, + "MediaStop" => 0xB2, + "MediaPlayPause" => 0xB3, + "LaunchMail" => 0xB4, + "MediaSelect" | "LaunchMediaPlayer" => 0xB5, + "LaunchApp1" => 0xB6, + "LaunchApp2" => 0xB7, + "BrowserBack" => 0xA6, + "BrowserForward" => 0xA7, + "BrowserRefresh" => 0xA8, + "BrowserStop" => 0xA9, + "BrowserSearch" => 0xAA, + "BrowserFavorites" => 0xAB, + "BrowserHome" => 0xAC, + _ => return None, + }) +} + +fn is_extended(code: &str) -> bool { + matches!( + code, + "ArrowRight" + | "ArrowLeft" + | "ArrowUp" + | "ArrowDown" + | "PageUp" + | "PageDown" + | "Home" + | "End" + | "Insert" + | "Delete" + | "NumLock" + | "PrintScreen" + | "Pause" + | "ControlRight" + | "AltRight" + | "MetaLeft" + | "MetaRight" + | "ContextMenu" + | "NumpadEnter" + | "NumpadDivide" + | "ScrollLock" + // media / volume / browser keys are extended + | "AudioVolumeMute" + | "AudioVolumeDown" + | "AudioVolumeUp" + | "MediaTrackNext" + | "MediaTrackPrevious" + | "MediaStop" + | "MediaPlayPause" + | "LaunchMail" + | "MediaSelect" + | "LaunchMediaPlayer" + | "LaunchApp1" + | "LaunchApp2" + | "BrowserBack" + | "BrowserForward" + | "BrowserRefresh" + | "BrowserStop" + | "BrowserSearch" + | "BrowserFavorites" + | "BrowserHome" + ) +} + +#[cfg(test)] +mod tests { + use super::code_to_vk; + + #[test] + fn maps_media_and_volume_keys() { + // Volume + media-transport keys the viewer's system-key grab forwards. + assert_eq!(code_to_vk("AudioVolumeMute"), Some(0xAD)); + assert_eq!(code_to_vk("AudioVolumeDown"), Some(0xAE)); + assert_eq!(code_to_vk("AudioVolumeUp"), Some(0xAF)); + assert_eq!(code_to_vk("MediaTrackNext"), Some(0xB0)); + assert_eq!(code_to_vk("MediaTrackPrevious"), Some(0xB1)); + assert_eq!(code_to_vk("MediaStop"), Some(0xB2)); + assert_eq!(code_to_vk("MediaPlayPause"), Some(0xB3)); + assert_eq!(code_to_vk("BrowserBack"), Some(0xA6)); + assert_eq!(code_to_vk("BrowserHome"), Some(0xAC)); + } + + #[test] + fn maps_system_keys_the_hook_grabs() { + // The WH_KEYBOARD_LL grab forwards these; the host must inject them. + assert_eq!(code_to_vk("MetaLeft"), Some(0x5B)); // LWin + assert_eq!(code_to_vk("MetaRight"), Some(0x5C)); // RWin + assert_eq!(code_to_vk("PrintScreen"), Some(0x2C)); + assert_eq!(code_to_vk("Tab"), Some(0x09)); + assert_eq!(code_to_vk("Escape"), Some(0x1B)); + } + + #[test] + fn still_maps_ordinary_keys() { + assert_eq!(code_to_vk("KeyA"), Some(0x41)); + assert_eq!(code_to_vk("Digit0"), Some(0x30)); + assert_eq!(code_to_vk("F5"), Some(0x74)); + assert_eq!(code_to_vk("Nonsense"), None); + } +} + + +#[cfg(test)] +mod rel_mouse_tests { + use super::*; + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos}; + + /// Real OS effect: a relative move must shift the ACTUAL cursor in the + /// commanded direction by a sane magnitude. Mirrors the e2e GetCursorPos + /// proof but as a focused, networkless unit test. The exact magnitude is + /// subject to the system pointer-speed/accel curve, so we assert direction + /// + a generous band around the sent (40, 30) rather than an exact match. + #[test] + fn mouse_rel_moves_cursor_relatively() { + unsafe { + let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(2); + let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(2); + // Park at center so the delta is not clamped at a screen edge. + let _ = SetCursorPos(vw / 2, vh / 2); + std::thread::sleep(std::time::Duration::from_millis(30)); + let mut before = POINT::default(); + let _ = GetCursorPos(&mut before); + + let mut inj = Injector::new(); + inj.mouse_rel(40, 30, 0, 0.0); + std::thread::sleep(std::time::Duration::from_millis(40)); + + let mut after = POINT::default(); + let _ = GetCursorPos(&mut after); + let (ddx, ddy) = (after.x - before.x, after.y - before.y); + println!( + "REL_MOUSE_OK before=({},{}) after=({},{}) delta=({},{})", + before.x, before.y, after.x, after.y, ddx, ddy + ); + // "Enhance pointer precision" (mouse acceleration) scales the whole + // relative vector by a system-dependent factor (seen 1.5x–3.2x on + // this box run-to-run), so assert the DIRECTION + the 40:30 ≈ 4:3 + // ratio (preserved under uniform scaling) + sane bounds, never an + // absolute magnitude. 3*ddx ≈ 4*ddy proves the sent vector landed. + let ratio_ok = (3 * ddx - 4 * ddy).abs() <= (3 * ddx).max(1) / 4; + assert!( + ddx > 3 && ddy > 2 && ddx > ddy && ddx < 1000 && ratio_ok, + "relative move delta ({ddx},{ddy}) must track the sent (40,30) vector \ + (right+down, ratio ~4:3), not stuck/absolute" + ); + let _ = SetCursorPos(vw / 2, vh / 2); + } + } +} + + +#[cfg(test)] +mod translate_kbd_tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + use std::time::{Duration, Instant}; + use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage, + UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE, WH_KEYBOARD_LL, WM_KEYDOWN, + }; + + // (vkCode, scanCode) captured on each keydown the LL hook sees. + static CAP: OnceLock>> = OnceLock::new(); + + unsafe extern "system" fn cap_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if code == 0 && wparam.0 as u32 == WM_KEYDOWN { + let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT); + if let Some(m) = CAP.get() { + if let Ok(mut v) = m.lock() { + v.push((kb.vkCode, kb.scanCode)); + } + } + } + CallNextHookEx(HHOOK::default(), code, wparam, lparam) + } + + fn pump(ms: u64) { + let end = Instant::now() + Duration::from_millis(ms); + while Instant::now() < end { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Real OS effect, focus-INDEPENDENT: Injector::text injects via + /// KEYEVENTF_UNICODE, which a WH_KEYBOARD_LL hook sees as VK_PACKET (0xE7) + /// with the UTF-16 code unit in `scanCode`. So "Ae-acute" ("A" + U+00E9) + /// must produce keydown packets 0x41 then 0xE9 — proving the exact character + /// is injected regardless of the host keyboard layout (the point of + /// translate mode). + #[test] + fn text_injects_unicode_as_vk_packet() { + const VK_PACKET: u32 = 0xE7; + let _ = CAP.set(Mutex::new(Vec::new())); + let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(cap_proc), None, 0) } + .expect("install LL hook"); + + Injector::new().text("A\u{00e9}"); + pump(400); + + unsafe { + let _ = UnhookWindowsHookEx(hook); + } + let got = CAP.get().unwrap().lock().unwrap().clone(); + let packets: Vec = got + .iter() + .filter(|(vk, _)| *vk == VK_PACKET) + .map(|(_, sc)| *sc) + .collect(); + println!("TRANSLATE_KBD_OK keydown unicode packets = {:04X?}", packets); + assert_eq!( + packets, + vec![0x41u32, 0xE9], + "expected Unicode packets for 'A','e-acute'" + ); + } +} diff --git a/native/host/src/keyhook.rs b/native/host/src/keyhook.rs new file mode 100644 index 0000000..48ad999 --- /dev/null +++ b/native/host/src/keyhook.rs @@ -0,0 +1,216 @@ +//! System-key grab for the viewer — a low-level keyboard hook (WH_KEYBOARD_LL) +//! that catches the keys Windows normally eats before an app can see them: the +//! Windows keys, PrintScreen, and the Alt+Tab / Alt+Esc / Ctrl+Esc task-switch +//! combos. While [`set_active`]`(true)` (the viewer calls this when a session is +//! live AND its window is focused), each grabbed key is forwarded to the host +//! (as a DOM `code`, through the install() channel) and the LOCAL action is +//! SUPPRESSED — so pressing Alt+Tab or the Windows key switches/opens on the +//! REMOTE machine instead of this one (RustDesk's "transmit system keys"). +//! +//! The hook is process-global but only acts while active, so it is inert when no +//! session is focused. It lives in this shared crate (next to input.rs's +//! injection) so it is unit-testable; only the viewer installs it. + +#![cfg(windows)] + +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Mutex, OnceLock}; + +use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_CONTROL, VK_MENU}; +use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, WH_KEYBOARD_LL, + WM_KEYDOWN, WM_SYSKEYDOWN, +}; + +static ACTIVE: AtomicBool = AtomicBool::new(false); +static SINK: OnceLock> = OnceLock::new(); +static RX: OnceLock>> = OnceLock::new(); +static HOOK: AtomicIsize = AtomicIsize::new(0); + +/// Turn grabbing on/off. The viewer sets this to `session_active && focused` +/// every frame; when false the hook passes everything through untouched. +pub fn set_active(on: bool) { + ACTIVE.store(on, Ordering::Relaxed); +} + +/// Whether the grab is currently installed + active. +pub fn is_active() -> bool { + ACTIVE.load(Ordering::Relaxed) +} + +/// The DOM `code` the host's `code_to_vk` expects for a grabbed virtual key. +fn system_code(vk: u16) -> Option<&'static str> { + Some(match vk { + 0x5B => "MetaLeft", // Left Windows + 0x5C => "MetaRight", // Right Windows + 0x2C => "PrintScreen", // PrtScn + 0x09 => "Tab", // only grabbed with Alt down (Alt+Tab) + 0x1B => "Escape", // only grabbed with Alt/Ctrl down (Alt/Ctrl+Esc) + _ => return None, + }) +} + +fn alt_down() -> bool { + unsafe { (GetAsyncKeyState(VK_MENU.0 as i32) as u16 & 0x8000) != 0 } +} +fn ctrl_down() -> bool { + unsafe { (GetAsyncKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0 } +} + +/// Grab the Windows keys + PrtScn unconditionally; Tab/Esc only as a task-switch +/// combo (so ordinary Tab/Esc still go through egui normally). +fn should_grab(vk: u16) -> bool { + match vk { + 0x5B | 0x5C | 0x2C => true, + 0x09 => alt_down(), // Alt+Tab + 0x1B => alt_down() || ctrl_down(), // Alt+Esc / Ctrl+Esc + _ => false, + } +} + +unsafe extern "system" fn proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + // HC_ACTION == 0: the struct is valid and we may inspect/act on it. + if code == 0 && ACTIVE.load(Ordering::Relaxed) { + let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT); + let vk = kb.vkCode as u16; + if should_grab(vk) { + if let Some(code_str) = system_code(vk) { + let down = matches!(wparam.0 as u32, WM_KEYDOWN | WM_SYSKEYDOWN); + if let Some(tx) = SINK.get() { + let _ = tx.send((code_str, down)); + } + return LRESULT(1); // swallow it locally — it goes to the remote + } + } + } + CallNextHookEx(HHOOK::default(), code, wparam, lparam) +} + +/// Install the global low-level keyboard hook once (owns its own channel; drain +/// grabbed keys with [`poll`]). Returns false if the hook could not be +/// installed. Must be called from a thread that pumps messages (the viewer's UI +/// thread does); idempotent. +pub fn install() -> bool { + if HOOK.load(Ordering::Relaxed) != 0 { + return true; // already installed + } + let (tx, rx) = std::sync::mpsc::channel(); + let _ = SINK.set(tx); + let _ = RX.set(Mutex::new(rx)); + match unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(proc), None, 0) } { + Ok(h) => { + HOOK.store(h.0 as isize, Ordering::Relaxed); + true + } + Err(e) => { + log::warn!("keyhook: SetWindowsHookExW failed: {e}"); + false + } + } +} + +/// Drain the keys grabbed since the last call, as `(dom_code, down)`. The viewer +/// calls this each frame and forwards each to the host via `Session::key`. +pub fn poll() -> Vec<(&'static str, bool)> { + let mut out = Vec::new(); + if let Some(m) = RX.get() { + if let Ok(rx) = m.lock() { + while let Ok(ev) = rx.try_recv() { + out.push(ev); + } + } + } + out +} + +/// Remove the hook (process exit also removes it). +pub fn uninstall() { + let raw = HOOK.swap(0, Ordering::Relaxed); + if raw != 0 { + unsafe { + let _ = UnhookWindowsHookEx(HHOOK(raw as *mut core::ffi::c_void)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + VIRTUAL_KEY, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE, + }; + + unsafe fn send_vk(vk: u16, down: bool) { + let mut flags = KEYBD_EVENT_FLAGS(0); + if !down { + flags |= KEYEVENTF_KEYUP; + } + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk), + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + SendInput(&[input], std::mem::size_of::() as i32); + } + + fn pump(ms: u64) { + let end = Instant::now() + Duration::from_millis(ms); + while Instant::now() < end { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + // One test (the hook + ACTIVE are process-global, so we must not run two in + // parallel). Injected keys ARE delivered to WH_KEYBOARD_LL hooks, so we prove + // the grab end to end: while ACTIVE, injecting the Left Windows key is + // forwarded as "MetaLeft" down+up (and suppressed locally, returning 1); + // while INACTIVE, nothing is grabbed (keys pass through to the app). + #[test] + fn grabs_system_keys_only_while_active() { + assert!(install(), "hook should install"); + + // ACTIVE: the Windows key is grabbed + forwarded. + set_active(true); + unsafe { + send_vk(0x5B, true); + send_vk(0x5B, false); + } + pump(400); + let got = poll(); + assert!( + got.contains(&("MetaLeft", true)) && got.contains(&("MetaLeft", false)), + "LWin should be grabbed as MetaLeft down+up while active; got {got:?}" + ); + + // INACTIVE: the same key is passed through, grabbed nothing. + set_active(false); + unsafe { + send_vk(0x5B, true); + send_vk(0x5B, false); + } + pump(200); + uninstall(); + assert!(poll().is_empty(), "nothing should be grabbed while inactive"); + } +} diff --git a/native/host/src/lib.rs b/native/host/src/lib.rs new file mode 100644 index 0000000..4ec1bf6 --- /dev/null +++ b/native/host/src/lib.rs @@ -0,0 +1,58 @@ +//! netdesk-host — the native Rust host engine: DXGI screen capture + H.264 +//! encode + SendInput injection, wired to `netdesk_core::host::run_host`. This +//! replaces the Python host so the Windows app is one unified Rust binary +//! (host + viewer). Developed by Akronlogic. + +#[cfg(windows)] +pub mod capture; + +pub mod encode; +pub mod color; +#[cfg(windows)] +pub mod encode_mf; + +#[cfg(windows)] +pub mod input; + +#[cfg(windows)] +pub mod sys; + +#[cfg(windows)] +pub mod cursor; +pub mod encoder; +#[cfg(windows)] +pub mod audio; +pub mod files; +#[cfg(windows)] +pub mod qos; +pub mod run; +#[cfg(windows)] +pub mod telemetry; +#[cfg(windows)] +pub mod service; +pub mod wol; +#[cfg(windows)] +pub mod term; + +#[cfg(windows)] +pub mod annot; +pub mod privacy; + +#[cfg(windows)] +pub mod keyhook; + +/// Make this process per-monitor DPI aware so DXGI capture returns real +/// physical pixels (not a virtualized, scaled desktop). Safe to call once at +/// startup; a no-op on non-Windows. +#[cfg(windows)] +pub fn set_dpi_aware() { + use windows::Win32::UI::HiDpi::{ + SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } +} + +#[cfg(not(windows))] +pub fn set_dpi_aware() {} diff --git a/native/host/src/privacy.rs b/native/host/src/privacy.rs new file mode 100644 index 0000000..cc35f9b --- /dev/null +++ b/native/host/src/privacy.rs @@ -0,0 +1,131 @@ +//! Driver-free privacy mode. A fullscreen, topmost, opaque-black overlay marked +//! `WDA_EXCLUDEFROMCAPTURE`, so: +//! - the LOCAL monitor(s) show solid black — a bystander at the host sees +//! nothing of what the remote operator is doing; +//! - the host's OWN screen capture (DXGI Desktop Duplication + the GDI BitBlt +//! fallback) EXCLUDES the overlay, so the remote operator still sees and +//! controls the real desktop behind it. +//! The overlay is also click-through + non-activating (WS_EX_TRANSPARENT | +//! WS_EX_NOACTIVATE), so the operator's injected SendInput passes straight +//! through to the real windows; blocking the LOCAL keyboard/mouse is the +//! separate `block_input` toggle. No virtual-display driver is needed — verified +//! by the `netdesk-privacy-probe` bin (WDA_EXCLUDEFROMCAPTURE hid a window from +//! both DXGI and GDI capture, 197600 -> 0 magenta pixels). +//! +//! The overlay lives on its own thread with a message pump (a window must be +//! serviced on its creating thread); dropping [`Privacy`] tears it down and +//! restores the screen. + +#![cfg(windows)] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::Duration; + +use windows::core::w; +use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Graphics::Gdi::CreateSolidBrush; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics, + PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow, + TranslateMessage, LWA_ALPHA, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WNDCLASSW, + WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP, + WS_VISIBLE, +}; + +unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT { + DefWindowProcW(h, m, w, l) +} + +/// A live privacy-mode blackout. Drop it to restore the screen. +pub struct Privacy { + stop: Arc, + thread: Option>, +} + +impl Privacy { + /// Raise the blackout overlay (returns once the window thread has started). + pub fn start() -> Privacy { + let stop = Arc::new(AtomicBool::new(false)); + let s2 = stop.clone(); + let thread = std::thread::Builder::new() + .name("netdesk-privacy".into()) + .spawn(move || unsafe { run(s2) }) + .ok(); + Privacy { stop, thread } + } +} + +impl Drop for Privacy { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +unsafe fn run(stop: Arc) { + let hinst = HINSTANCE::default(); + let brush = CreateSolidBrush(COLORREF(0x0000_0000)); // solid black + let class = w!("NdPrivacyBlackout"); + let wc = WNDCLASSW { + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: class, + hbrBackground: brush, + ..Default::default() + }; + // 0 if the class is already registered (a prior toggle this process) — fine. + let _ = RegisterClassW(&wc); + + // Cover the whole virtual desktop (all monitors). + let x = GetSystemMetrics(SM_XVIRTUALSCREEN); + let y = GetSystemMetrics(SM_YVIRTUALSCREEN); + let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1); + let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1); + + let hwnd = match CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE, + class, + w!("nd-privacy"), + WS_POPUP | WS_VISIBLE, + x, + y, + cx, + cy, + None, + None, + hinst, + None, + ) { + Ok(h) => h, + Err(e) => { + log::warn!("privacy: window create failed: {e}"); + return; + } + }; + // Opaque (alpha 255) so the overlay is solid black on the physical screen. + let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA); + // Exclude from THIS host's own capture so the remote still sees the desktop. + if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) { + log::warn!("privacy: SetWindowDisplayAffinity failed: {e}"); + } + // Show without activating (keyboard focus stays on the real foreground app, + // so injected keystrokes still land there). + let _ = ShowWindow(hwnd, SW_SHOWNA); + log::info!("privacy: blackout up ({cx}x{cy} @ {x},{y}); excluded from capture, click-through"); + + let mut msg = MSG::default(); + while !stop.load(Ordering::Relaxed) { + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + std::thread::sleep(Duration::from_millis(30)); + } + let _ = DestroyWindow(hwnd); + log::info!("privacy: blackout down; screen restored"); +} diff --git a/native/host/src/qos.rs b/native/host/src/qos.rs new file mode 100644 index 0000000..3c3326e --- /dev/null +++ b/native/host/src/qos.rs @@ -0,0 +1,365 @@ +//! Adaptive quality controller — the smoothness engine. A faithful adaptation +//! of RustDesk's `src/server/video_qos.rs` (delay-threshold ABR + FPS/scale +//! backoff with congestion confirmation and gradual recovery), distilled to a +//! pure, deterministic state machine the host feeds with viewer telemetry. +//! +//! The viewer reports, ~once a second, how far behind it is — a `delay` (how +//! long frames queue/decode in front of it) plus the transport RTT and packet +//! loss. [`Qos::on_sample`] turns that into the next `(fps, bitrate, scale)` for +//! the live encoder (the same knobs the manual `set.quality` lever drives). The +//! user's manual quality is the CEILING; the controller only ever trims BELOW it +//! under congestion and restores back toward it when the link is healthy. +//! +//! Priority mirrors RustDesk: **reduce bitrate first** (keep motion smooth), +//! then frame-rate (down to a floor), then resolution scale as the last resort; +//! recover in the reverse-ish order (frame-rate, then bitrate, then full +//! resolution). Congestion must be CONFIRMED (several bad samples in a row) +//! before cutting, so a single jittery reply does not thrash the stream. + +/// A good-vs-bad network boundary: frames more than this far behind mean the +/// link cannot keep up (RustDesk's `DELAY_THRESHOLD_150MS`). +pub const DELAY_THRESHOLD_MS: u32 = 150; +/// Packet loss above this also counts as congestion. +pub const LOSS_THRESHOLD: f32 = 0.05; +/// Consecutive bad samples required before a reduction (confirm, don't thrash). +const BAD_CONFIRM: u8 = 3; +/// Consecutive good samples required before a step of recovery. +const GOOD_CONFIRM: u8 = 3; +/// Multiplicative bitrate cut on MILD congestion (keep 80%). Severe congestion +/// cuts harder (see [`severity_cut`]) so the stream sheds load fast when it is +/// badly behind, then trims gently near the threshold — RustDesk's graduated +/// backoff rather than one fixed step. +const BITRATE_CUT: f32 = 0.8; + +/// Bitrate retention factor for how far past the delay/loss threshold a sample +/// is: gentle (0.8) just over the line, aggressive (0.6) when badly congested. +fn severity_cut(delay_ms: u32, loss: f32, threshold_ms: u32) -> f32 { + let t = threshold_ms.max(1); + if delay_ms >= t * 3 || loss >= 0.15 { + 0.6 + } else if delay_ms >= t * 2 || loss >= 0.10 { + 0.7 + } else { + BITRATE_CUT + } +} +/// FPS step down/up. +const FPS_STEP: u32 = 5; +/// Scale step down/up (resolution), last resort. +const SCALE_STEP: f32 = 0.1; + +/// Startup frame-rate the ramp begins at (RustDesk-style slow start). +pub const INIT_FPS: u32 = 15; +/// How long the startup ramp takes to climb from [`INIT_FPS`] to the target. +pub const RAMP_MS: u64 = 3000; + +/// Startup FPS ramp: a fresh session begins at [`INIT_FPS`] and climbs linearly +/// to the target over the first [`RAMP_MS`], so the initial keyframe + buffer +/// fill don't arrive as a full-rate burst that stutters before the decode / +/// jitter pipeline has warmed up. After [`RAMP_MS`] it returns the target +/// unchanged (no limiting), and a target already at/below [`INIT_FPS`] is never +/// slowed. Pure + deterministic; the capture loop caps its pacing fps with this. +pub fn startup_fps(elapsed_ms: u64, target_fps: u32) -> u32 { + let target = target_fps.max(1); + if elapsed_ms >= RAMP_MS || target <= INIT_FPS { + return target; + } + // Linear climb INIT_FPS -> target across RAMP_MS. + let span = (target - INIT_FPS) as u64; + let add = (span * elapsed_ms / RAMP_MS) as u32; + (INIT_FPS + add).min(target) +} + +/// Static bounds + the user's manual ceiling. `ceil_*` are the manual quality +/// (the maximum the controller will restore to). +#[derive(Debug, Clone, Copy)] +pub struct QosConfig { + pub ceil_fps: u32, + pub ceil_bitrate: u32, // kbps + pub min_fps: u32, + pub min_bitrate: u32, // kbps + pub min_scale: f32, + pub delay_threshold_ms: u32, +} + +impl QosConfig { + /// Defaults around a 1080p/30fps/6Mbps session, floors that stay usable on a + /// bad link (5fps, 400kbps, half resolution). + pub fn new(ceil_fps: u32, ceil_bitrate: u32) -> Self { + Self { + ceil_fps: ceil_fps.max(1), + ceil_bitrate: ceil_bitrate.max(1), + min_fps: 5, + min_bitrate: 400, + min_scale: 0.5, + delay_threshold_ms: DELAY_THRESHOLD_MS, + } + } +} + +/// What the controller wants the encoder set to right now. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QosTarget { + pub fps: u32, + pub bitrate: u32, // kbps + pub scale: f32, +} + +/// The adaptive controller. Pure + deterministic: feed samples, read targets. +#[derive(Debug, Clone)] +pub struct Qos { + cfg: QosConfig, + fps: u32, + bitrate: u32, + scale: f32, + bad_run: u8, + good_run: u8, +} + +impl Qos { + /// Start at the user's ceiling (full quality) — the controller only trims + /// below it under measured congestion. + pub fn new(cfg: QosConfig) -> Self { + Self { + fps: cfg.ceil_fps, + bitrate: cfg.ceil_bitrate, + scale: 1.0, + cfg, + bad_run: 0, + good_run: 0, + } + } + + /// The current target. + pub fn target(&self) -> QosTarget { + QosTarget { + fps: self.fps, + bitrate: self.bitrate, + scale: self.scale, + } + } + + /// Raise the ceiling (the user moved the manual quality lever). Current + /// values are re-clamped under the new ceiling; a raised ceiling lets the + /// controller recover higher. + pub fn set_ceiling(&mut self, ceil_fps: u32, ceil_bitrate: u32) { + self.cfg.ceil_fps = ceil_fps.max(1); + self.cfg.ceil_bitrate = ceil_bitrate.max(1); + self.fps = self.fps.min(self.cfg.ceil_fps); + self.bitrate = self.bitrate.min(self.cfg.ceil_bitrate); + } + + /// Feed one telemetry sample (delay in ms, packet loss 0..1). Returns the + /// new target. Congestion is confirmed over [`BAD_CONFIRM`] samples before a + /// cut; recovery takes [`GOOD_CONFIRM`] good samples per step. + pub fn on_sample(&mut self, delay_ms: u32, loss: f32) -> QosTarget { + let congested = delay_ms >= self.cfg.delay_threshold_ms || loss >= LOSS_THRESHOLD; + if congested { + self.good_run = 0; + self.bad_run = self.bad_run.saturating_add(1); + if self.bad_run >= BAD_CONFIRM { + let cut = severity_cut(delay_ms, loss, self.cfg.delay_threshold_ms); + self.reduce(cut); + self.bad_run = 0; // require fresh confirmation before the next cut + } + } else { + self.bad_run = 0; + self.good_run = self.good_run.saturating_add(1); + if self.good_run >= GOOD_CONFIRM { + self.grow(); + self.good_run = 0; + } + } + self.target() + } + + /// Trim one step: bitrate first (keep motion), then fps, then resolution. + /// `cut` is the bitrate retention factor for this sample's severity. + fn reduce(&mut self, cut: f32) { + if self.bitrate > self.cfg.min_bitrate { + let next = (self.bitrate as f32 * cut) as u32; + self.bitrate = next.max(self.cfg.min_bitrate); + } else if self.fps > self.cfg.min_fps { + self.fps = self.fps.saturating_sub(FPS_STEP).max(self.cfg.min_fps); + } else if self.scale > self.cfg.min_scale + 1e-3 { + self.scale = (self.scale - SCALE_STEP).max(self.cfg.min_scale); + } + } + + /// Restore one step toward the ceiling: full resolution, then frame-rate, + /// then bitrate. + fn grow(&mut self) { + if self.scale < 1.0 - 1e-3 { + self.scale = (self.scale + SCALE_STEP).min(1.0); + } else if self.fps < self.cfg.ceil_fps { + self.fps = (self.fps + FPS_STEP).min(self.cfg.ceil_fps); + } else if self.bitrate < self.cfg.ceil_bitrate { + // +≤ a quarter of the ceiling per step, like RustDesk's capped ramp. + let step = (self.cfg.ceil_bitrate / 4).max(200); + self.bitrate = (self.bitrate + step).min(self.cfg.ceil_bitrate); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn qos() -> Qos { + Qos::new(QosConfig::new(30, 6000)) + } + + #[test] + fn starts_at_ceiling() { + let t = qos().target(); + assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0)); + } + + #[test] + fn healthy_link_stays_at_ceiling() { + let mut q = qos(); + for _ in 0..50 { + q.on_sample(20, 0.0); + } + let t = q.target(); + assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0)); + } + + #[test] + fn single_bad_sample_does_not_cut() { + let mut q = qos(); + q.on_sample(300, 0.0); // one bad reply + assert_eq!(q.target().bitrate, 6000, "must confirm congestion before cutting"); + } + + #[test] + fn confirmed_congestion_cuts_bitrate_first() { + let mut q = qos(); + for _ in 0..BAD_CONFIRM { + q.on_sample(400, 0.0); + } + let t = q.target(); + assert!(t.bitrate < 6000, "bitrate cut first"); + assert_eq!(t.fps, 30, "fps untouched while bitrate has room"); + assert_eq!(t.scale, 1.0, "scale untouched while bitrate has room"); + } + + #[test] + fn heavy_congestion_walks_bitrate_then_fps_then_scale() { + let mut q = qos(); + for _ in 0..400 { + q.on_sample(500, 0.1); // relentless congestion + } + let t = q.target(); + assert_eq!(t.bitrate, 400, "bitrate at floor"); + assert_eq!(t.fps, 5, "fps at floor"); + assert!((t.scale - 0.5).abs() < 1e-3, "scale at floor"); + } + + #[test] + fn severe_congestion_cuts_harder_than_mild() { + // One confirmed cut at severe delay (>=3x threshold) must drop bitrate + // further than one confirmed cut just over the threshold. + let mut mild = qos(); + for _ in 0..BAD_CONFIRM { + mild.on_sample(DELAY_THRESHOLD_MS + 5, 0.0); // ~threshold -> 0.8 + } + let mut severe = qos(); + for _ in 0..BAD_CONFIRM { + severe.on_sample(DELAY_THRESHOLD_MS * 4, 0.0); // deep -> 0.6 + } + assert_eq!(mild.target().bitrate, (6000.0 * 0.8) as u32); + assert_eq!(severe.target().bitrate, (6000.0 * 0.6) as u32); + assert!( + severe.target().bitrate < mild.target().bitrate, + "severe congestion should shed more bitrate per cut" + ); + } + + #[test] + fn loss_alone_triggers_congestion() { + let mut q = qos(); + for _ in 0..BAD_CONFIRM { + q.on_sample(10, 0.2); // low delay but 20% loss + } + assert!(q.target().bitrate < 6000, "packet loss counts as congestion"); + } + + #[test] + fn recovers_toward_ceiling_after_congestion() { + let mut q = qos(); + for _ in 0..400 { + q.on_sample(500, 0.1); + } + assert!(q.target().bitrate <= 400); + // link heals + for _ in 0..1000 { + q.on_sample(20, 0.0); + } + let t = q.target(); + assert_eq!(t.fps, 30, "fps recovered to ceiling"); + assert_eq!(t.bitrate, 6000, "bitrate recovered to ceiling"); + assert!((t.scale - 1.0).abs() < 1e-3, "scale recovered to full"); + } + + #[test] + fn never_exceeds_ceiling_on_recovery() { + let mut q = qos(); + for _ in 0..5000 { + q.on_sample(5, 0.0); + } + let t = q.target(); + assert!(t.fps <= 30 && t.bitrate <= 6000 && t.scale <= 1.0); + } + + #[test] + fn raising_ceiling_lets_it_climb() { + let mut q = Qos::new(QosConfig::new(15, 3000)); + for _ in 0..200 { + q.on_sample(10, 0.0); + } + assert_eq!(q.target().fps, 15); + q.set_ceiling(60, 12000); + for _ in 0..1000 { + q.on_sample(10, 0.0); + } + assert_eq!(q.target().fps, 60, "climbs to the raised ceiling"); + } + + #[test] + fn startup_ramp_begins_at_init_fps() { + assert_eq!(startup_fps(0, 60), INIT_FPS, "a fresh session starts at the ramp floor"); + assert_eq!(startup_fps(0, 30), INIT_FPS); + } + + #[test] + fn startup_ramp_reaches_target_by_ramp_ms() { + assert_eq!(startup_fps(RAMP_MS, 60), 60, "full target once the ramp window elapses"); + assert_eq!(startup_fps(RAMP_MS + 1000, 60), 60, "stays at target after the ramp"); + } + + #[test] + fn startup_ramp_is_monotonic_nondecreasing() { + let mut prev = 0; + for ms in (0..=RAMP_MS).step_by(100) { + let f = startup_fps(ms, 60); + assert!(f >= prev, "ramp must never step backward (ms={ms})"); + assert!(f >= INIT_FPS && f <= 60, "ramp stays within [INIT_FPS, target]"); + prev = f; + } + } + + #[test] + fn startup_ramp_midpoint_is_between_floor_and_target() { + let mid = startup_fps(RAMP_MS / 2, 60); + assert!(mid > INIT_FPS && mid < 60, "half-way through, fps is between floor and target (got {mid})"); + } + + #[test] + fn startup_ramp_never_slows_a_low_target() { + // A target already at/below the floor is served at full rate immediately. + assert_eq!(startup_fps(0, 15), 15); + assert_eq!(startup_fps(0, 10), 10); + assert_eq!(startup_fps(500, 5), 5); + } +} diff --git a/native/host/src/run.rs b/native/host/src/run.rs new file mode 100644 index 0000000..7cefb9d --- /dev/null +++ b/native/host/src/run.rs @@ -0,0 +1,1994 @@ +//! The native Windows host engine: it captures the screen, encodes H.264 with +//! the best available encoder, serves one viewer at a time over the NetDesk +//! protocol (via `netdesk_core::host::run_host`), injects the viewer's mouse and +//! keyboard, and auto-reconnects so the machine stays reachable. This is the +//! Rust replacement for the Python host engine. + +#![cfg(windows)] + +use crate::capture::{Capturer, DxgiCapturer}; +use crate::encoder::VideoEncoder; +use crate::input::Injector; +use anyhow::{anyhow, Result}; +use netdesk_core::crypto; +use netdesk_core::host::{run_host, HostConfig, HostControl, HostEvent, HostFrame, HostPerms}; +use serde_json::json; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Notify}; + +/// Engine configuration. `password` is turned into PBKDF2 credentials locally; +/// it never leaves the machine. +#[derive(Clone)] +pub struct EngineConfig { + pub ws_url: String, + pub host_id: String, + pub name: String, + pub password: String, + pub monitor: u32, + pub fps: u32, + pub bitrate: u32, + /// Host-enforced per-session permissions (input/clipboard/files/audio). + /// Enforced in the core engine (events never emitted when disabled) and + /// advertised to viewers in `session.accept` `info.perms`. + pub perms: HostPerms, + /// Approve-before-connect: hold a password-authenticated viewer until the + /// person at this PC clicks Accept (20s timeout denies). Headless hosts + /// should leave this off or nobody can answer the prompt. + pub approve: bool, + /// Optional TOTP 2FA secret (base32); empty disables 2FA. + pub totp_secret: String, + /// Encrypted direct-IP access: when set, the engine ALSO listens on this TCP + /// port and serves a viewer that dials it directly (no relay), running the + /// same challenge/auth + E2E. `None` = relay only. + pub direct_port: Option, + /// CIDR allow-list for direct-IP peers (comma/space separated). Empty = + /// allow any peer that can reach the port. + pub allow_ips: String, + /// Max simultaneous DIRECT-IP viewers (1 = the classic single viewer). >1 + /// enables multiple simultaneous viewers: the first is the CONTROLLER (full + /// input/audio/clipboard), the rest are view-only OBSERVERS of the same + /// capture+encode. Relay sessions are unaffected (still one viewer). + pub max_viewers: u32, +} + +/// Live host status for the UI (unified app) — safe to read from any thread. +#[derive(Clone, Default)] +pub struct EngineStatus { + pub registered: bool, + pub viewer_connected: bool, + pub e2e: bool, + pub encoder: String, + pub last_error: Option, +} + +/// Start/stop + status handle for a running engine. Clone it to share between +/// the UI and the engine thread. +#[derive(Clone)] +pub struct EngineHandle { + stop: Arc, + notify: Arc, + pub status: Arc>, +} + +impl Default for EngineHandle { + fn default() -> Self { + Self::new() + } +} + +impl EngineHandle { + pub fn new() -> Self { + Self { + stop: Arc::new(AtomicBool::new(false)), + notify: Arc::new(Notify::new()), + status: Arc::new(Mutex::new(EngineStatus::default())), + } + } + + /// Ask the engine to stop and return promptly. + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + self.notify.notify_waiters(); + } + + pub fn is_stopping(&self) -> bool { + self.stop.load(Ordering::Relaxed) + } + + pub fn snapshot(&self) -> EngineStatus { + self.status.lock().unwrap().clone() + } +} + +/// Shared control between the async event loop and the capture/encode thread. +/// How to map viewer frame-pixel coordinates to absolute virtual-desktop +/// coordinates for input injection. The viewer sends input in the frame space +/// it sees (session.info `w/h` = the encoded size); the host maps each coord to +/// the captured monitor's pixels (frame -> monitor) and adds that monitor's +/// virtual-desktop origin. Without this, input on a secondary monitor lands on +/// the primary (missing origin) and is wrong under a <1 quality scale. +#[derive(Clone, Copy)] +struct InputMap { + origin_x: i32, + origin_y: i32, + mon_w: u32, + mon_h: u32, + frame_w: u32, + frame_h: u32, +} + +impl InputMap { + /// Map a viewer frame coord to an absolute virtual-desktop pixel. + fn to_virtual(&self, x: i32, y: i32) -> (i32, i32) { + let fw = self.frame_w.max(1) as i64; + let fh = self.frame_h.max(1) as i64; + let vx = self.origin_x as i64 + (x as i64 * self.mon_w as i64) / fw; + let vy = self.origin_y as i64 + (y as i64 * self.mon_h as i64) / fh; + (vx as i32, vy as i32) + } +} + +struct Control { + streaming: AtomicBool, + want_keyframe: AtomicBool, + target_fps: AtomicU32, + /// Quality scale * 1000 (1000 = full resolution). + target_scale: AtomicU32, + /// Target encode bitrate in bps (driven live by the adaptive QoS + the + /// manual quality lever). The encode thread re-inits the encoder when it + /// changes materially. + target_bitrate: AtomicU32, + /// Whether the connected viewer advertised VP9 decode (from `session.begin`). + /// The capture loop only uses VP9 when this is set. + viewer_vp9: AtomicBool, + /// Whether the viewer can decode VP9 profile-1 4:4:4 (crisp text). The + /// capture loop only sends 4:4:4 to such viewers, and only if NETDESK_I444=1. + viewer_i444: AtomicBool, + /// The monitor index the capture loop should be capturing. The viewer moves + /// it via `monitor.switch`; the loop re-opens the capturer when it changes. + target_monitor: AtomicU32, + /// Viewer-requested codec pin: -1 = auto (default), 0 = H.264, 1 = VP9. + /// Overrides auto-selection (used so recording stays on the H.264-only muxer). + pin_codec: AtomicI32, + /// Frame->virtual-desktop input mapping, kept current by the capture loop as + /// the monitor / scale changes; read by the event loop to inject input. + input_map: Mutex, + stop: Arc, + status: Arc>, +} + +type FrameSlot = Arc>>>; +type CtlSlot = Arc>>>; + +/// An in-progress viewer->host upload (written to a `.part`, renamed on +/// completion). +struct UploadState { + file: std::fs::File, + final_path: std::path::PathBuf, + part: std::path::PathBuf, + received: u64, + size: u64, + sid: String, + fid: u32, +} + +/// Run the host engine until `handle.stop()` is called. Captures `monitor`, +/// encodes with the best encoder, and serves viewers with auto-reconnect. +pub async fn run_engine(cfg: EngineConfig, handle: EngineHandle) -> Result<()> { + // Per-monitor-DPI-aware FIRST, before any DXGI/GetSystemMetrics/enumerate, so + // monitor enumeration, capture, the virtual-screen metrics used for input, and + // SendInput ALL agree in physical pixels. Setting it late (in the capture + // thread) made enumerate() return DPI-scaled LOGICAL sizes while capture + + // input ran physical — so input on secondary monitors / scaled displays + // landed on the wrong pixel. Process-wide + idempotent. + crate::set_dpi_aware(); + + // Derive the password verifier locally (never sent). + let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&cfg.password); + + // Enumerate all displays so the viewer gets the full monitor list + its + // switch control; also gives us the chosen monitor's dimensions for the + // advertised HostConfig. Falls back to a throwaway single-monitor probe if + // enumeration is unavailable. + let all_monitors = crate::capture::DxgiCapturer::enumerate(); + let mut monitors: Vec<(u32, u32, u32)> = + all_monitors.iter().map(|m| (m.index, m.width, m.height)).collect(); + // Offer an all-in-one "all displays" source (the whole virtual desktop) + // when there is more than one monitor. capture::MON_ALL is advertised as + // monitor -1 and captured via GDI. + if all_monitors.len() > 1 { + let (_l, _t, vw, vh) = crate::capture::virtual_desktop_rect(); + if vw > 0 && vh > 0 { + monitors.push((crate::capture::MON_ALL, vw, vh)); + } + } + let (width, height) = match all_monitors.iter().find(|m| m.index == cfg.monitor) { + Some(m) => (m.width, m.height), + None => { + let cap = DxgiCapturer::new(cfg.monitor) + .map_err(|e| anyhow!("cannot open monitor {}: {e}", cfg.monitor))?; + (cap.width(), cap.height()) + } + }; + log::info!( + "host engine: {} monitor(s); capturing {} ({}x{})", + monitors.len().max(1), + cfg.monitor, + width, + height + ); + log::info!( + "privileges: {}", + if crate::sys::is_elevated() { "ADMINISTRATOR (elevated)" } else { "standard user" } + ); + + let control = Arc::new(Control { + streaming: AtomicBool::new(false), + want_keyframe: AtomicBool::new(false), + target_fps: AtomicU32::new(cfg.fps.clamp(1, 120)), + target_scale: AtomicU32::new(1000), + target_bitrate: AtomicU32::new(cfg.bitrate), + viewer_vp9: AtomicBool::new(false), + viewer_i444: AtomicBool::new(false), + target_monitor: AtomicU32::new(cfg.monitor), + pin_codec: AtomicI32::new(-1), + input_map: Mutex::new(InputMap { + origin_x: 0, + origin_y: 0, + mon_w: width, + mon_h: height, + frame_w: width, + frame_h: height, + }), + stop: handle.stop.clone(), + status: handle.status.clone(), + }); + let frame_slot: FrameSlot = Arc::new(Mutex::new(None)); + let ctl_slot: CtlSlot = Arc::new(Mutex::new(None)); + // Host<->viewer clipboard sync: the last clipboard text we've seen, shared + // between the watcher thread (pushes host copies to the viewer) and the + // viewer-apply handler (so a viewer-pushed clip doesn't echo straight back). + let last_clip = Arc::new(Mutex::new(String::new())); + // Host audio source the viewer selects at runtime (0=off, 1=system, 2=mic). + let audio_source = Arc::new(AtomicU8::new(0)); + + // Capture + encode thread (blocking DXGI + encoder live entirely in-thread + // so none of the !Send COM objects cross a thread boundary). + let cap_control = control.clone(); + let cap_slot = frame_slot.clone(); + let cap_ctl = ctl_slot.clone(); + let cap_cfg = cfg.clone(); + let capture_thread = std::thread::Builder::new() + .name("netdesk-capture".into()) + .spawn(move || capture_loop(cap_cfg, width, height, cap_control, cap_slot, cap_ctl)) + .map_err(|e| anyhow!("spawn capture thread: {e}"))?; + + // Optional system-audio capture (WASAPI loopback -> Opus -> CHAN_AUDIO). + // Off unless NETDESK_AUDIO=1, so a normal session is never affected. Lives + // for the whole engine (like capture_loop); sends via the current ctl_slot + // and only encodes while a viewer is streaming. + // Audio capture thread (on-demand): the viewer picks the source via + // `audio.set` (0=off, 1=system "what you hear", 2=microphone); capture only + // runs while a source is selected AND a viewer streams. Env NETDESK_AUDIO=1 + // starts it on "system" for headless/testing. + #[cfg(windows)] + { + if std::env::var("NETDESK_AUDIO").as_deref() == Ok("1") && cfg.perms.audio { + audio_source.store(1, Ordering::Relaxed); + } + let a_stop = control.stop.clone(); + let a_ctl = ctl_slot.clone(); + let a_ctrl = control.clone(); + let a_src = audio_source.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-audio".into()) + .spawn(move || { + crate::audio::run_audio( + a_stop, + move || a_ctrl.streaming.load(Ordering::Relaxed), + a_src, + move |pkt| { + if let Some(tx) = a_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::AudioFrame(pkt)); + } + }, + ); + }); + } + + // Host -> viewer clipboard sync: watch the host OS clipboard and push text + // changes to the connected viewer (origin "host"), so text copied on this PC + // (Ctrl+C / right-click Copy) lands in the viewer's local clipboard, + // RustDesk/TeamViewer-style. Only while a viewer streams; skips clips the + // viewer itself just applied (guarded by last_clip). + { + let w_stop = control.stop.clone(); + let w_ctrl = control.clone(); + let w_ctl = ctl_slot.clone(); + let w_last = last_clip.clone(); + let w_perm = cfg.perms.clipboard; + let _ = std::thread::Builder::new() + .name("netdesk-clip".into()) + .spawn(move || { + if !w_perm { + // Clipboard perm off: never watch or push the host clipboard + // (the viewer->host direction is gated in the core engine). + log::info!("clipboard sync disabled by perms"); + return; + } + let mut cb = match arboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::warn!("clipboard watch unavailable: {e}"); + return; + } + }; + // Last image sent, deduped by a cheap hash of its RGBA bytes so a + // static image isn't re-sent every poll. + let mut last_img_hash: u64 = 0; + while !w_stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(400)); + if !w_ctrl.streaming.load(Ordering::Relaxed) { + continue; + } + // Text first (cheap); fall through to image when there's no text. + match cb.get_text() { + Ok(text) if !text.is_empty() => { + let changed = { + let mut last = w_last.lock().unwrap(); + if *last == text { false } else { *last = text.clone(); true } + }; + if changed { + if let Some(tx) = w_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Clipboard { text }); + } + } + continue; + } + _ => {} + } + // Image on the clipboard (copied a screenshot / image): push it + // to the viewer as PNG. get_image errs fast when none present. + if let Ok(img) = cb.get_image() { + if img.width > 0 && img.height > 0 { + // Cheap FNV-1a over the RGBA bytes for change detection. + let mut hash: u64 = 0xcbf29ce484222325; + for b in img.bytes.iter() { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + if hash != last_img_hash { + last_img_hash = hash; + if let Some(png) = crate::cursor::encode_png( + &img.bytes, img.width as u32, img.height as u32, + ) { + if let Some(tx) = w_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::ClipboardImage { png }); + } + } + } + } + } + } + }); + } + + // Host metrics -> portal: sample CPU/RAM/disk/battery every 5s and push a + // `host.telemetry` control message to the SERVER (Raw is sent verbatim with + // no session gate), so the portal shows live monitoring for this machine. + { + let t_stop = control.stop.clone(); + let t_ctl = ctl_slot.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-telemetry".into()) + .spawn(move || { + let mut tel = crate::telemetry::Telemetry::new(); + let _ = tel.sample(); // prime the CPU baseline + std::thread::sleep(Duration::from_millis(600)); + while !t_stop.load(Ordering::Relaxed) { + let v = tel.sample(); + if let Some(tx) = t_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { json: v.to_string() }); + } + std::thread::sleep(Duration::from_secs(5)); + } + }); + } + + // LAN discovery responder: answer UDP broadcast queries so viewers on the + // same network can find this host by id/name (RustDesk-style). Default on; + // NETDESK_LAN=0 disables it. direct_port is 0 for now (direct-IP connect is + // a later item) — the discovered id is what a viewer uses to connect. + if std::env::var("NETDESK_LAN").as_deref() != Ok("0") { + let l_stop = control.stop.clone(); + let l_id = cfg.host_id.clone(); + let l_name = cfg.name.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-lan".into()) + .spawn(move || { + if let Err(e) = netdesk_core::lan::respond_to_queries( + netdesk_core::lan::LAN_PORT, + l_id, + l_name, + 0, + l_stop, + ) { + log::info!("lan discovery responder unavailable: {e}"); + } + }); + } + + // Event loop: inject input, drive streaming/keyframe/quality state. + let (ev_tx, mut ev_rx) = mpsc::unbounded_channel::(); + let ev_control = control.clone(); + let ev_ctl = ctl_slot.clone(); + let ev_last_clip = last_clip.clone(); + let ev_audio_source = audio_source.clone(); + // Voice call: the viewer's microphone arrives on CHAN_VOICE; a dedicated + // thread decodes it and plays it on the host's default render device. The + // thread lives for the whole engine (idle = silence) and ends when its + // sender drops. Windows-only (WASAPI render). + #[cfg(windows)] + let ev_voice_tx: Option>> = { + let (vtx, vrx) = std::sync::mpsc::channel::>(); + let v_stop = control.stop.clone(); + let _ = std::thread::Builder::new() + .name("netdesk-voice".into()) + .spawn(move || crate::audio::run_voice_playback(v_stop, vrx)); + Some(vtx) + }; + #[cfg(not(windows))] + let ev_voice_tx: Option>> = None; + let event_task = tokio::spawn(async move { + let mut injector = Injector::new(); + // sys.* toggle state; restored on session end so we never leave the host + // input-blocked or wallpaper-hidden. + let mut block_on = false; + let mut wp_hidden = false; + let mut wp_orig = String::new(); + // Privacy mode: a capture-excluded black overlay (Some while active). + #[cfg(windows)] + let mut privacy: Option = None; + // Collaborative annotation overlay (Some once the first stroke lands). + #[cfg(windows)] + let mut annot: Option = None; + // Adaptive QoS: the manual quality (fps + bitrate in kbps) is the ceiling; + // net telemetry trims below it under congestion. Default on; NETDESK_QOS=0 + // keeps the manual lever authoritative. The Qos works in kbps; the encoder + // atomic is bps. + let qos_enabled = std::env::var("NETDESK_QOS").as_deref() != Ok("0"); + let mut qos = crate::qos::Qos::new(crate::qos::QosConfig::new( + cfg.fps, + cfg.bitrate / 1000, + )); + // In-progress uploads keyed by fid. + let mut uploads: std::collections::HashMap = + std::collections::HashMap::new(); + // In-progress ranged downloads: fid -> bytes the viewer has acked + // (file.ack). Shared with the per-download streamer threads for + // IDM-style windowed flow control; entries are removed by the streamer + // when its segment finishes. + let dl_acks: Arc>>> = + Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + // Open TCP tunnels: tid -> a sender feeding that socket's write half. + // Dropping the sender ends the writer task; the read pump sends bytes + // back as HostControl::TunnelData and a TunnelClosed on EOF. + let mut tunnels: std::collections::HashMap>> = + std::collections::HashMap::new(); + // Open interactive terminals (ConPTY) keyed by tid. + let mut terminals: std::collections::HashMap = + std::collections::HashMap::new(); + let send_raw = |obj: serde_json::Value| { + if let Some(tx) = ev_ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { json: obj.to_string() }); + } + }; + while let Some(ev) = ev_rx.recv().await { + match ev { + HostEvent::Registered => { + log::info!("registered; reachable"); + let mut s = ev_control.status.lock().unwrap(); + s.registered = true; + s.last_error = None; + } + HostEvent::ViewerCaps { vp9, i444 } => { + log::info!("viewer caps update (vp9={vp9}, i444={i444})"); + ev_control.viewer_vp9.store(vp9, Ordering::Relaxed); + ev_control.viewer_i444.store(i444, Ordering::Relaxed); + } + HostEvent::ViewerConnected { e2e, vp9, i444 } => { + log::info!("viewer connected (e2e={e2e}, vp9={vp9}, i444={i444})"); + ev_control.viewer_vp9.store(vp9, Ordering::Relaxed); + ev_control.viewer_i444.store(i444, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + ev_control.streaming.store(true, Ordering::Relaxed); + let mut s = ev_control.status.lock().unwrap(); + s.viewer_connected = true; + s.e2e = e2e; + } + HostEvent::ViewerLeft => { + log::info!("viewer left"); + ev_control.streaming.store(false, Ordering::Relaxed); + injector.release_buttons(); + if block_on { + crate::sys::set_block_input(false); + block_on = false; + } + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } + #[cfg(windows)] + { + privacy = None; // restore the screen if privacy was on + annot = None; // remove any annotation overlay + } + ev_control.status.lock().unwrap().viewer_connected = false; + } + HostEvent::Mouse { x, y, buttons, wheel } => { + // Map the viewer's frame coords to the captured monitor's + // absolute virtual-desktop pixels (origin + frame->monitor + // scale), so input lands on the right monitor. + let (vx, vy) = { + let m = ev_control.input_map.lock().unwrap(); + m.to_virtual(x, y) + }; + injector.mouse(vx, vy, buttons, wheel); + } + HostEvent::MouseRel { dx, dy, buttons, wheel } => { + // Relative (game / pointer-lock) motion: injected raw (no + // InputMap), so apps reading raw mouse input get the deltas. + injector.mouse_rel(dx, dy, buttons, wheel); + } + HostEvent::Text { text } => injector.text(&text), + HostEvent::Annot { json } => { + #[cfg(windows)] + { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + match v.get("op").and_then(|x| x.as_str()).unwrap_or("") { + "clear" => { + if let Some(a) = &annot { + a.clear(); + } + } + "stroke" => { + let color = v.get("color").and_then(|x| x.as_u64()) + .unwrap_or(0xFF_3333) as u32; + let width = v.get("width").and_then(|x| x.as_i64()) + .unwrap_or(3) as i32; + let pts: Vec<(i32, i32)> = v.get("pts") + .and_then(|x| x.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + let a = p.as_array()?; + Some(( + a.first()?.as_i64()? as i32, + a.get(1)?.as_i64()? as i32, + )) + }) + .collect() + }) + .unwrap_or_default(); + let mapped: Vec<(i32, i32)> = { + let m = ev_control.input_map.lock().unwrap(); + pts.iter().map(|&(x, y)| m.to_virtual(x, y)).collect() + }; + if !mapped.is_empty() { + if annot.is_none() { + annot = Some(crate::annot::Annotations::start()); + } + if let Some(a) = &annot { + a.add_stroke(crate::annot::Stroke { + color, + width, + points: mapped, + }); + } + } + } + _ => {} + } + } + #[cfg(not(windows))] + { + let _ = &json; + } + } + HostEvent::Key { code, down } => injector.key(&code, down), + HostEvent::Clipboard { text } => { + // Apply the viewer's clipboard to this machine (RustDesk-style + // viewer -> host clipboard sync). + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(text.clone()); + } + // Remember it so the watcher doesn't echo this straight back. + *ev_last_clip.lock().unwrap() = text; + } + HostEvent::ClipboardImage { png } => { + // Apply a viewer-pushed image to this machine's clipboard + // (decode PNG -> RGBA, hand arboard an ImageData). + if let Some((rgba, w, h)) = crate::cursor::decode_png(&png) { + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_image(arboard::ImageData { + width: w as usize, + height: h as usize, + bytes: std::borrow::Cow::Owned(rgba), + }); + } + } + } + HostEvent::Sys { json } => { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + let a = v.get("a").and_then(|x| x.as_str()).unwrap_or(""); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match a { + "processes" => send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"processes", + "items": crate::sys::process_list() + })), + "kill" => { + let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let (ok, msg) = crate::sys::process_kill(pid); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"kill","msg":msg})); + } + "refresh_screen" => { + ev_control.want_keyframe.store(true, Ordering::Relaxed); + send_raw(json!({"t":"sys.res","sid":sid,"ok":true,"kind":"refresh_screen"})); + } + "block_input" => { + block_on = !block_on; + let ok = crate::sys::set_block_input(block_on); + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":ok,"kind":"block_input","on":block_on, + "msg": if block_on {"local input blocked"} else {"local input restored"} + })); + } + "privacy" => { + // Driver-free privacy: a capture-excluded, click-through + // black overlay blanks the local monitor(s) while the + // remote keeps capturing + controlling the real desktop. + #[cfg(windows)] + { + if privacy.is_some() { + privacy = None; // Drop tears down the overlay + } else { + privacy = Some(crate::privacy::Privacy::start()); + } + let on = privacy.is_some(); + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"privacy","on":on, + "msg": if on {"privacy mode on — local screen blanked"} else {"privacy mode off"} + })); + } + #[cfg(not(windows))] + { + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":false,"kind":"privacy","on":false, + "msg":"privacy unsupported on this platform" + })); + } + } + "wallpaper" => { + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } else { + wp_orig = crate::sys::wallpaper_get(); + crate::sys::wallpaper_set(""); + wp_hidden = true; + } + send_raw(json!({ + "t":"sys.res","sid":sid,"ok":true,"kind":"wallpaper","on":wp_hidden, + "msg": if wp_hidden {"wallpaper hidden"} else {"wallpaper restored"} + })); + } + "run" => { + // Run CMD / PowerShell on the host. Executes with the + // host's privileges (Administrator/SYSTEM when the host + // is elevated). Off the event loop so a slow command + // never stalls input/video; result streamed back. + let cmd = v.get("cmd").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let elevated = crate::sys::is_elevated(); + let rctl = ev_ctl.clone(); + std::thread::spawn(move || { + let (ok, outp) = crate::sys::run_command(&cmd); + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({ + "t":"sys.res","sid":sid,"ok":ok,"kind":"run", + "elevated":elevated,"msg":outp + }) + .to_string(), + }); + } + }); + } + "lock" => { + let ok = crate::sys::lock_workstation(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"lock", + "msg": if ok {"workstation locked"} else {"lock failed"}})); + } + "logoff" => { + let ok = crate::sys::logoff(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"logoff", + "msg": if ok {"logging off"} else {"logoff failed"}})); + } + "reboot" => { + let ok = crate::sys::reboot(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"reboot", + "msg": if ok {"restarting"} else {"restart failed"}})); + } + "shutdown" => { + let ok = crate::sys::shutdown(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"shutdown", + "msg": if ok {"shutting down"} else {"shutdown failed"}})); + } + "privinfo" => { + send_raw(json!({"t":"sys.res","sid":sid,"ok":true,"kind":"privinfo", + "elevated": crate::sys::is_elevated()})); + } + "cad" => { + // Ctrl+Alt+Del to the active session (needs an elevated + // host + the SAS policy that --install-admin-task sets). + let ok = crate::sys::send_cad(); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"cad", + "msg": if ok {"Ctrl+Alt+Del sent"} else {"CAD needs an elevated host + SAS policy"}})); + } + "uac_visible" => { + // Show UAC prompts on the normal desktop so a remote + // viewer can approve them ("enter admin pass"). on=false + // restores the secure desktop. Needs an elevated host. + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(true); + let ok = crate::sys::set_uac_visible(on); + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"uac_visible","on":on, + "msg": if ok { + if on {"UAC prompts now appear on the desktop (remotely approvable)"} + else {"UAC restored to the secure desktop"} + } else {"needs an elevated host"}})); + } + "wol" => { + // Wake another PC on THIS host's LAN (the server routed + // the request here because this host shares the target's + // network). Broadcasts the magic packet for {mac}. + let mac = v.get("mac").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let (ok, msg) = match crate::wol::send_wol(&mac) { + Ok(n) => (true, format!("magic packet sent ({n} broadcast(s)) for {mac}")), + Err(e) => (false, format!("wol failed: {e}")), + }; + send_raw(json!({"t":"sys.res","sid":sid,"ok":ok,"kind":"wol","msg":msg})); + } + other => send_raw(json!({ + "t":"sys.res","sid":sid,"ok":false, + "msg": format!("unsupported sys command: {other}") + })), + } + } + HostEvent::File { json } => { + let v: serde_json::Value = + serde_json::from_str(&json).unwrap_or(serde_json::Value::Null); + let t = v.get("t").and_then(|x| x.as_str()).unwrap_or(""); + let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match t { + "file.roots" => send_raw(json!({ + "t":"file.roots.res","sid":sid,"path":"","items": crate::files::roots() + })), + "file.list" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or(""); + match crate::files::list_dir(path) { + Ok(items) => send_raw(json!({ + "t":"file.list.res","sid":sid,"path":path,"items":items + })), + Err(e) => send_raw(json!({ + "t":"file.err","sid":sid,"msg": format!("list failed: {e}") + })), + } + } + "file.walk" => { + // Recursively enumerate a folder so the viewer can + // download the whole tree (RustDesk-style folder + // transfer). Off the event loop — a deep tree can take + // a moment — so a big walk never stalls input/video. + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let wctl = ev_ctl.clone(); + std::thread::spawn(move || { + let (items, truncated) = crate::files::walk_dir(&path) + .unwrap_or_else(|_| (Vec::new(), false)); + if let Some(tx) = wctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({ + "t":"file.walk.res","sid":sid,"path":path, + "items":items,"truncated":truncated + }) + .to_string(), + }); + } + }); + } + "file.get" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + // Optional byte range (IDM-style segmented download): + // offset+len let the viewer fetch N segments of one + // file in parallel and resume missing ranges. A + // ranged request opts in to file.ack flow control; + // a legacy plain get streams exactly as before. + let start = v.get("offset").and_then(|x| x.as_u64()).unwrap_or(0); + let max_len = v.get("len").and_then(|x| x.as_u64()); + let windowed = v.get("offset").is_some() || v.get("len").is_some(); + let acked = Arc::new(AtomicU64::new(0)); + if windowed { + dl_acks.lock().unwrap().insert(fid, acked.clone()); + } + // Stream the file on a worker thread so a big download + // never stalls the event loop. + let dctl = ev_ctl.clone(); + let reg = dl_acks.clone(); + std::thread::spawn(move || { + download_file(dctl, &sid, fid, &path, start, max_len, windowed, acked, reg); + }); + } + "file.ack" => { + // Viewer's cumulative received-bytes for a ranged + // download segment — advances the sender's window. + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let rcvd = v.get("rcvd").and_then(|x| x.as_u64()).unwrap_or(0); + if let Some(a) = dl_acks.lock().unwrap().get(&fid) { + a.store(rcvd, Ordering::Relaxed); + } + } + "file.put" => { + let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let size = v.get("size").and_then(|x| x.as_u64()).unwrap_or(0); + let fid = v.get("fid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let final_path = crate::files::resolve_upload_path(&path); + if let Some(parent) = final_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let part = crate::files::part_path(&final_path.to_string_lossy()); + match std::fs::File::create(&part) { + Ok(file) => { + uploads.insert(fid, UploadState { + file, final_path, part, received: 0, size, sid, fid, + }); + // Zero-byte file completes immediately. + if size == 0 { + if let Some(u) = uploads.remove(&fid) { + send_raw(finish_upload(u)); + } + } + } + Err(e) => send_raw(json!({ + "t":"file.err","sid":sid,"msg": format!("create failed: {e}") + })), + } + } + _ => {} + } + } + HostEvent::FileChunk { fid, offset, data } => { + if let Some(u) = uploads.get_mut(&fid) { + use std::io::{Seek, SeekFrom, Write}; + let _ = u.file.seek(SeekFrom::Start(offset)); + if u.file.write_all(&data).is_ok() { + u.received += data.len() as u64; + if u.received >= u.size { + if let Some(u) = uploads.remove(&fid) { + send_raw(finish_upload(u)); + } + } + } + } + } + HostEvent::Voice { opus } => { + // Viewer microphone (voice call): hand the Opus packet to the + // render thread, which decodes + plays it on this PC. + if let Some(tx) = ev_voice_tx.as_ref() { + let _ = tx.send(opus); + } + } + HostEvent::ApproveRequest { sid, peer, user } => { + // Approve-before-connect: ask the person at this PC. The + // prompt runs on its own thread (MessageBox blocks) and the + // engine's 20s timeout denies if nobody answers. + // NETDESK_APPROVE_TEST=accept|deny answers headlessly (e2e). + log::info!("approve: prompt for viewer {peer} {user} (sid={sid})"); + let rctl = ev_ctl.clone(); + std::thread::spawn(move || { + let accept = match std::env::var("NETDESK_APPROVE_TEST").as_deref() { + Ok("accept") => true, + Ok("deny") => false, + _ => crate::sys::approve_prompt(&peer, &user), + }; + log::info!( + "approve: decision = {}", + if accept { "ACCEPT" } else { "REJECT" } + ); + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::ApproveReply { sid, accept }); + } + }); + } + HostEvent::ViewOnly { on } => { + // Host-enforced view-only flipped: input is already dropped in + // the core engine; make sure nothing stays held down. + log::info!("view-only (host-enforced): {}", if on { "ON" } else { "off" }); + if on { + injector.release_buttons(); + } + } + HostEvent::TunnelOpen { tid, thost, port } => { + // Open a local TCP connection for a port-forward and pump it + // both ways over the host ws (via the ctl channel). Runs off + // the event loop so a slow connect never stalls input/video. + log::info!("tunnel {tid}: open -> {thost}:{port}"); + let (wtx, mut wrx) = mpsc::unbounded_channel::>(); + tunnels.insert(tid, wtx); + let tctl = ev_ctl.clone(); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let stream = match tokio::net::TcpStream::connect((thost.as_str(), port)).await { + Ok(s) => s, + Err(e) => { + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"tunnel.err","tid":tid,"msg":e.to_string()}).to_string(), + }); + } + return; + } + }; + let _ = stream.set_nodelay(true); + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"tunnel.ok","tid":tid}).to_string(), + }); + } + let (mut rd, mut wr) = stream.into_split(); + // socket -> server (read pump) + let rctl = tctl.clone(); + let reader = tokio::spawn(async move { + let mut buf = vec![0u8; 32 * 1024]; + loop { + match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if let Some(tx) = rctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TunnelData { + tid, + data: buf[..n].to_vec(), + }); + } else { + break; + } + } + } + } + }); + // server -> socket (write pump); ends when the sender drops + // (tunnel.close) or the socket errors. + while let Some(chunk) = wrx.recv().await { + if wr.write_all(&chunk).await.is_err() { + break; + } + } + reader.abort(); + let _ = wr.shutdown().await; + if let Some(tx) = tctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TunnelClosed { tid }); + } + }); + } + HostEvent::TunnelData { tid, data } => { + if let Some(tx) = tunnels.get(&tid) { + if tx.send(data).is_err() { + tunnels.remove(&tid); + } + } + } + HostEvent::TunnelClose { tid } => { + log::info!("tunnel {tid}: close"); + tunnels.remove(&tid); // drops the write sender -> ends the task + } + HostEvent::TermOpen { tid } => { + log::info!("terminal {tid}: open (ConPTY)"); + let octl = ev_ctl.clone(); + let xctl = ev_ctl.clone(); + match crate::term::Terminal::spawn( + 80, + 25, + move |bytes| { + // Terminal output -> viewer (VT bytes as lossy UTF-8). + if let Some(tx) = octl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TermData { + tid, + data: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + }, + move || { + if let Some(tx) = xctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::TermExit { tid }); + } + }, + ) { + Ok(term) => { + terminals.insert(tid, term); + } + Err(e) => { + log::warn!("terminal {tid}: spawn failed: {e}"); + send_raw(json!({"t":"term.exit","sid":"","tid":tid})); + } + } + } + HostEvent::TermInput { tid, data } => { + if let Some(t) = terminals.get(&tid) { + t.write_input(data.as_bytes()); + } + } + HostEvent::TermResize { tid, cols, rows } => { + if let Some(t) = terminals.get(&tid) { + t.resize(cols, rows); + } + } + HostEvent::TermClose { tid } => { + log::info!("terminal {tid}: close"); + terminals.remove(&tid); // Drop kills the shell + closes the PTY + } + HostEvent::KeyframeRequested => { + ev_control.want_keyframe.store(true, Ordering::Relaxed) + } + HostEvent::SwitchMonitor { mon } => { + // Viewer picked a different display; the capture loop re-opens + // the capturer on this index and the size change re-inits the + // encoder (which tells the viewer the new resolution). + log::info!("viewer switched to monitor {mon}"); + ev_control.target_monitor.store(mon, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + } + HostEvent::Quality { fps, bitrate, scale } => { + // Manual lever: sets the adaptive CEILING and applies now. fps + // + bitrate are the QoS ceiling (reset so a raise takes effect + // immediately); scale stays a direct manual lever. + log::info!("quality (manual ceiling): fps={fps} bitrate={bitrate}kbps scale={scale}"); + let fps = fps.clamp(1, 120); + qos = crate::qos::Qos::new(crate::qos::QosConfig::new(fps, bitrate.max(1))); + ev_control.target_fps.store(fps, Ordering::Relaxed); + ev_control + .target_bitrate + .store(bitrate.saturating_mul(1000).max(100_000), Ordering::Relaxed); + let s = (scale.clamp(0.1, 1.0) * 1000.0) as u32; + ev_control.target_scale.store(s, Ordering::Relaxed); + } + HostEvent::NetStat { rtt_ms, loss } => { + // Feed the adaptive controller; it trims fps/bitrate below the + // manual ceiling under congestion and restores when healthy. + if qos_enabled { + let t = qos.on_sample(rtt_ms, loss); + ev_control.target_fps.store(t.fps.clamp(1, 120), Ordering::Relaxed); + ev_control + .target_bitrate + .store(t.bitrate.saturating_mul(1000).max(100_000), Ordering::Relaxed); + log::debug!( + "qos: rtt={rtt_ms}ms loss={loss:.2} -> fps={} bitrate={}kbps", + t.fps, t.bitrate + ); + } + } + HostEvent::AudioSource { src } => { + // Viewer picked the host audio source (0=off, 1=system, 2=mic). + log::info!("audio source -> {}", match src { 1 => "system", 2 => "microphone", _ => "off" }); + ev_audio_source.store(src, Ordering::Relaxed); + } + HostEvent::PinCodec { codec } => { + // Viewer pinned the codec (e.g. H.264 while recording). The + // capture loop reads this and forces it; a keyframe makes the + // switch take effect promptly. + log::info!("codec pin -> {}", match codec { 0 => "H.264", 1 => "VP9", _ => "auto" }); + ev_control.pin_codec.store(codec, Ordering::Relaxed); + ev_control.want_keyframe.store(true, Ordering::Relaxed); + } + HostEvent::Ended { reason } => { + log::info!("link ended: {reason}"); + ev_control.streaming.store(false, Ordering::Relaxed); + injector.release_buttons(); + if block_on { + crate::sys::set_block_input(false); + block_on = false; + } + if wp_hidden { + crate::sys::wallpaper_set(&wp_orig); + wp_hidden = false; + } + #[cfg(windows)] + { + privacy = None; // restore the screen if privacy was on + annot = None; // remove any annotation overlay + } + let mut s = ev_control.status.lock().unwrap(); + s.viewer_connected = false; + s.registered = false; + } + } + } + }); + + // Reconnect loop: a fresh frame channel each dial; run_host returns on drop. + let cfg_core = HostConfig { + ws_url: cfg.ws_url.clone(), + host_id: cfg.host_id.clone(), + name: cfg.name.clone(), + salt_b64, + iters, + verifier_hex, + width, + height, + // System-audio streaming (WASAPI loopback -> Opus). Off unless opted in + // via NETDESK_AUDIO=1; see audio::run_loopback_opus. + audio: true, // capability advertised; actual capture is on-demand (audio.set) + monitors: monitors.clone(), + monitor: cfg.monitor, + perms: cfg.perms, + approve: cfg.approve, + totp_secret: cfg.totp_secret.clone(), + }; + let mut backoff = 1u64; + while !handle.stop.load(Ordering::Relaxed) { + let (ftx, mut frx) = mpsc::unbounded_channel::(); + let (ctx, crx) = mpsc::unbounded_channel::(); + *frame_slot.lock().unwrap() = Some(ftx); + *ctl_slot.lock().unwrap() = Some(ctx); + if cfg.direct_port.is_none() { + log::info!("connecting to {}", cfg.ws_url); + } + // Race the host link against a stop request so stopping is prompt even + // while idle (dropping the run_host future closes the socket). With + // direct_port set, serve a viewer that dials this host's IP:port over a + // raw TCP socket (encrypted direct-IP) instead of the relay. + tokio::select! { + r = async { + if let Some(port) = cfg.direct_port { + use std::sync::atomic::{AtomicBool as AB, AtomicU32 as AU32}; + use tokio::net::TcpListener; + // Direct-IP uses per-viewer control channels, not the + // per-iteration one created above. + drop(crx); + let listener = TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, port)) + .await + .map_err(|e| netdesk_core::NdError::Connect(format!("direct bind :{port}: {e}")))?; + log::info!( + "direct-IP: listening on 0.0.0.0:{port} (max {} viewer(s))", + cfg.max_viewers + ); + // Fan the ONE capture+encode out to every viewer session; each + // seals the same plaintext frame with its own E2E key. + let (btx, _) = tokio::sync::broadcast::channel::(16); + { + let bt = btx.clone(); + tokio::spawn(async move { + while let Some(f) = frx.recv().await { + let _ = bt.send(f); + } + }); + } + let active = Arc::new(AU32::new(0)); + let has_controller = Arc::new(AB::new(false)); + loop { + let (stream, peer) = listener + .accept() + .await + .map_err(|e| netdesk_core::NdError::Connect(format!("direct accept: {e}")))?; + if !netdesk_core::cidr::allowed(&peer.ip(), &cfg.allow_ips) { + log::warn!("direct-IP: {peer} rejected (not in allow-list)"); + continue; + } + if active.load(Ordering::Relaxed) >= cfg.max_viewers { + log::warn!("direct-IP: at max {} viewer(s); rejecting {peer}", cfg.max_viewers); + continue; // stream dropped -> connection closed + } + // First viewer with no controller is the CONTROLLER (full + // perms + the shared audio/clipboard/sys via ctl_slot); the + // rest are view-only OBSERVERS of the same stream. + let controller = !has_controller.swap(true, Ordering::Relaxed); + active.fetch_add(1, Ordering::Relaxed); + log::info!( + "direct-IP: viewer {peer} connected as {} ({} active)", + if controller { "controller" } else { "observer" }, + active.load(Ordering::Relaxed) + ); + // Per-viewer frames: broadcast -> mpsc (run_host_with takes mpsc). + let mut brx = btx.subscribe(); + let (vftx, vfrx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + loop { + match brx.recv().await { + Ok(f) => { + if vftx.send(f).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + }); + let (vctx, vcrx) = mpsc::unbounded_channel::(); + if controller { + *ctl_slot.lock().unwrap() = Some(vctx); + } + let mut vcfg = cfg_core.clone(); + if !controller { + vcfg.perms = netdesk_core::host::HostPerms { + input: false, + clipboard: false, + files: false, + audio: false, + }; + } + let transport = netdesk_core::transport::DirectTcp::new(stream); + let ev = ev_tx.clone(); + let active2 = active.clone(); + let hc2 = has_controller.clone(); + tokio::spawn(async move { + let _ = + netdesk_core::host::run_host_with(vcfg, vfrx, ev, vcrx, Box::new(transport)) + .await; + active2.fetch_sub(1, Ordering::Relaxed); + if controller { + hc2.store(false, Ordering::Relaxed); + } + log::info!("direct-IP: a viewer left ({} active)", active2.load(Ordering::Relaxed)); + }); + } + } else { + run_host(cfg_core.clone(), frx, ev_tx.clone(), crx).await + } + } => { + match r { + Ok(()) => backoff = 1, + Err(e) => { + log::warn!("host link error: {e}"); + control.status.lock().unwrap().last_error = Some(e.to_string()); + } + } + } + _ = handle.notify.notified() => { + log::info!("stop requested"); + } + } + control.streaming.store(false, Ordering::Relaxed); + { + let mut s = control.status.lock().unwrap(); + s.registered = false; + s.viewer_connected = false; + } + *frame_slot.lock().unwrap() = None; + *ctl_slot.lock().unwrap() = None; + if handle.stop.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } + + handle.stop.store(true, Ordering::Relaxed); + let _ = capture_thread.join(); + event_task.abort(); + log::info!("engine stopped"); + Ok(()) +} + +/// Choose the video codec for the current link, with hysteresis so it does not +/// flap as the adaptive-QoS bitrate drifts. +/// +/// * `forced` pins a codec (env `NETDESK_CODEC`): VP9 still requires viewer +/// support, so a forced VP9 against a non-VP9 viewer falls back to H.264. +/// * Otherwise it is automatic: switch to VP9 below `VP9_BELOW` (a constrained +/// link, where VP9's per-bitrate quality wins) and back to hardware H.264 +/// above `H264_ABOVE` (where H.264 is near-free and lower-latency). Between +/// the two thresholds the current codec is kept (the hysteresis band). +/// +/// Returns a `proto::VideoCodec` id: 0 = H.264, 1 = VP9. +/// Resolve the effective forced codec: a live viewer pin (`pin`: -1 = none, +/// 0 = H.264, 1 = VP9) wins over the env `NETDESK_CODEC` override; otherwise the +/// env override (or None for auto) stands. Used so recording can force H.264. +fn effective_forced(pin: i32, env_forced: Option) -> Option { + match pin { + 0 => Some(0), + 1 => Some(1), + _ => env_forced, + } +} + +fn select_codec(current: u8, bitrate_bps: u32, viewer_vp9: bool, forced: Option) -> u8 { + const VP9_BELOW: u32 = 1_500_000; // < 1.5 Mbps -> VP9 + const H264_ABOVE: u32 = 3_000_000; // > 3 Mbps -> H.264 + // An explicit NETDESK_CODEC is honored as-is: it's the operator asserting + // their viewers' capability. (The signaling server strips custom handshake + // fields, so the viewer_vp9 advertisement isn't reliably delivered yet — + // auto-mode therefore stays on H.264 until that's wired server-side or via + // a post-connect caps message.) + if let Some(f) = forced { + return f; + } + if !viewer_vp9 { + return 0; // can't confirm the viewer decodes VP9 -> safe H.264 + } + if bitrate_bps < VP9_BELOW { + 1 + } else if bitrate_bps > H264_ABOVE { + 0 + } else { + current + } +} + +/// The capture/encode hot loop. Opens its own capturer + encoder, waits while no +/// viewer is connected, and otherwise grabs -> encodes -> sends at the target +/// fps. Recreates the capturer on desktop switches (ACCESS_LOST). +fn capture_loop( + cfg: EngineConfig, + width: u32, + height: u32, + control: Arc, + slot: FrameSlot, + ctl: CtlSlot, +) { + crate::set_dpi_aware(); + // Unattended / lock-screen mode (set by the SYSTEM-service session agent): + // follow the active INPUT desktop each frame and capture via GDI, so the + // lock screen, Windows sign-in screen and UAC are captured + controllable + // (DXGI can't duplicate the secure desktop). Off for the normal host. + let follow = std::env::var("NETDESK_FOLLOW_DESKTOP").as_deref() == Ok("1"); + // Base capture dimensions + the currently-open monitor. All three change + // when the viewer switches monitors (control.target_monitor). + let mut width = width; + let mut height = height; + let mut cur_monitor = cfg.monitor; + let mut cap = match Capturer::open(cur_monitor) { + Ok(c) => c, + Err(e) => { + log::error!("capture: cannot open monitor: {e}"); + return; + } + }; + // Codec policy. By default the host picks the codec automatically per link + // quality (see `select_codec`): hardware H.264 on fast links (near-free, + // low-latency), VP9 on constrained links (far better per-bitrate). VP9 is + // only ever used once a VP9-capable viewer has negotiated it (session.begin + // -> control.viewer_vp9). NETDESK_CODEC=vp9|h264 pins one codec (still + // subject to viewer capability for VP9). + let forced: Option = match std::env::var("NETDESK_CODEC").as_deref() { + Ok("vp9") => Some(1), + Ok("h264") => Some(0), + _ => None, + }; + // Opt-in 4:4:4 chroma (crisp text). Default off: it ~doubles chroma data, + // so it is used only for text-heavy workloads AND only to i444-capable viewers. + let i444_enabled = std::env::var("NETDESK_I444").as_deref() == Ok("1"); + // Start on H.264 (no viewer has negotiated yet); the loop re-inits to VP9 + // on the first iteration after a VP9 viewer connects. + let mut enc = match VideoEncoder::best(width as usize, height as usize, cfg.fps, cfg.bitrate) { + Ok(e) => { + log::info!("capture: {}", e.label()); + control.status.lock().unwrap().encoder = e.label(); + e + } + Err(e) => { + log::error!("capture: no encoder: {e}"); + control.status.lock().unwrap().last_error = Some(format!("no encoder: {e}")); + return; + } + }; + // Codec the current encoder was built for (0 = H.264, 1 = VP9). Tracked + // separately from enc.codec_id() so a VP9 init that fell back to H.264 does + // not trigger a re-init every frame. + let mut enc_codec: u8 = enc.codec_id(); + let mut enc_i444: bool = false; // H.264 start is never 4:4:4 + + let mut last_frame: Option> = None; + // Current encode dimensions (change when the viewer moves the scale lever). + let mut enc_w = width; + let mut enc_h = height; + // Current encode bitrate (bps); the adaptive QoS moves target_bitrate and we + // re-init the encoder when it changes materially. + let mut enc_bitrate = cfg.bitrate; + let mut scaled: Vec = Vec::new(); + // After any encoder re-init (scale change / forced keyframe), encode a few + // frames even on a static screen so the fresh IDR actually flushes out (the + // hardware MFT emits nothing on its first frame while it renegotiates). + let mut kf_flush: u32 = 0; + // Remote cursor overlay state. Origin moves when the monitor switches. + let (mut mon_left, mut mon_top) = cap.origin(); + // Seed the input mapping for the initial monitor (frame == full at startup). + *control.input_map.lock().unwrap() = InputMap { + origin_x: mon_left, + origin_y: mon_top, + mon_w: width, + mon_h: height, + frame_w: enc_w, + frame_h: enc_h, + }; + let mut last_cur_h: u64 = 0; + let mut last_cur_pos: (i32, i32) = (i32::MIN, i32::MIN); + // Handles we've already extracted + sent a custom-cursor bitmap for, so each + // app cursor is captured once (the viewer caches shapes by id). + let mut cursor_bitmaps_sent: std::collections::HashSet = std::collections::HashSet::new(); + // Startup FPS ramp: the clock starts when streaming begins and resets when a + // viewer leaves, so each fresh session slow-starts from qos::INIT_FPS and + // climbs to the target over qos::RAMP_MS (applied to the pacing fps below). + let mut stream_since: Option = None; + let mut last_ramp_fps: u32 = 0; // for a sparse debug trace of the ramp + while !control.stop.load(Ordering::Relaxed) { + if !control.streaming.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(40)); + last_frame = None; + stream_since = None; + continue; + } + + // Monitor switch (viewer sent monitor.switch): re-open the capturer on + // the chosen output. The new base size re-inits the encoder below and + // that path sends session.info with the new resolution; want_keyframe + // (set by the event) flushes a fresh IDR of the new display. + let target_mon = control.target_monitor.load(Ordering::Relaxed); + if target_mon != cur_monitor { + match Capturer::open(target_mon) { + Ok(c) => { + cap = c; + cur_monitor = target_mon; + width = cap.width(); + height = cap.height(); + let (l, t) = cap.origin(); + mon_left = l; + mon_top = t; + { + let mut m = control.input_map.lock().unwrap(); + m.origin_x = mon_left; + m.origin_y = mon_top; + m.mon_w = width; + m.mon_h = height; + } + last_frame = None; + kf_flush = 3; + log::info!("capture: now on monitor {cur_monitor} ({width}x{height})"); + } + Err(e) => { + log::warn!("capture: monitor {target_mon} open failed: {e}; staying on {cur_monitor}"); + control.target_monitor.store(cur_monitor, Ordering::Relaxed); + } + } + } + + // Startup slow-start: cap the pacing fps on a ramp from qos::INIT_FPS up + // to the (qos-adjusted) target over the first qos::RAMP_MS of the session. + let target_fps = control.target_fps.load(Ordering::Relaxed).max(1); + let ramp_ms = stream_since.get_or_insert_with(Instant::now).elapsed().as_millis() as u64; + let fps = crate::qos::startup_fps(ramp_ms, target_fps); + if ramp_ms < crate::qos::RAMP_MS && fps.abs_diff(last_ramp_fps) >= 3 { + log::debug!("startup ramp: {fps}/{target_fps} fps @ {ramp_ms}ms"); + last_ramp_fps = fps; + } + let frame_budget = Duration::from_micros(1_000_000 / fps as u64); + let t0 = Instant::now(); + + // Honor the quality scale lever + the adaptive QoS bitrate: re-init the + // encoder on a size change OR a material bitrate change (>10%). The first + // frame after re-init is a keyframe, so the viewer's decoder re-negotiates + // cleanly from the new SPS. QoS bitrate steps are confirmed + coarse, so + // re-inits stay infrequent (a keyframe per step, not per frame). + let scale = control.target_scale.load(Ordering::Relaxed) as f32 / 1000.0; + let (tw, th) = crate::color::scaled_even(width, height, scale); + let target_br = control.target_bitrate.load(Ordering::Relaxed).max(100_000); + let br_changed = target_br.abs_diff(enc_bitrate) > enc_bitrate / 10; + // Effective codec: auto per link quality (or the env override), gated by + // the viewer's advertised VP9 capability. Hysteresis in select_codec + // keeps it from flapping as the QoS bitrate drifts. + // A live viewer pin (e.g. H.264 while recording) overrides the env + // override and auto-selection; -1 means no pin. + let eff_forced = effective_forced(control.pin_codec.load(Ordering::Relaxed), forced); + let eff_codec = select_codec( + enc_codec, + target_br, + control.viewer_vp9.load(Ordering::Relaxed), + eff_forced, + ); + // 4:4:4 only when the effective codec is VP9, the viewer advertised + // i444 decode, and the operator opted in (NETDESK_I444=1). + let use_i444 = eff_codec == 1 + && i444_enabled + && control.viewer_i444.load(Ordering::Relaxed); + if (tw, th) != (enc_w, enc_h) || br_changed || eff_codec != enc_codec || use_i444 != enc_i444 { + match VideoEncoder::with_codec(eff_codec, use_i444, tw as usize, th as usize, fps, target_br) { + Ok(e) => { + log::info!( + "capture: re-init {}x{} @ {} kbps (scale {:.2}, codec {})", + tw, th, target_br / 1000, scale, e.label() + ); + enc = e; + enc_codec = eff_codec; + enc_i444 = use_i444; + enc_w = tw; + enc_h = th; + enc_bitrate = target_br; + kf_flush = 3; + // The viewer now sends input in this new frame size; keep the + // input map's frame (+ monitor, in case of a switch) in sync. + { + let mut m = control.input_map.lock().unwrap(); + m.frame_w = tw; + m.frame_h = th; + m.mon_w = width; + m.mon_h = height; + m.origin_x = mon_left; + m.origin_y = mon_top; + } + // Tell the viewer the authoritative new frame size (it trusts + // session.info, since the decoder does not re-report dims). + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Info { w: tw, h: th }); + } + } + Err(e) => log::warn!("capture: re-init at {tw}x{th} failed: {e}"), + } + } + + // Grab; on no-change reuse the last frame so motion after a pause still + // flushes, but skip sending if we have nothing yet. + let grabbed = if follow { + // Unattended mode: attach to the active input desktop (Default, or + // Winlogon when locked / at sign-in / during UAC) and GDI-capture it + // — the one path that works on the secure desktop. Always yields the + // current pixels, so a remote viewer can see and unlock the machine. + crate::capture::attach_input_desktop(); + match crate::capture::gdi_capture(mon_left, mon_top, width, height) { + Ok(buf) => { + last_frame = Some(buf); + true + } + Err(_) => false, + } + } else { + match cap.grab(frame_budget.as_millis().min(20) as u32) { + Ok(Some(buf)) => { + last_frame = Some(buf); + true + } + Ok(None) => false, + Err(e) => { + log::warn!("capture: {e}; reopening"); + match Capturer::open(cur_monitor) { + Ok(c) => cap = c, + Err(e2) => { + log::error!("capture: reopen failed: {e2}"); + std::thread::sleep(Duration::from_millis(200)); + } + } + continue; + } + } + }; + + let want_kf = control.want_keyframe.swap(false, Ordering::Relaxed); + if want_kf { + // Force an IDR in place on every encoder, HW included (ICodecAPI + // CODECAPI_AVEncVideoForceKeyFrame) — no more whole-encoder rebuild, + // so a viewer join / refresh / QoS step no longer stalls the stream. + enc.request_keyframe(); + kf_flush = 3; + } + + // A long-running DXGI duplication returns WAIT_TIMEOUT on a static + // screen, so `last_frame` is None at viewer-connect and a keyframe flush + // would have nothing to encode -> the viewer sits on a black "waiting + // for first frame" forever (seen on idle hosts whose captured monitor + // has no animation, e.g. the clock/taskbar living on the other display). + // Seed the CURRENT desktop (a brief DXGI retry, then a GDI BitBlt + // fallback that always returns the live pixels) so the IDR has real + // content to flush. Retried next tick if it fails. + if kf_flush > 0 && last_frame.is_none() { + match cap.grab_seed() { + Ok(buf) => last_frame = Some(buf), + Err(e) => log::warn!("capture: keyframe seed failed: {e}"), + } + } + + // Encode on new content, or while flushing a fresh IDR after a re-init. + if grabbed || kf_flush > 0 { + if let Some(frame) = last_frame.as_deref() { + // Downscale into the scratch buffer when the scale lever is < 1. + let to_encode: &[u8] = if (enc_w, enc_h) == (width, height) { + frame + } else { + scaled.resize((enc_w * enc_h * 4) as usize, 0); + crate::color::resize_bgra( + frame, + width as usize, + height as usize, + &mut scaled, + enc_w as usize, + enc_h as usize, + ); + &scaled + }; + match enc.encode(to_encode) { + Ok(aus) => { + let codec = enc.codec_id(); + let guard = slot.lock().unwrap(); + if let Some(tx) = guard.as_ref() { + for au in aus { + let _ = tx.send(HostFrame { + data: au.data, + keyframe: au.keyframe, + codec, + }); + } + } + } + Err(e) => log::warn!("encode: {e}"), + } + // Spend the flush budget ONLY when a frame was actually encoded. + // Decrementing outside this guard (as before) burned all 3 on a + // static screen with no frame in hand, so the fresh encoder was + // never fed and no IDR was ever emitted -> permanent black. + kf_flush = kf_flush.saturating_sub(1); + } + } + + // Remote cursor overlay: report position (frame-pixel coords) and shape + // changes on the control channel so idle screens still track the cursor. + if let Some((h, gx, gy)) = crate::cursor::cursor_state() { + let sx = enc_w as f32 / width as f32; + let sy = enc_h as f32 / height as f32; + let cx = ((gx - mon_left) as f32 * sx) as i32; + let cy = ((gy - mon_top) as f32 * sy) as i32; + if h != last_cur_h { + last_cur_h = h; + let guard = ctl.lock().unwrap(); + if let Some(tx) = guard.as_ref() { + if h == 0 { + let _ = tx.send(HostControl::Cursor { id: 0, x: cx, y: cy, hidden: true }); + } else if let Some(css) = crate::cursor::cursor_css(h) { + let _ = tx.send(HostControl::CursorShape { + id: h, css: Some(css.to_string()), + png: Vec::new(), w: 0, h: 0, hx: 0, hy: 0, + }); + } else if cursor_bitmaps_sent.insert(h) { + // App-custom cursor: extract its bitmap once + send it so + // the viewer shows the real shape instead of a fallback. + if let Some(b) = crate::cursor::cursor_bitmap(h) { + let _ = tx.send(HostControl::CursorShape { + id: h, css: None, + png: b.png, w: b.w, h: b.h, hx: b.hx, hy: b.hy, + }); + } + } + } + } + if h != 0 && (cx, cy) != last_cur_pos { + last_cur_pos = (cx, cy); + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Cursor { id: h, x: cx, y: cy, hidden: false }); + } + } + } + + // Pace to the target fps. + let spent = t0.elapsed(); + if spent < frame_budget { + std::thread::sleep(frame_budget - spent); + } + } + log::info!("capture loop stopped"); +} + +/// Finalize an upload: flush + close the `.part`, rename it into place, and +/// return the viewer reply (`file.ok` or `file.err`). +fn finish_upload(u: UploadState) -> serde_json::Value { + use std::io::Write; + let UploadState { + mut file, + final_path, + part, + sid, + .. + } = u; + let _ = file.flush(); + drop(file); // close before rename (Windows refuses a rename of an open file) + match std::fs::rename(&part, &final_path) { + Ok(()) => json!({"t":"file.ok","sid":sid,"refresh":true,"msg":"uploaded"}), + Err(e) => { + let _ = std::fs::remove_file(&part); + json!({"t":"file.err","sid":sid,"msg": format!("finalize failed: {e}")}) + } + } +} + +/// How many sent-but-unacked bytes a ranged download may have in flight before +/// the streamer pauses (per segment). Keeps a huge file from ballooning the +/// send queue and adapts the pace to the link, like a TCP window. +const DL_WINDOW: u64 = 8 * 1024 * 1024; + +/// Stream a file — or a byte RANGE of it — to the viewer as 0xf1 download +/// chunks, then `file.done{fid,offset,len,size}`. Runs on its own thread; sends +/// through the current control sender. A ranged request (`windowed`) is +/// flow-controlled by the viewer's `file.ack` (IDM-style parallel segments + +/// resume); a legacy plain get streams unwindowed exactly as before. +#[allow(clippy::too_many_arguments)] +fn download_file( + ctl: CtlSlot, + sid: &str, + fid: u32, + path: &str, + start: u64, + max_len: Option, + windowed: bool, + acked: Arc, + registry: Arc>>>, +) { + use std::io::{Read, Seek, SeekFrom}; + const CHUNK: usize = 256 * 1024; + let cleanup = |r: &Arc>>>| { + r.lock().unwrap().remove(&fid); + }; + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => { + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.err","sid":sid,"fid":fid,"msg":format!("open failed: {e}")}) + .to_string(), + }); + } + cleanup(®istry); + return; + } + }; + let size = file.metadata().map(|m| m.len()).unwrap_or(0); + if start > 0 && file.seek(SeekFrom::Start(start)).is_err() { + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.err","sid":sid,"fid":fid,"msg":"seek failed"}).to_string(), + }); + } + cleanup(®istry); + return; + } + let mut remaining = max_len.unwrap_or(u64::MAX); + let mut offset = start; + let mut sent: u64 = 0; + let mut buf = vec![0u8; CHUNK]; + loop { + if remaining == 0 { + break; + } + let want = (CHUNK as u64).min(remaining) as usize; + let n = match file.read(&mut buf[..want]) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + match ctl.lock().unwrap().as_ref() { + Some(tx) => { + let _ = tx.send(HostControl::FileData { + fid, + offset, + data: buf[..n].to_vec(), + }); + } + None => { + cleanup(®istry); + return; // session ended; stop streaming + } + } + offset += n as u64; + sent += n as u64; + remaining = remaining.saturating_sub(n as u64); + // Window: wait for the viewer's acks before queuing more. + while windowed && sent.saturating_sub(acked.load(Ordering::Relaxed)) > DL_WINDOW { + std::thread::sleep(std::time::Duration::from_millis(15)); + if ctl.lock().unwrap().is_none() { + cleanup(®istry); + return; + } + } + } + if let Some(tx) = ctl.lock().unwrap().as_ref() { + let _ = tx.send(HostControl::Raw { + json: json!({"t":"file.done","sid":sid,"fid":fid,"path":path,"size":size, + "offset":start,"len":sent}) + .to_string(), + }); + } + cleanup(®istry); +} + +#[cfg(test)] +mod codec_select_tests { + use super::{effective_forced, select_codec}; + + #[test] + fn pin_overrides_env_and_auto() { + // No pin (-1): the env override (or None) stands. + assert_eq!(effective_forced(-1, None), None); + assert_eq!(effective_forced(-1, Some(1)), Some(1)); + // A pin wins over the env override and over auto-selection. + assert_eq!(effective_forced(0, Some(1)), Some(0)); // record pins H.264 over env VP9 + assert_eq!(effective_forced(1, None), Some(1)); + // And a pinned H.264 then forces H.264 even on a VP9-favorable link. + assert_eq!(select_codec(1, 500_000, true, effective_forced(0, None)), 0); + } + + #[test] + fn forced_codec_is_honored() { + // An explicit NETDESK_CODEC pin is the operator's choice and wins + // regardless of the (server-stripped, unreliable) viewer advertisement. + assert_eq!(select_codec(0, 8_000_000, true, Some(1)), 1); + assert_eq!(select_codec(0, 8_000_000, false, Some(1)), 1); // forced VP9 honored + assert_eq!(select_codec(1, 500_000, true, Some(0)), 0); // pinned H.264 + } + + #[test] + fn no_vp9_when_viewer_cannot_decode() { + assert_eq!(select_codec(1, 300_000, false, None), 0); + } + + #[test] + fn auto_picks_vp9_on_constrained_links() { + // Well below 1.5 Mbps -> VP9. + assert_eq!(select_codec(0, 800_000, true, None), 1); + // Well above 3 Mbps -> H.264. + assert_eq!(select_codec(1, 6_000_000, true, None), 0); + } + + #[test] + fn hysteresis_band_keeps_current() { + // Between the thresholds, keep whatever is running (no flap). + assert_eq!(select_codec(0, 2_000_000, true, None), 0); + assert_eq!(select_codec(1, 2_000_000, true, None), 1); + } +} + +#[cfg(test)] +mod input_map_tests { + use super::InputMap; + + #[test] + fn primary_monitor_identity_at_scale_1() { + let m = InputMap { origin_x: 0, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 1920, frame_h: 1080 }; + assert_eq!(m.to_virtual(0, 0), (0, 0)); + assert_eq!(m.to_virtual(960, 540), (960, 540)); + } + + #[test] + fn secondary_monitor_adds_origin() { + // Monitor 2 is to the right of monitor 1 (virtual origin 1920,0). + let m = InputMap { origin_x: 1920, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 1920, frame_h: 1080 }; + assert_eq!(m.to_virtual(0, 0), (1920, 0)); // top-left of monitor 2 + assert_eq!(m.to_virtual(960, 540), (2880, 540)); // center of monitor 2 + } + + #[test] + fn unscales_frame_to_monitor_pixels() { + // Frame is half the monitor size (quality scale 0.5). + let m = InputMap { origin_x: 0, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 960, frame_h: 540 }; + assert_eq!(m.to_virtual(480, 270), (960, 540)); // frame center -> monitor center + } + + #[test] + fn secondary_monitor_scaled() { + let m = InputMap { origin_x: 1920, origin_y: 0, mon_w: 1920, mon_h: 1080, frame_w: 960, frame_h: 540 }; + assert_eq!(m.to_virtual(480, 270), (2880, 540)); + } + + #[test] + fn tall_secondary_monitor_above() { + // Monitor above the primary (negative origin), different resolution. + let m = InputMap { origin_x: 0, origin_y: -1200, mon_w: 1600, mon_h: 1200, frame_w: 1600, frame_h: 1200 }; + assert_eq!(m.to_virtual(800, 600), (800, -600)); + } +} + +#[cfg(all(test, windows))] +mod dpi_diag { + use crate::capture::DxgiCapturer; + use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, + }; + + fn vscreen() -> (i32, i32, i32, i32) { + unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + } + } + + #[test] + fn print_coordinate_spaces() { + println!("=== BEFORE set_dpi_aware ==="); + for m in DxgiCapturer::enumerate() { + println!(" mon #{} {}x{} @({},{})", m.index, m.width, m.height, m.left, m.top); + } + let (l, t, w, h) = vscreen(); + println!(" vscreen left={l} top={t} w={w} h={h}"); + + crate::set_dpi_aware(); + + println!("=== AFTER set_dpi_aware (per-monitor-v2) ==="); + for m in DxgiCapturer::enumerate() { + println!(" mon #{} {}x{} @({},{})", m.index, m.width, m.height, m.left, m.top); + } + let (l, t, w, h) = vscreen(); + println!(" vscreen left={l} top={t} w={w} h={h}"); + println!("DPI_DIAG_DONE"); + } +} + +#[cfg(all(test, windows))] +mod injection_e2e { + use super::InputMap; + use crate::capture::DxgiCapturer; + use crate::input::Injector; + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos}; + + // Real OS-effect test: for EVERY monitor, map a frame coord through InputMap + // and inject a move, then confirm with GetCursorPos that the cursor landed on + // that monitor's physical pixel. Proves the DPI fix + InputMap + SendInput + // path end to end. Moves the real cursor briefly, then restores it. + #[test] + fn injected_moves_land_on_the_right_monitor_pixel() { + crate::set_dpi_aware(); // physical coords, as run_engine now does first + let mons = DxgiCapturer::enumerate(); + assert!(!mons.is_empty(), "no monitors enumerated"); + + let mut orig = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut orig); + } + let mut injector = Injector::new(); + let mut worst = 0i32; + for m in &mons { + // Test at full size (frame == monitor) and at a 0.5 quality scale. + for (fw, fh) in [(m.width, m.height), (m.width / 2, m.height / 2)] { + let im = InputMap { + origin_x: m.left, + origin_y: m.top, + mon_w: m.width, + mon_h: m.height, + frame_w: fw, + frame_h: fh, + }; + let (fx, fy) = ((fw / 2) as i32, (fh / 2) as i32); // frame center + let (vx, vy) = im.to_virtual(fx, fy); + injector.mouse(vx, vy, 0, 0.0); + std::thread::sleep(std::time::Duration::from_millis(25)); + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + let want = (m.left + (m.width / 2) as i32, m.top + (m.height / 2) as i32); + let (dx, dy) = ((p.x - want.0).abs(), (p.y - want.1).abs()); + worst = worst.max(dx).max(dy); + println!( + "mon #{} frame {}x{}: ({fx},{fy})->virt({vx},{vy})->cursor({},{}) want({},{}) d=({dx},{dy})", + m.index, fw, fh, p.x, p.y, want.0, want.1 + ); + assert!(dx <= 3 && dy <= 3, "mon #{} off by ({dx},{dy})", m.index); + } + } + unsafe { + let _ = SetCursorPos(orig.x, orig.y); + } // restore the user's cursor + println!("INJECTION_E2E_OK monitors={} worst_err_px={worst}", mons.len()); + } +} diff --git a/native/host/src/service.rs b/native/host/src/service.rs new file mode 100644 index 0000000..1297754 --- /dev/null +++ b/native/host/src/service.rs @@ -0,0 +1,289 @@ +//! Windows SYSTEM service for UNATTENDED access + lock/login-screen capture. +//! +//! The service runs as LocalSystem (session 0) and spawns the NetDesk host INTO +//! the active console session **as SYSTEM**, with its thread desktop following +//! the current input desktop. That lets the host capture and inject on the +//! secure desktop (the lock screen, the Windows sign-in screen, and UAC) — so a +//! remote viewer can sign in and unlock an unattended machine, TeamViewer-style. +//! +//! Opt-in and isolated: nothing here runs unless `--install-service` is executed +//! elevated. The ordinary user-session host (Desktop autostart / admin task) is +//! untouched, so installing or removing the service never risks the path that +//! already works. +//! +//! Entry points (wired in the viewer binary's `main`): +//! --install-service register + start the service (self-elevates via UAC) +//! --uninstall-service stop + delete the service +//! --service-run the SCM service entry (run by Windows, not by hand) +//! --session-agent the per-session host the service spawns (follows the +//! input desktop so the lock screen is captured) + +#![cfg(windows)] + +use anyhow::{anyhow, Result}; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; +use windows::Win32::Security::{ + AdjustTokenPrivileges, DuplicateTokenEx, LookupPrivilegeValueW, SetTokenInformation, + SecurityIdentification, TokenPrimary, TokenSessionId, LUID_AND_ATTRIBUTES, + SE_PRIVILEGE_ENABLED, TOKEN_ACCESS_MASK, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES, + TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_PRIVILEGES, TOKEN_QUERY, +}; +use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId; +use windows::Win32::System::Services::{ + RegisterServiceCtrlHandlerW, SetServiceStatus, StartServiceCtrlDispatcherW, + SERVICE_STATUS, SERVICE_STATUS_HANDLE, SERVICE_TABLE_ENTRYW, SERVICE_ACCEPT_STOP, + SERVICE_CONTROL_SHUTDOWN, SERVICE_CONTROL_STOP, SERVICE_RUNNING, SERVICE_START_PENDING, + SERVICE_STOP_PENDING, SERVICE_STOPPED, SERVICE_WIN32_OWN_PROCESS, +}; +use windows::Win32::System::Threading::{ + CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, TerminateProcess, + CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW, +}; + +const SERVICE_NAME: &str = "NetDeskHost"; + +static STOP: AtomicBool = AtomicBool::new(false); +static mut STATUS_HANDLE: isize = 0; + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +// --------------------------------------------------------------------------- +// install / uninstall (shell out to sc.exe; requires elevation) +// --------------------------------------------------------------------------- + +/// Register the service (LocalSystem, auto-start, auto-restart) and start it. +pub fn install_service(exe: &str) -> Result<()> { + let bin = format!("\"{exe}\" --service-run"); + // Create (quirk: sc wants "key=" and the value as separate tokens). + run_sc(&["create", SERVICE_NAME, "binPath=", &bin, "start=", "auto", + "obj=", "LocalSystem", "DisplayName=", "NetDesk Host (unattended)"])?; + let _ = run_sc(&["description", SERVICE_NAME, + "NetDesk unattended host — captures the lock/sign-in screen so you can access this PC remotely."]); + // Restart on crash so unattended access survives faults. + let _ = run_sc(&["failure", SERVICE_NAME, "reset=", "0", "actions=", + "restart/5000/restart/5000/restart/5000"]); + run_sc(&["start", SERVICE_NAME])?; + Ok(()) +} + +/// Stop and delete the service. +pub fn uninstall_service() -> Result<()> { + let _ = run_sc(&["stop", SERVICE_NAME]); + run_sc(&["delete", SERVICE_NAME])?; + Ok(()) +} + +fn run_sc(args: &[&str]) -> Result<()> { + let status = std::process::Command::new("sc") + .args(args) + .status() + .map_err(|e| anyhow!("sc {args:?}: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(anyhow!("sc {:?} exited with {:?}", args, status.code())) + } +} + +// --------------------------------------------------------------------------- +// service entry (run by the SCM via `--service-run`) +// --------------------------------------------------------------------------- + +/// Hand control to the Service Control Manager. Blocks until the service stops. +pub fn run_service() -> Result<()> { + let name = wide(SERVICE_NAME); + let table = [ + SERVICE_TABLE_ENTRYW { + lpServiceName: PWSTR(name.as_ptr() as *mut u16), + lpServiceProc: Some(service_main), + }, + SERVICE_TABLE_ENTRYW { + lpServiceName: PWSTR::null(), + lpServiceProc: None, + }, + ]; + unsafe { + StartServiceCtrlDispatcherW(table.as_ptr()) + .map_err(|e| anyhow!("StartServiceCtrlDispatcher: {e}"))?; + } + Ok(()) +} + +extern "system" fn service_main(_argc: u32, _argv: *mut PWSTR) { + unsafe { + let name = wide(SERVICE_NAME); + match RegisterServiceCtrlHandlerW(PCWSTR(name.as_ptr()), Some(handler)) { + Ok(h) => STATUS_HANDLE = h.0 as isize, + Err(_) => return, + } + set_status(SERVICE_START_PENDING, 0); + set_status(SERVICE_RUNNING, SERVICE_ACCEPT_STOP); + // Main loop: keep a host alive in the active console session. + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let mut child: Option<(HANDLE, u32)> = None; + while !STOP.load(Ordering::Relaxed) { + let session = WTSGetActiveConsoleSessionId(); + let need_spawn = match child { + // 0xFFFFFFFF => no one attached to the console right now. + _ if session == 0xFFFF_FFFF => false, + None => true, + Some((_, sess)) => sess != session || !process_alive(child), + }; + if need_spawn && session != 0xFFFF_FFFF { + if let Some((h, _)) = child.take() { + let _ = TerminateProcess(h, 1); + let _ = CloseHandle(h); + } + match spawn_host_in_session(session, &exe) { + Ok(h) => child = Some((h, session)), + Err(_) => {} + } + } + std::thread::sleep(std::time::Duration::from_millis(1500)); + } + if let Some((h, _)) = child.take() { + let _ = TerminateProcess(h, 0); + let _ = CloseHandle(h); + } + set_status(SERVICE_STOPPED, 0); + } +} + +extern "system" fn handler(control: u32) { + if control == SERVICE_CONTROL_STOP || control == SERVICE_CONTROL_SHUTDOWN { + STOP.store(true, Ordering::Relaxed); + unsafe { set_status(SERVICE_STOP_PENDING, 0) }; + } +} + +unsafe fn set_status(state: windows::Win32::System::Services::SERVICE_STATUS_CURRENT_STATE, accept: u32) { + if STATUS_HANDLE == 0 { + return; + } + let mut st = SERVICE_STATUS { + dwServiceType: SERVICE_WIN32_OWN_PROCESS, + dwCurrentState: state, + dwControlsAccepted: accept, + dwWin32ExitCode: 0, + dwServiceSpecificExitCode: 0, + dwCheckPoint: 0, + dwWaitHint: 3000, + }; + let _ = SetServiceStatus(SERVICE_STATUS_HANDLE(STATUS_HANDLE as *mut c_void), &mut st); +} + +fn process_alive(child: Option<(HANDLE, u32)>) -> bool { + use windows::Win32::Foundation::{STILL_ACTIVE, WAIT_OBJECT_0}; + use windows::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject}; + match child { + None => false, + Some((h, _)) => unsafe { + // Fast path: a 0ms wait signals only if the process has exited. + if WaitForSingleObject(h, 0) == WAIT_OBJECT_0 { + return false; + } + let mut code = 0u32; + if GetExitCodeProcess(h, &mut code).is_ok() { + code == STILL_ACTIVE.0 as u32 + } else { + false + } + }, + } +} + +// --------------------------------------------------------------------------- +// spawn the host as SYSTEM in the active session (so it can reach the secure +// desktop). Duplicates the service's SYSTEM token, re-targets it at the active +// session, and CreateProcessAsUser's the host on winsta0\default. +// --------------------------------------------------------------------------- + +fn spawn_host_in_session(session: u32, exe: &str) -> Result { + unsafe { + let proc = GetCurrentProcess(); + let access = TOKEN_ACCESS_MASK( + TOKEN_DUPLICATE.0 + | TOKEN_QUERY.0 + | TOKEN_ASSIGN_PRIMARY.0 + | TOKEN_ADJUST_DEFAULT.0 + | TOKEN_ADJUST_SESSIONID.0 + | TOKEN_ADJUST_PRIVILEGES.0, + ); + let mut tok = HANDLE::default(); + OpenProcessToken(proc, access, &mut tok).map_err(|e| anyhow!("OpenProcessToken: {e}"))?; + enable_privilege(tok, "SeTcbPrivilege"); + + let mut dup = HANDLE::default(); + DuplicateTokenEx( + tok, + TOKEN_ACCESS_MASK(0x02000000), // MAXIMUM_ALLOWED + None, + SecurityIdentification, + TokenPrimary, + &mut dup, + ) + .map_err(|e| anyhow!("DuplicateTokenEx: {e}"))?; + + // Re-target the primary token at the active console session. + let sess = session; + SetTokenInformation( + dup, + TokenSessionId, + &sess as *const u32 as *const c_void, + std::mem::size_of::() as u32, + ) + .map_err(|e| anyhow!("SetTokenInformation(session): {e}"))?; + + let mut cmd = wide(&format!("\"{exe}\" --session-agent")); + let mut desktop = wide("winsta0\\default"); + let mut si = STARTUPINFOW::default(); + si.cb = std::mem::size_of::() as u32; + si.lpDesktop = PWSTR(desktop.as_mut_ptr()); + let mut pi = PROCESS_INFORMATION::default(); + + CreateProcessAsUserW( + dup, + PCWSTR::null(), + PWSTR(cmd.as_mut_ptr()), + None, + None, + false, + CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + None, + PCWSTR::null(), + &si, + &mut pi, + ) + .map_err(|e| anyhow!("CreateProcessAsUser: {e}"))?; + + let _ = CloseHandle(pi.hThread); + let _ = CloseHandle(dup); + let _ = CloseHandle(tok); + Ok(pi.hProcess) + } +} + +fn enable_privilege(token: HANDLE, name: &str) { + unsafe { + let wname = wide(name); + let mut luid = LUID::default(); + if LookupPrivilegeValueW(PCWSTR::null(), PCWSTR(wname.as_ptr()), &mut luid).is_err() { + return; + } + let tp = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + let _ = AdjustTokenPrivileges(token, false, Some(&tp), 0, None, None); + } +} diff --git a/native/host/src/sys.rs b/native/host/src/sys.rs new file mode 100644 index 0000000..58bdb8c --- /dev/null +++ b/native/host/src/sys.rs @@ -0,0 +1,319 @@ +//! Host system controls (RustDesk/TeamViewer-grade): process list + kill, +//! block-local-input, and wallpaper hide/restore. Native Win32 via the windows +//! crate — no shelling out, no Python. + +#![cfg(windows)] + +use serde_json::{json, Value}; +use std::ffi::c_void; +use std::os::windows::process::CommandExt; +use windows::Win32::Foundation::CloseHandle; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS}; +use windows::Win32::System::Threading::{ + OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, +}; +use windows::Win32::UI::Input::KeyboardAndMouse::BlockInput; +use windows::Win32::UI::WindowsAndMessaging::{ + SystemParametersInfoW, SPIF_UPDATEINIFILE, SPI_GETDESKWALLPAPER, + SPI_SETDESKWALLPAPER, +}; + +/// Running processes as `{name, pid, mem}` (mem in KB), heaviest first, capped +/// at 300 — the shape the viewer's process panel expects. +pub fn process_list() -> Vec { + let mut items = Vec::new(); + unsafe { + let snap = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) { + Ok(h) => h, + Err(_) => return items, + }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + let pid = entry.th32ProcessID; + let mem = process_mem_kb(pid); + items.push(json!({ "name": name, "pid": pid, "mem": mem })); + if Process32NextW(snap, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snap); + } + items.sort_by_key(|i| std::cmp::Reverse(i.get("mem").and_then(|m| m.as_u64()).unwrap_or(0))); + items.truncate(300); + items +} + +unsafe fn process_mem_kb(pid: u32) -> u64 { + if pid == 0 { + return 0; + } + let h = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { + Ok(h) => h, + Err(_) => return 0, + }; + let mut c = PROCESS_MEMORY_COUNTERS::default(); + let r = GetProcessMemoryInfo(h, &mut c, std::mem::size_of::() as u32); + let _ = CloseHandle(h); + if r.is_ok() { + (c.WorkingSetSize / 1024) as u64 + } else { + 0 + } +} + +/// Terminate a process by pid. Returns (ok, message). +pub fn process_kill(pid: u32) -> (bool, String) { + if pid == 0 { + return (false, "invalid pid".into()); + } + unsafe { + match OpenProcess(PROCESS_TERMINATE, false, pid) { + Ok(h) => { + let r = TerminateProcess(h, 1); + let _ = CloseHandle(h); + match r { + Ok(()) => (true, format!("terminated {pid}")), + Err(e) => (false, format!("terminate failed: {e}")), + } + } + Err(e) => (false, format!("cannot open process {pid}: {e}")), + } + } +} + +/// Block or unblock local keyboard/mouse so the person at the host cannot +/// interfere during a control session. Returns true on success. +pub fn set_block_input(on: bool) -> bool { + unsafe { BlockInput(on).is_ok() } +} + +/// Current desktop wallpaper path (empty if none / unreadable). +pub fn wallpaper_get() -> String { + unsafe { + let mut buf = [0u16; 520]; + if SystemParametersInfoW( + SPI_GETDESKWALLPAPER, + buf.len() as u32, + Some(buf.as_mut_ptr() as *mut c_void), + Default::default(), + ) + .is_ok() + { + let len = buf.iter().position(|&c| c == 0).unwrap_or(0); + String::from_utf16_lossy(&buf[..len]) + } else { + String::new() + } + } +} + +/// Set the desktop wallpaper (empty string clears it to the background colour). +pub fn wallpaper_set(path: &str) -> bool { + unsafe { + let mut wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + SystemParametersInfoW( + SPI_SETDESKWALLPAPER, + 0, + Some(wide.as_mut_ptr() as *mut c_void), + SPIF_UPDATEINIFILE, + ) + .is_ok() + } +} + + +/// Windows process-creation flag: run without flashing a console window. +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Run a shell command on the host and capture stdout+stderr (size-capped). +/// Runs with the host process's privileges — full Administrator/SYSTEM when the +/// host is elevated (service or run-as-admin), standard user otherwise. This is +/// the "run CMD / PowerShell on the remote machine" feature. +pub fn run_command(cmd: &str) -> (bool, String) { + let out = std::process::Command::new("cmd") + .args(["/c", cmd]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + match out { + Ok(o) => { + let mut s = String::from_utf8_lossy(&o.stdout).into_owned(); + let err = String::from_utf8_lossy(&o.stderr); + if !err.trim().is_empty() { + if !s.is_empty() { + s.push('\n'); + } + s.push_str(&err); + } + if s.len() > 60_000 { + s.truncate(60_000); + s.push_str("\n…[output truncated]"); + } + if s.trim().is_empty() { + s = format!("(exit {}, no output)", o.status.code().unwrap_or(-1)); + } + (o.status.success(), s) + } + Err(e) => (false, format!("failed to launch: {e}")), + } +} + +fn spawn_detached(program: &str, args: &[&str]) -> bool { + std::process::Command::new(program) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .is_ok() +} + +/// Lock the workstation (screen-lock). Works from the interactive user session. +pub fn lock_workstation() -> bool { + spawn_detached("rundll32.exe", &["user32.dll,LockWorkStation"]) +} + +/// Log the interactive user off. +pub fn logoff() -> bool { + spawn_detached("shutdown.exe", &["/l", "/f"]) +} + +/// Restart the machine now. +pub fn reboot() -> bool { + spawn_detached("shutdown.exe", &["/r", "/t", "0"]) +} + +/// Shut the machine down now. +pub fn shutdown() -> bool { + spawn_detached("shutdown.exe", &["/s", "/t", "0"]) +} + +/// Is this host process running with an elevated (Administrator) token? Reported +/// to the viewer so the UI can show whether `run` commands have admin rights. +pub fn is_elevated() -> bool { + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + unsafe { + let mut token = HANDLE::default(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { + return false; + } + let mut elev = TOKEN_ELEVATION::default(); + let mut ret = 0u32; + let ok = GetTokenInformation( + token, + TokenElevation, + Some(&mut elev as *mut _ as *mut c_void), + std::mem::size_of::() as u32, + &mut ret, + ) + .is_ok(); + let _ = CloseHandle(token); + ok && elev.TokenIsElevated != 0 + } +} + +#[cfg(test)] +mod admin_tests { + use super::*; + + // Safe: runs `whoami` only — never fires lock/logoff/reboot/shutdown. + #[test] + fn run_command_captures_output() { + let (ok, out) = run_command("whoami"); + assert!(ok, "whoami should succeed: {out}"); + assert!(!out.trim().is_empty(), "expected username output"); + let _ = is_elevated(); // must not panic + eprintln!("whoami -> {} | elevated={}", out.trim(), is_elevated()); + } +} + +/// Send a Secure Attention Sequence (Ctrl+Alt+Del) to the active session. The +/// host must be elevated and the SoftwareSASGeneration policy must allow +/// software SAS (set by `--install-admin-task`); fails gracefully otherwise. +pub fn send_cad() -> bool { + use windows::Win32::Security::Authentication::Identity::SendSAS; + // AsUser = TRUE: generated as the interactive (elevated) user. + unsafe { SendSAS(windows::Win32::Foundation::TRUE) }; + true +} + +/// Make UAC consent prompts appear on the NORMAL desktop (so a remote viewer can +/// see and click them — "enter admin pass" over a session) instead of the +/// isolated secure desktop. `visible=false` restores the Windows default. +/// Writes HKLM, so it needs an elevated host; fails gracefully otherwise. +pub fn set_uac_visible(visible: bool) -> bool { + let val = if visible { "0" } else { "1" }; + std::process::Command::new("reg") + .args([ + "add", + r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", + "/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", val, "/f", + ]) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Approve-before-connect prompt: a topmost Yes/No box on the interactive +/// desktop (" () wants to control this PC"). Auto-dismisses after +/// 20s (returns false), matching the engine's own approve timeout, via +/// user32's MessageBoxTimeoutW (exported by name; stable since XP). Returns +/// true only on an explicit Yes. +pub fn approve_prompt(peer: &str, user: &str) -> bool { + #[link(name = "user32")] + extern "system" { + fn MessageBoxTimeoutW( + hwnd: isize, + lptext: *const u16, + lpcaption: *const u16, + utype: u32, + wlangid: u16, + dwmilliseconds: u32, + ) -> i32; + } + let from = match (peer.is_empty(), user.is_empty()) { + (true, true) => "an unknown viewer".to_string(), + (false, true) => peer.to_string(), + (true, false) => user.to_string(), + (false, false) => format!("{peer} ({user})"), + }; + let text = format!( + "Incoming NetDesk connection\n\nFrom: {from}\n\nThe viewer entered the correct password.\nAccept remote control of this PC?" + ); + let wide = |s: &str| s.encode_utf16().chain(std::iter::once(0)).collect::>(); + let t = wide(&text); + let c = wide("NetDesk — accept connection?"); + const MB_YESNO: u32 = 0x0000_0004; + const MB_ICONQUESTION: u32 = 0x0000_0020; + const MB_SYSTEMMODAL: u32 = 0x0000_1000; // topmost, above fullscreen apps + const MB_SETFOREGROUND: u32 = 0x0001_0000; + const MB_DEFBUTTON2: u32 = 0x0000_0100; // default = No (Enter won't accept) + const IDYES: i32 = 6; + let r = unsafe { + MessageBoxTimeoutW( + 0, + t.as_ptr(), + c.as_ptr(), + MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND | MB_DEFBUTTON2, + 0, + 20_000, + ) + }; + r == IDYES +} diff --git a/native/host/src/telemetry.rs b/native/host/src/telemetry.rs new file mode 100644 index 0000000..cf2eacd --- /dev/null +++ b/native/host/src/telemetry.rs @@ -0,0 +1,118 @@ +//! Lightweight host metrics (CPU %, RAM, disk, battery, uptime) for the portal's +//! live monitoring. Windows-only; sampled every few seconds by the engine and +//! pushed to the server as a `host.telemetry` control message. +#![cfg(windows)] + +use serde_json::{json, Value}; + +/// Keeps the previous CPU time sample so each `sample()` yields a real % over +/// the interval since the last call. +pub struct Telemetry { + prev_idle: u64, + prev_total: u64, +} + +impl Telemetry { + pub fn new() -> Self { + Self { prev_idle: 0, prev_total: 0 } + } + + pub fn sample(&mut self) -> Value { + let cpu = self.cpu_percent(); + let (ram_used, ram_total) = mem(); + let (disk_used, disk_total) = disk(); + let (battery, charging) = power(); + json!({ + "t": "host.telemetry", + "cpu": cpu, + "ram_used": ram_used, "ram_total": ram_total, + "disk_used": disk_used, "disk_total": disk_total, + "battery": battery, "charging": charging, + "uptime": uptime_secs(), + }) + } + + fn cpu_percent(&mut self) -> u32 { + use windows::Win32::Foundation::FILETIME; + use windows::Win32::System::Threading::GetSystemTimes; + unsafe { + let mut idle = FILETIME::default(); + let mut kern = FILETIME::default(); + let mut user = FILETIME::default(); + if GetSystemTimes(Some(&mut idle), Some(&mut kern), Some(&mut user)).is_err() { + return 0; + } + let ft = |f: FILETIME| ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64; + let idle_t = ft(idle); + // Kernel time INCLUDES idle, so kernel+user is the full busy+idle total. + let total_t = ft(kern) + ft(user); + let d_total = total_t.saturating_sub(self.prev_total); + let d_idle = idle_t.saturating_sub(self.prev_idle); + self.prev_total = total_t; + self.prev_idle = idle_t; + if d_total == 0 { + return 0; + } + let busy = d_total.saturating_sub(d_idle); + ((busy as f64 / d_total as f64) * 100.0).round().clamp(0.0, 100.0) as u32 + } + } +} + +fn mem() -> (u64, u64) { + use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + unsafe { + let mut m = MEMORYSTATUSEX { + dwLength: std::mem::size_of::() as u32, + ..Default::default() + }; + if GlobalMemoryStatusEx(&mut m).is_ok() { + (m.ullTotalPhys.saturating_sub(m.ullAvailPhys), m.ullTotalPhys) + } else { + (0, 0) + } + } +} + +fn disk() -> (u64, u64) { + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + unsafe { + let path: Vec = "C:\\".encode_utf16().chain(std::iter::once(0)).collect(); + let mut free_avail = 0u64; + let mut total = 0u64; + let mut total_free = 0u64; + if GetDiskFreeSpaceExW( + PCWSTR(path.as_ptr()), + Some(&mut free_avail), + Some(&mut total), + Some(&mut total_free), + ) + .is_ok() + { + (total.saturating_sub(total_free), total) + } else { + (0, 0) + } + } +} + +fn power() -> (i32, bool) { + use windows::Win32::System::Power::GetSystemPowerStatus; + use windows::Win32::System::Power::SYSTEM_POWER_STATUS; + unsafe { + let mut s = SYSTEM_POWER_STATUS::default(); + if GetSystemPowerStatus(&mut s).is_ok() { + let pct = if s.BatteryLifePercent == 255 { -1 } else { s.BatteryLifePercent as i32 }; + let charging = s.ACLineStatus == 1; // 1 = plugged in + (pct, charging) + } else { + (-1, true) + } + } +} + +fn uptime_secs() -> u64 { + use windows::Win32::System::SystemInformation::GetTickCount64; + unsafe { GetTickCount64() / 1000 } +} diff --git a/native/host/src/term.rs b/native/host/src/term.rs new file mode 100644 index 0000000..3c859b3 --- /dev/null +++ b/native/host/src/term.rs @@ -0,0 +1,209 @@ +//! Interactive terminal via Windows ConPTY (pseudo-console) — a real shell the +//! viewer drives, not the one-shot `run` command. Mirrors RustDesk 1.5's +//! terminal: `term.open` spawns cmd.exe attached to a pseudo-console, its output +//! streams back as `term.data`, the viewer's keystrokes are written to its +//! input, `term.resize` resizes it, and `term.close` / shell exit tears it down. + +#![cfg(windows)] + +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use windows::core::PWSTR; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile}; +use windows::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, +}; +use windows::Win32::System::Pipes::CreatePipe; +use windows::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, + TerminateProcess, UpdateProcThreadAttribute, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION, + STARTUPINFOEXW, STARTUPINFOW, +}; + +// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 (not a named const in the +// windows crate's Threading module); value from the Windows SDK. +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016; + +/// A running ConPTY terminal: write to its input, resize, or kill it. The +/// output is pumped by a reader thread (see [`Terminal::spawn`]). +pub struct Terminal { + hpc: HPCON, + input_write: HANDLE, + proc: HANDLE, + thread: HANDLE, + stop: Arc, + attr_list: Vec, // keep the proc-thread attribute buffer alive til drop +} + +// The handles are owned by this struct + used from the engine thread; the reader +// thread only touches a cloned output handle. Mark Send so run.rs can hold it in +// its HashMap across .await points (the engine loop is single-threaded per host). +unsafe impl Send for Terminal {} + +impl Terminal { + /// Spawn `cmd.exe` on a fresh pseudo-console sized `cols`x`rows`. `on_output` + /// is called (on a reader thread) with each chunk of raw terminal bytes + /// (VT-encoded); `on_exit` is called once when the reader sees EOF. Returns + /// the live terminal handle. + pub fn spawn( + cols: u32, + rows: u32, + on_output: impl Fn(Vec) + Send + 'static, + on_exit: impl FnOnce() + Send + 'static, + ) -> std::io::Result { + unsafe { + // Two pipes: viewer->pty input, pty->viewer output. + let mut in_r = HANDLE::default(); + let mut in_w = HANDLE::default(); + let mut out_r = HANDLE::default(); + let mut out_w = HANDLE::default(); + CreatePipe(&mut in_r, &mut in_w, None, 0) + .map_err(|e| std::io::Error::other(format!("CreatePipe(in): {e}")))?; + CreatePipe(&mut out_r, &mut out_w, None, 0) + .map_err(|e| std::io::Error::other(format!("CreatePipe(out): {e}")))?; + + let size = COORD { + X: cols.clamp(1, 1000) as i16, + Y: rows.clamp(1, 1000) as i16, + }; + let hpc: HPCON = CreatePseudoConsole(size, in_r, out_w, 0) + .map_err(|e| std::io::Error::other(format!("CreatePseudoConsole: {e}")))?; + // The pseudo-console now owns these ends; we keep in_w + out_r. + let _ = CloseHandle(in_r); + let _ = CloseHandle(out_w); + + // Build STARTUPINFOEX with the PSEUDOCONSOLE attribute. + let mut attr_size: usize = 0; + let _ = InitializeProcThreadAttributeList( + windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(std::ptr::null_mut()), + 1, + 0, + &mut attr_size, + ); + let mut attr_list = vec![0u8; attr_size]; + let attr = + windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(attr_list.as_mut_ptr() as *mut c_void); + InitializeProcThreadAttributeList(attr, 1, 0, &mut attr_size) + .map_err(|e| std::io::Error::other(format!("InitAttrList: {e}")))?; + UpdateProcThreadAttribute( + attr, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + // Per the Win32 ConPTY sample (EchoCon), lpValue is the HPCON + // handle value passed as the pointer, cbValue = sizeof(HPCON). + Some(hpc.0 as *const c_void), + std::mem::size_of::(), + None, + None, + ) + .map_err(|e| std::io::Error::other(format!("UpdateAttr: {e}")))?; + + let mut si = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: std::mem::size_of::() as u32, + ..Default::default() + }, + lpAttributeList: attr, + }; + let mut pi = PROCESS_INFORMATION::default(); + // Mutable, NUL-terminated command line for CreateProcessW. + let mut cmd: Vec = "cmd.exe".encode_utf16().chain(std::iter::once(0)).collect(); + let created = CreateProcessW( + None, + PWSTR(cmd.as_mut_ptr()), + None, + None, + false, + EXTENDED_STARTUPINFO_PRESENT, + None, + None, + &si.StartupInfo, + &mut pi, + ); + // DeleteProcThreadAttributeList uses lpAttributeList; keep attr_list alive. + let _ = &mut si; + if let Err(e) = created { + DeleteProcThreadAttributeList(attr); + let _ = CloseHandle(in_w); + let _ = CloseHandle(out_r); + ClosePseudoConsole(hpc); + return Err(std::io::Error::other(format!("CreateProcessW(cmd): {e}"))); + } + + // Reader thread: pump ConPTY output -> on_output, EOF -> on_exit. + let stop = Arc::new(AtomicBool::new(false)); + let r_stop = stop.clone(); + let out_r_raw = out_r.0 as isize; + std::thread::Builder::new() + .name("netdesk-term-read".into()) + .spawn(move || { + let out_r = HANDLE(out_r_raw as *mut c_void); + let mut buf = [0u8; 8192]; + loop { + if r_stop.load(Ordering::Relaxed) { + break; + } + let mut read = 0u32; + let ok = unsafe { ReadFile(out_r, Some(&mut buf), Some(&mut read), None) }; + if ok.is_err() || read == 0 { + break; // pipe closed / shell exited + } + on_output(buf[..read as usize].to_vec()); + } + unsafe { + let _ = CloseHandle(out_r); + } + on_exit(); + }) + .ok(); + + Ok(Terminal { + hpc, + input_write: in_w, + proc: pi.hProcess, + thread: pi.hThread, + stop, + attr_list, + }) + } + } + + /// Write viewer keystrokes / command bytes to the shell. + pub fn write_input(&self, data: &[u8]) { + unsafe { + let mut written = 0u32; + let _ = WriteFile(self.input_write, Some(data), Some(&mut written), None); + } + } + + /// Resize the pseudo-console. + pub fn resize(&self, cols: u32, rows: u32) { + unsafe { + let _ = ResizePseudoConsole( + self.hpc, + COORD { + X: cols.clamp(1, 1000) as i16, + Y: rows.clamp(1, 1000) as i16, + }, + ); + } + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + unsafe { + // Closing the PTY + input signals the shell to exit; then reap it. + ClosePseudoConsole(self.hpc); + let _ = CloseHandle(self.input_write); + let _ = TerminateProcess(self.proc, 0); + let _ = CloseHandle(self.proc); + let _ = CloseHandle(self.thread); + } + let _ = &self.attr_list; // kept alive until here + } +} diff --git a/native/host/src/wol.rs b/native/host/src/wol.rs new file mode 100644 index 0000000..63af1b3 --- /dev/null +++ b/native/host/src/wol.rs @@ -0,0 +1,92 @@ +//! Wake-on-LAN magic-packet sender. An ONLINE NetDesk host can wake another PC +//! on its LAN on command (sys action `wol`), so a remote user whose own PC is +//! asleep can wake it through any online peer on the same network — the server +//! routes the `wol` command to a same-LAN peer, which calls [`send_wol`]. +//! +//! Driverless + dependency-free: a standard WoL magic packet is 6×0xFF followed +//! by the target MAC repeated 16 times (102 bytes), UDP-broadcast to the LAN. +//! Developed by Akronlogic. + +use std::net::UdpSocket; + +/// Parse "AA:BB:CC:DD:EE:FF" or "AA-BB-..." into 6 bytes. +pub fn parse_mac(s: &str) -> Option<[u8; 6]> { + let parts: Vec<&str> = s.split(|c| c == ':' || c == '-').collect(); + if parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + for (i, p) in parts.iter().enumerate() { + mac[i] = u8::from_str_radix(p.trim(), 16).ok()?; + } + Some(mac) +} + +/// Build the 102-byte WoL magic packet: 6×0xFF, then the MAC ×16. +pub fn magic_packet(mac: &[u8; 6]) -> [u8; 102] { + let mut pkt = [0u8; 102]; + for b in pkt.iter_mut().take(6) { + *b = 0xFF; + } + for i in 0..16 { + pkt[6 + i * 6..6 + i * 6 + 6].copy_from_slice(mac); + } + pkt +} + +/// Broadcast the magic packet for `mac_str` to the LAN. Sends to the limited +/// broadcast on the two conventional WoL ports (9 and 7). Returns how many of +/// the sends succeeded (0 = total failure). +pub fn send_wol(mac_str: &str) -> anyhow::Result { + let mac = parse_mac(mac_str).ok_or_else(|| anyhow::anyhow!("bad MAC: {mac_str}"))?; + let pkt = magic_packet(&mac); + let sock = UdpSocket::bind("0.0.0.0:0")?; + sock.set_broadcast(true)?; + let mut sent = 0usize; + for addr in ["255.255.255.255:9", "255.255.255.255:7"] { + if sock.send_to(&pkt, addr).is_ok() { + sent += 1; + } + } + if sent == 0 { + anyhow::bail!("no broadcast send succeeded"); + } + Ok(sent) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_builds_magic_packet() { + assert_eq!(parse_mac("01:02:03:04:05:06").unwrap(), [1, 2, 3, 4, 5, 6]); + assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + assert!(parse_mac("nope").is_none()); + let p = magic_packet(&[1, 2, 3, 4, 5, 6]); + assert_eq!(p.len(), 102); + assert_eq!(&p[0..6], &[0xFF; 6]); + assert_eq!(&p[6..12], &[1, 2, 3, 4, 5, 6]); + assert_eq!(&p[96..102], &[1, 2, 3, 4, 5, 6]); // 16th repeat + } + + // Prove the packet is actually built + transmitted correctly by sending it + // to a bound loopback receiver and reading the 102 bytes back. + #[test] + fn sends_magic_packet_over_udp_loopback() { + let rx = UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = rx.local_addr().unwrap().port(); + let mac = parse_mac("aa:bb:cc:dd:ee:ff").unwrap(); + let pkt = magic_packet(&mac); + let tx = UdpSocket::bind("0.0.0.0:0").unwrap(); + tx.send_to(&pkt, ("127.0.0.1", port)).unwrap(); + rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + let mut buf = [0u8; 256]; + let (n, _) = rx.recv_from(&mut buf).unwrap(); + assert_eq!(n, 102); + assert_eq!(&buf[0..6], &[0xFF; 6]); + assert_eq!(&buf[6..12], &mac); + assert_eq!(&buf[96..102], &mac); + println!("WOL_SEND_OK 102-byte magic packet transmitted + verified over UDP"); + } +} diff --git a/native/opus/Cargo.toml b/native/opus/Cargo.toml new file mode 100644 index 0000000..b975783 --- /dev/null +++ b/native/opus/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "netdesk-opus" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Opus (libopus) encode/decode for NetDesk audio. Developed by Akronlogic." +links = "opus" +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } + +[build-dependencies] +bindgen = "=0.65.1" diff --git a/native/opus/build.rs b/native/opus/build.rs new file mode 100644 index 0000000..6b7a14d --- /dev/null +++ b/native/opus/build.rs @@ -0,0 +1,48 @@ +//! Link the vendored static libopus (UCRT MinGW ABI) + generate FFI bindings +//! from the vendored opus headers via bindgen (libclang). Same vendoring +//! approach as netdesk-vpx: a UCRT-ABI libopus.a so there is no vcpkg/MSYS2 or +//! runtime DLL dependency, and no fragile build-from-source (audiopus_sys's +//! build script mangles extended-length paths on this toolchain). +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let tp = manifest.join("..").join("third_party").join("ucrt64"); + let lib = tp.join("lib"); + let inc = tp.join("include"); + + println!("cargo:rustc-link-search=native={}", lib.display()); + println!("cargo:rustc-link-lib=static=opus"); + println!("cargo:rerun-if-changed=wrapper.h"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", lib.join("libopus.a").display()); + + if std::env::var("LIBCLANG_PATH").is_err() { + for p in ["C:/Program Files/LLVM/bin", "C:/Program Files (x86)/LLVM/bin"] { + if PathBuf::from(p).join("libclang.dll").exists() { + std::env::set_var("LIBCLANG_PATH", p); + break; + } + } + } + + // OpusEncoder / OpusDecoder are opaque (used only through pointers), so + // bindgen's default opaque representation is exactly right here — unlike + // libvpx's config structs, we never set their fields. + let regex = "^(opus|Opus|OPUS).*"; + let bindings = bindgen::Builder::default() + .header("wrapper.h") + .clang_arg(format!("-I{}", inc.display())) + .allowlist_type(regex) + .allowlist_var(regex) + .allowlist_function(regex) + .rustified_enum(regex) + .trust_clang_mangling(false) + .layout_tests(false) + .generate_comments(false) + .generate() + .expect("bindgen libopus"); + bindings + .write_to_file(PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("opus_ffi.rs")) + .expect("write opus_ffi.rs"); +} diff --git a/native/opus/src/lib.rs b/native/opus/src/lib.rs new file mode 100644 index 0000000..4cce49c --- /dev/null +++ b/native/opus/src/lib.rs @@ -0,0 +1,225 @@ +//! Opus audio encode/decode over the vendored static libopus (UCRT MinGW). +//! +//! The raw FFI is generated by bindgen from the vendored headers (build.rs). +//! `OpusEncoder`/`OpusDecoder` are opaque (used only through pointers), so +//! bindgen's default handling is correct — no hand-written structs needed, +//! unlike netdesk-vpx's config structs. +//! +//! NetDesk audio is 48 kHz stereo, one 20 ms frame (960 samples/channel) per +//! Opus packet, RESTRICTED_LOWDELAY — matching what the web viewer sends/decodes +//! and what the host system-audio capture produces. +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +use anyhow::{bail, Result}; + +pub mod ffi { + #![allow( + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + dead_code, + improper_ctypes + )] + include!(concat!(env!("OUT_DIR"), "/opus_ffi.rs")); +} + +use std::ffi::CStr; +use std::os::raw::c_int; + +/// 48 kHz — the Opus "fullband" rate NetDesk uses end to end. +pub const SAMPLE_RATE: u32 = 48_000; +/// 20 ms frame at 48 kHz = 960 samples per channel. +pub const FRAME_SIZE: usize = 960; +/// Opus can emit up to 120 ms per packet = 5760 samples/channel; size any decode +/// scratch for the max so a decode can never overflow. +pub const MAX_FRAME_SIZE: usize = 5760; + +/// libopus version string, e.g. "libopus 1.5.2". +pub fn version() -> String { + unsafe { + let p = ffi::opus_get_version_string(); + if p.is_null() { + return String::new(); + } + CStr::from_ptr(p).to_string_lossy().into_owned() + } +} + +/// Opus encoder for system audio: RESTRICTED_LOWDELAY (lowest algorithmic delay, +/// which is what a screen-share wants). Input PCM is interleaved i16. +pub struct Encoder { + enc: *mut ffi::OpusEncoder, + channels: usize, +} + +impl Encoder { + pub fn new(sample_rate: u32, channels: u32) -> Result { + let mut err: c_int = 0; + let enc = unsafe { + ffi::opus_encoder_create( + sample_rate as i32, + channels as i32, + ffi::OPUS_APPLICATION_RESTRICTED_LOWDELAY as i32, + &mut err, + ) + }; + if enc.is_null() || err != 0 { + bail!("opus_encoder_create failed: {err}"); + } + // Quality tuning for crystal-clear system/mic audio (defaults are + // conservative). 128 kbps VBR, max complexity, music-optimised — the + // audio stream is a tiny fraction of the video bitrate, so this costs + // almost nothing on the wire but is near-transparent for music/voice. + unsafe { + ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_BITRATE_REQUEST as c_int, 128_000 as c_int); + ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_VBR_REQUEST as c_int, 1 as c_int); + ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_COMPLEXITY_REQUEST as c_int, 10 as c_int); + ffi::opus_encoder_ctl( + enc, + ffi::OPUS_SET_SIGNAL_REQUEST as c_int, + ffi::OPUS_SIGNAL_MUSIC as c_int, + ); + } + Ok(Self { + enc, + channels: channels as usize, + }) + } + + /// Encode one frame of interleaved i16 PCM into a single Opus packet. + /// `frame_size` is samples PER CHANNEL (e.g. [`FRAME_SIZE`]). + pub fn encode(&mut self, pcm: &[i16], frame_size: usize) -> Result> { + if pcm.len() < frame_size * self.channels { + bail!( + "pcm too short: {} < {}", + pcm.len(), + frame_size * self.channels + ); + } + let mut out = vec![0u8; 4000]; // max sensible Opus packet + let n = unsafe { + ffi::opus_encode( + self.enc, + pcm.as_ptr(), + frame_size as i32, + out.as_mut_ptr(), + out.len() as i32, + ) + }; + if n < 0 { + bail!("opus_encode failed: {n}"); + } + out.truncate(n as usize); + Ok(out) + } +} + +impl Drop for Encoder { + fn drop(&mut self) { + unsafe { ffi::opus_encoder_destroy(self.enc) }; + } +} + +/// Opus decoder. Output PCM is interleaved i16. +pub struct Decoder { + dec: *mut ffi::OpusDecoder, + channels: usize, +} + +impl Decoder { + pub fn new(sample_rate: u32, channels: u32) -> Result { + let mut err: c_int = 0; + let dec = + unsafe { ffi::opus_decoder_create(sample_rate as i32, channels as i32, &mut err) }; + if dec.is_null() || err != 0 { + bail!("opus_decoder_create failed: {err}"); + } + Ok(Self { + dec, + channels: channels as usize, + }) + } + + /// Decode one Opus packet into interleaved i16 PCM. Returns + /// `samples_per_channel * channels` samples. + pub fn decode(&mut self, data: &[u8]) -> Result> { + let mut pcm = vec![0i16; MAX_FRAME_SIZE * self.channels]; + let n = unsafe { + ffi::opus_decode( + self.dec, + data.as_ptr(), + data.len() as i32, + pcm.as_mut_ptr(), + MAX_FRAME_SIZE as i32, + 0, + ) + }; + if n < 0 { + bail!("opus_decode failed: {n}"); + } + pcm.truncate(n as usize * self.channels); + Ok(pcm) + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { ffi::opus_decoder_destroy(self.dec) }; + } +} + +// Opus contexts hold raw pointers; they are not shared (no Sync) but a single +// owner may move them between threads (the host capture thread owns the Encoder, +// the viewer audio thread owns the Decoder). +unsafe impl Send for Encoder {} +unsafe impl Send for Decoder {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn links_libopus() { + let v = version(); + assert!(v.to_lowercase().contains("opus"), "opus version: {v}"); + println!("OPUS_LINK_OK {v}"); + } + + #[test] + fn opus_roundtrip_48k_stereo() { + let mut enc = Encoder::new(SAMPLE_RATE, 2).unwrap(); + let mut dec = Decoder::new(SAMPLE_RATE, 2).unwrap(); + + // 5 frames of a full-scale 440 Hz stereo sine. + let mut max_abs = 0i32; + let mut total_out = 0usize; + let mut packet_bytes = 0usize; + for frame in 0..5 { + let mut pcm = vec![0i16; FRAME_SIZE * 2]; + for i in 0..FRAME_SIZE { + let t = (frame * FRAME_SIZE + i) as f32 / SAMPLE_RATE as f32; + let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin(); + let v = (s * 30000.0) as i16; + pcm[i * 2] = v; + pcm[i * 2 + 1] = v; + } + let pkt = enc.encode(&pcm, FRAME_SIZE).unwrap(); + assert!(!pkt.is_empty(), "empty opus packet"); + packet_bytes += pkt.len(); + let out = dec.decode(&pkt).unwrap(); + assert_eq!(out.len(), FRAME_SIZE * 2, "decoded frame size (stereo)"); + total_out += out.len(); + for &s in &out { + max_abs = max_abs.max((s as i32).abs()); + } + } + assert_eq!(total_out, 5 * FRAME_SIZE * 2); + // After the encoder's lookahead delay, the sine energy must come through + // (proves the codec actually ran, not just returned silence). + assert!(max_abs > 1000, "decoded audio is silent (max_abs={max_abs})"); + println!("OPUS_ROUNDTRIP_OK 48k/stereo 5x20ms total_pkt_bytes={packet_bytes} peak={max_abs}"); + } +} diff --git a/native/opus/wrapper.h b/native/opus/wrapper.h new file mode 100644 index 0000000..da600ec --- /dev/null +++ b/native/opus/wrapper.h @@ -0,0 +1 @@ +#include diff --git a/native/packaging/README.md b/native/packaging/README.md new file mode 100644 index 0000000..28cab82 --- /dev/null +++ b/native/packaging/README.md @@ -0,0 +1,130 @@ +# NetDesk — Windows Packaging + +Build scripts that turn the Rust `netdesk-viewer` binary into the two Windows +distributions **NetDesk by Akronlogic** ships: + +| Edition | Artifact | Install? | +|---------------|---------------------------------|----------| +| **Portable** | `NetDesk-Portable-win64.zip` | No — unzip and run | +| **Installer** | `NetDesk-Setup-win64.exe` | Yes — Start Menu + Desktop shortcuts, uninstaller | + +Everything in this folder is self-contained; nothing here touches the Rust +sources. All outputs land in **`packaging\dist\`**. + +``` +packaging/ +├── build-portable.ps1 builds + stages the portable folder, zips it +├── build-installer.ps1 stages, runs makensis, signs both artifacts +├── netdesk.nsi NSIS (Modern UI 2) installer definition +├── README.md this file +└── dist/ (generated) + ├── NetDesk-Portable/ staging folder (also fed to the installer) + │ ├── NetDesk.exe = target/release/netdesk-viewer.exe, renamed + │ ├── libstdc++-6.dll MinGW C++ runtime (required at runtime) + │ ├── libgcc_s_seh-1.dll MinGW GCC runtime + │ ├── libwinpthread-1.dll MinGW threading runtime + │ └── README.txt + ├── NetDesk-Portable-win64.zip ← portable artifact + └── NetDesk-Setup-win64.exe ← installer artifact (needs makensis) +``` + +## Prerequisites + +- **Rust (windows-GNU toolchain)** + the MinGW-w64 toolchain at + `C:\mingw-netdesk\mingw64\bin`. The scripts prepend this and `~\.cargo\bin` + to `PATH` before calling cargo, matching the project's manual build step: + + ```sh + export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:$PATH" + ``` + +- **NSIS** (for the installer only). If `makensis` is missing, + `build-installer.ps1` still produces the staging folder and prints the + install command: + + ```powershell + winget install --id NSIS.NSIS -e + # or download: https://nsis.sourceforge.io/Download + ``` + +## Produce the PORTABLE zip + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1 +``` + +Builds `cargo build --release -p netdesk-viewer`, assembles +`dist\NetDesk-Portable\` (NetDesk.exe + the 3 DLLs + README.txt) and writes +`dist\NetDesk-Portable-win64.zip`. Add `-SkipBuild` to reuse an existing +`target\release\netdesk-viewer.exe`. + +## Produce the INSTALLER exe + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-installer.ps1 +``` + +Runs the portable staging, then compiles `netdesk.nsi` with `makensis` into +`dist\NetDesk-Setup-win64.exe`, and signs both `NetDesk.exe` and the setup exe. +Useful flags: `-SkipBuild`, `-NoSign`, `-Thumbprint `. + +Compile the installer by hand (after staging) with: + +```powershell +makensis /DSTAGEDIR=packaging\dist\NetDesk-Portable /DOUTFILE=packaging\dist\NetDesk-Setup-win64.exe packaging\netdesk.nsi +``` + +The installer installs into `%ProgramFiles%\NetDesk`, creates Start Menu and +Desktop shortcuts, registers an uninstaller in Add/Remove Programs, and offers +an **optional, off-by-default** per-user autostart (HKCU `...\Run`). + +## Code signing + +Signing is handled by the project helper `..\certs\sign-release.ps1` +(Akronlogic cert, thumbprint `YOUR_CODE_SIGNING_CERT_THUMBPRINT`, +Sectigo timestamp). `build-installer.ps1`: + +1. invokes `sign-release.ps1` against the release tree, then +2. signs the staged `NetDesk.exe` (a renamed copy) and the finished + `NetDesk-Setup-win64.exe` with the **same** certificate + timestamp + mechanism (that helper is hardcoded to the `target\release` paths, so the + staged/setup files are signed with the identical cert here). + +> **Public trust note:** the bundled Akronlogic certificate is self-issued, so +> signatures are only trusted on machines that ran `certs\trust-akronlogic.cmd`. +> For signatures trusted on **every** Windows PC with no action by the user, a +> **CA-issued code-signing certificate** (Sectigo/DigiCert, chaining to a root +> in the Microsoft Trusted Root Program) is required — pass it to the helper +> with `-Thumbprint ` or `-PfxPath cert.pfx -PfxPassword ...`. +> Self-signed certificates never qualify for public trust. + +Skip signing entirely with `-NoSign` (e.g. for a local test build). + +## Notes + +- The portable and installer editions ship the **same** `NetDesk.exe` plus the + same three MinGW runtime DLLs. The viewer imports `libstdc++-6.dll` at + runtime, so the DLLs must always sit beside the exe. +- Deleting the portable folder fully removes it. The installer edition is + removed from Add/Remove Programs (or `uninstall.exe`). + +## iOS (macOS only) + +`build-ios.sh` (+ the `ios/` CocoaPods templates) builds the signed iOS app on a +Mac: it adds the Rust iOS targets, compiles `netdesk-core` for device + +simulator, assembles `NetDeskCore.xcframework`, wires it into the Flutter +`ui/ios/` project, and runs `flutter build ipa`. It cannot run on Windows (Xcode +is required). One command on the Mac: + +```bash +packaging/build-ios.sh --team +``` + +Full walkthrough — Apple Developer Program, signing, the VideoToolbox decode +path, every command — is in [`../docs/build-ios.md`](../docs/build-ios.md). + +``` +packaging/ios/ +├── netdesk_core.podspec vendors NetDeskCore.xcframework + the VideoToolbox frameworks +└── Podfile Flutter iOS Podfile template with the netdesk_core pod +``` diff --git a/native/packaging/build-android.ps1 b/native/packaging/build-android.ps1 new file mode 100644 index 0000000..934b8f8 --- /dev/null +++ b/native/packaging/build-android.ps1 @@ -0,0 +1,402 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + NetDesk - Android build pipeline. + + Compiles the Rust FFI layer (netdesk-frb, a cdylib that statically links + netdesk-core) into per-ABI .so libraries with cargo-ndk, drops them into + the Flutter jniLibs tree, optionally provisions a release signing keystore, + then produces the release APK, the per-ABI split APKs and the Play Store + App Bundle (.aab). + + Developed by Akronlogic (https://akronlogic.com). + +.DESCRIPTION + The Flutter UI (ui/) binds, via flutter_rust_bridge, to the compiled Rust + library shipped as a shared object inside the APK/AAB under + lib//libnetdesk_frb.so (the Dart loader looks for stem 'netdesk_frb'). + This script is the single entry point that wires the Android toolchain: + + netdesk-frb --cargo-ndk--> ui/android/app/src/main/jniLibs//libnetdesk_frb.so + Flutter --gradle-----> release .apk / split .apk / .aab (signed) + + THE ONE COMMAND (from the repo root): + + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 + + First release-signed run (keystore does not exist yet): + + $env:NETDESK_KEYSTORE_PASSWORD = '' + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore + +.NOTES + Prerequisites (the script verifies / auto-provisions what it can): + * Rust toolchain on PATH (cargo, rustup). + * cargo-ndk (auto-installed with `cargo install cargo-ndk` if absent). + * rustup Android std (auto-added if absent). + * Android NDK (default C:\dev\android-ndk; override with -NdkHome + or $env:ANDROID_NDK_HOME). REQUIRED for the Rust step. + * Android SDK (default %LOCALAPPDATA%\Android\Sdk). + * Flutter (default C:\dev\flutter\bin\flutter.bat). + * A JDK for keytool / Gradle (Android Studio's JBR is auto-detected). + + SECURITY: no real keystore password is ever stored in a committed file. + key.properties and *.keystore are git-ignored. -GenerateKeystore reads the + password ONLY from -KeystorePassword or $env:NETDESK_KEYSTORE_PASSWORD. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'KeystorePassword', + Justification = 'keytool -storepass/-keypass require plaintext; sourced from an env var and never committed.')] +[CmdletBinding()] +param( + # Root of the Android NDK (contains source.properties + toolchains/llvm). + [string] $NdkHome = $(if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { 'C:\dev\android-ndk' }), + # Android SDK root. + [string] $SdkHome = $(if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }), + # JDK home for keytool + Gradle. Auto-detected (Android Studio JBR) when empty. + [string] $JavaHome = $env:JAVA_HOME, + # ABIs to build. arm64-v8a is the primary shipping ABI. + [string[]] $Abis = @('arm64-v8a', 'armeabi-v7a', 'x86_64'), + # Android API level the .so links against. Must be <= the Flutter minSdk. + [int] $MinSdk = 21, + # Cargo package compiled into the Android .so (the flutter_rust_bridge layer). + [string] $RustPackage = 'netdesk-frb', + # The crate's [lib] name -> the .so is lib.so (what the Dart loader opens). + [string] $LibName = 'netdesk_frb', + # Which deliverables to produce. + [ValidateSet('all', 'apk', 'split', 'aab', 'rust', 'none')] + [string] $Target = 'all', + # Skip the Rust/cargo-ndk step (reuse whatever .so are already in jniLibs). + [switch] $SkipRust, + # Create (if missing) the release keystore + key.properties before building. + [switch] $GenerateKeystore, + # Keystore / key password. NEVER hard-code this - pass it or set the env var. + [string] $KeystorePassword = $env:NETDESK_KEYSTORE_PASSWORD, + # Key alias inside the keystore. + [string] $KeyAlias = 'netdesk', + # Path to flutter.bat. + [string] $FlutterBat = 'C:\dev\flutter\bin\flutter.bat' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$RepoRoot = Split-Path -Parent $ScriptDir +$UiDir = Join-Path $RepoRoot 'ui' +$AndroidDir = Join-Path $UiDir 'android' +$JniLibs = Join-Path $AndroidDir 'app\src\main\jniLibs' +$CorePkg = $RustPackage +$SoName = "lib$LibName.so" + +# Flutter ABI name -> Rust target triple (for reporting / target checks). +$AbiTriple = @{ + 'arm64-v8a' = 'aarch64-linux-android' + 'armeabi-v7a' = 'armv7-linux-androideabi' + 'x86_64' = 'x86_64-linux-android' + 'x86' = 'i686-linux-android' +} + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +function Write-Step { param([string]$m) Write-Host "`n==> $m" -ForegroundColor Cyan } +function Write-Info { param([string]$m) Write-Host " $m" -ForegroundColor Gray } +function Write-Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } +function Write-Warn2{ param([string]$m) Write-Host " [warn] $m" -ForegroundColor Yellow } +function Die { param([string]$m) Write-Host "`n[FATAL] $m`n" -ForegroundColor Red; exit 1 } + +function Invoke-Native { + param([string]$Exe, [string[]]$ArgList, [string]$WorkDir) + if ($WorkDir) { Push-Location $WorkDir } + try { + Write-Info "$Exe $($ArgList -join ' ')" + & $Exe @ArgList + if ($LASTEXITCODE -ne 0) { Die "'$Exe' exited with code $LASTEXITCODE" } + } finally { + if ($WorkDir) { Pop-Location } + } +} + +$banner = @' + _ _ _ ____ _ _ _ _ _ + | \ | | ___| |_| _ \ ___ ___| | _| | __ _ _ __ __| |_ __ ___ (_) __| | + | \| |/ _ \ __| | | |/ _ \/ __| |/ / | / _` | '_ \ / _` | '__/ _ \| |/ _` | + | |\ | __/ |_| |_| | __/\__ \ <| |___| (_| | | | | (_| | | | (_) | | (_| | + |_| \_|\___|\__|____/ \___||___/_|\_\_____|\__,_|_| |_|\__,_|_| \___/|_|\__,_| +'@ +Write-Host $banner -ForegroundColor Blue +Write-Host " NetDesk Android build pipeline - Akronlogic" -ForegroundColor DarkGray + +# --------------------------------------------------------------------------- # +# Toolchain discovery / environment +# --------------------------------------------------------------------------- # +Write-Step 'Resolving toolchain' + +if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + Die 'cargo not found on PATH. Install Rust (https://rustup.rs) and re-open the shell.' +} +Write-Ok "cargo: $((cargo --version) 2>&1)" + +# JAVA_HOME: auto-detect Android Studio's bundled JBR when not provided. +if (-not $JavaHome -or -not (Test-Path (Join-Path $JavaHome 'bin'))) { + $jbrCandidates = @( + 'C:\Program Files\Android\Android Studio\jbr', + 'C:\Program Files\Android\Android Studio\jre', + (Join-Path ${env:ProgramFiles} 'Android\Android Studio\jbr') + ) | Where-Object { $_ -and (Test-Path (Join-Path $_ 'bin\java.exe')) } + if ($jbrCandidates) { $JavaHome = $jbrCandidates[0] } +} +if ($JavaHome -and (Test-Path (Join-Path $JavaHome 'bin\java.exe'))) { + $env:JAVA_HOME = $JavaHome + $env:PATH = (Join-Path $JavaHome 'bin') + ';' + $env:PATH + Write-Ok "JAVA_HOME: $JavaHome" +} else { + Write-Warn2 'No JDK found (JAVA_HOME unset and no Android Studio JBR). Gradle/keytool may fail.' +} + +# Android SDK. +if (Test-Path $SdkHome) { + $env:ANDROID_HOME = $SdkHome + $env:ANDROID_SDK_ROOT = $SdkHome + Write-Ok "ANDROID_HOME: $SdkHome" +} else { + Write-Warn2 "Android SDK not found at $SdkHome (Flutter build apk/aab needs it)." +} + +$needRust = (-not $SkipRust) -and ($Target -in @('all', 'apk', 'split', 'aab', 'rust')) + +# --------------------------------------------------------------------------- # +# Android NDK - detection (the script "detects it"; required only for Rust) +# --------------------------------------------------------------------------- # +function Test-Ndk { + param([string]$Path) + if (-not $Path) { return $false } + if (-not (Test-Path (Join-Path $Path 'source.properties'))) { return $false } + if (-not (Test-Path (Join-Path $Path 'toolchains\llvm\prebuilt'))) { return $false } + return $true +} + +$ndkOk = Test-Ndk $NdkHome +if ($needRust) { + Write-Step 'Locating Android NDK' + if ($ndkOk) { + $rev = (Select-String -Path (Join-Path $NdkHome 'source.properties') -Pattern 'Pkg.Revision\s*=\s*(.+)' ).Matches.Groups[1].Value.Trim() + $env:ANDROID_NDK_HOME = $NdkHome + $env:ANDROID_NDK_ROOT = $NdkHome + Write-Ok "NDK $rev at $NdkHome" + } else { + Die @" +Android NDK not found (or incomplete) at: + $NdkHome + +Expected '$NdkHome\source.properties' and '$NdkHome\toolchains\llvm\prebuilt'. +The NDK may still be downloading/extracting. When it is in place, re-run: + + powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 + +Or point at another location: -NdkHome / `$env:ANDROID_NDK_HOME +Or skip the Rust step to build Flutter only: -SkipRust +"@ + } +} + +# --------------------------------------------------------------------------- # +# Rust -> per-ABI .so via cargo-ndk +# --------------------------------------------------------------------------- # +if ($needRust) { + Write-Step 'Preparing Rust Android targets' + + # cargo-ndk (auto-install if missing). + if (-not (Get-Command cargo-ndk -ErrorAction SilentlyContinue)) { + Write-Warn2 'cargo-ndk not found - installing (cargo install cargo-ndk)...' + Invoke-Native 'cargo' @('install', 'cargo-ndk') + } + Write-Ok "cargo-ndk: $((cargo ndk --version) 2>&1)" + + # rustup std for each requested ABI (auto-add if missing). + $installed = (rustup target list --installed) 2>&1 + foreach ($abi in $Abis) { + $triple = $AbiTriple[$abi] + if (-not $triple) { Die "Unknown ABI '$abi'. Valid: $($AbiTriple.Keys -join ', ')" } + if ($installed -notcontains $triple) { + Write-Warn2 "rustup target '$triple' missing - adding..." + Invoke-Native 'rustup' @('target', 'add', $triple) + } else { + Write-Ok "rust target present: $triple" + } + } + + Write-Step "Building $CorePkg -> $SoName [$($Abis -join ', ')] (API $MinSdk)" + New-Item -ItemType Directory -Force -Path $JniLibs | Out-Null + + # cargo-ndk writes // directly (that IS the "copy"). + # cargo ndk -t ... -o --platform build --release --package --lib + $ndkArgs = @() + foreach ($abi in $Abis) { $ndkArgs += @('-t', $abi) } + $ndkArgs += '-o'; $ndkArgs += $JniLibs + $ndkArgs += '--platform'; $ndkArgs += "$MinSdk" + $ndkArgs += 'build'; $ndkArgs += '--release' + $ndkArgs += '--package'; $ndkArgs += $CorePkg + $ndkArgs += '--lib' + + Invoke-Native 'cargo' (@('ndk') + $ndkArgs) $RepoRoot + + Write-Step 'Verifying .so artifacts' + $allOk = $true + foreach ($abi in $Abis) { + $so = Join-Path $JniLibs "$abi\$SoName" + if (Test-Path $so) { + $kb = [math]::Round((Get-Item $so).Length / 1KB) + Write-Ok "$abi/$SoName ($kb KB)" + } else { + Write-Warn2 "MISSING: $abi/$SoName" + $allOk = $false + } + } + if (-not $allOk) { Die 'One or more ABIs failed to produce a .so (see cargo output above).' } + + # ----------------------------------------------------------------------- # + # Bundle the shared C++ STL beside the engine. The Rust cdylib links + # against libc++_shared (pulled in by the openh264 C++ build), and Android + # devices do NOT ship it — without this copy the app dies at startup with: + # dlopen failed: library "libc++_shared.so" not found + # (needed by .../lib//libnetdesk_frb.so) + # NOTE: the sysroot folder for armeabi-v7a is 'arm-linux-androideabi' + # (NOT the Rust triple 'armv7-linux-androideabi'). + # ----------------------------------------------------------------------- # + Write-Step 'Bundling libc++_shared.so per ABI' + $SysrootLib = Join-Path $NdkHome 'toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib' + $AbiSysDir = @{ + 'arm64-v8a' = 'aarch64-linux-android' + 'armeabi-v7a' = 'arm-linux-androideabi' + 'x86_64' = 'x86_64-linux-android' + } + foreach ($abi in $Abis) { + $src = Join-Path $SysrootLib "$($AbiSysDir[$abi])\libc++_shared.so" + if (-not (Test-Path $src)) { Die "libc++_shared.so not found for ${abi}: $src" } + Copy-Item $src (Join-Path $JniLibs "$abi\libc++_shared.so") -Force + $kb = [math]::Round((Get-Item (Join-Path $JniLibs "$abi\libc++_shared.so")).Length / 1KB) + Write-Ok "$abi/libc++_shared.so bundled ($kb KB)" + } +} else { + Write-Step 'Skipping Rust step (-SkipRust or -Target none)' +} + +if ($Target -eq 'rust' -or $Target -eq 'none') { + Write-Step 'Done (Rust-only target requested).' + exit 0 +} + +# --------------------------------------------------------------------------- # +# Release signing (optional keystore provisioning) +# --------------------------------------------------------------------------- # +$KeyPropsFile = Join-Path $AndroidDir 'key.properties' +$KeystoreFile = Join-Path $AndroidDir 'app\netdesk-release.keystore' + +if ($GenerateKeystore) { + Write-Step 'Provisioning release signing keystore' + if (Test-Path $KeystoreFile) { + Write-Ok "Keystore already exists: $KeystoreFile (not regenerating)" + } else { + if ([string]::IsNullOrWhiteSpace($KeystorePassword)) { + Die @' +-GenerateKeystore was requested but no password was supplied. +Set it WITHOUT hard-coding it anywhere committed, e.g.: + + $env:NETDESK_KEYSTORE_PASSWORD = '' + ... -GenerateKeystore + +or pass -KeystorePassword ''. +'@ + } + $keytool = $null + foreach ($c in @( + (Join-Path $env:JAVA_HOME 'bin\keytool.exe'), + 'C:\Program Files\Android\Android Studio\jbr\bin\keytool.exe')) { + if ($c -and (Test-Path $c)) { $keytool = $c; break } + } + if (-not $keytool) { + $gc = Get-Command keytool -ErrorAction SilentlyContinue + if ($gc) { $keytool = $gc.Source } + } + if (-not $keytool) { Die 'keytool not found (install a JDK or Android Studio).' } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeystoreFile) | Out-Null + Invoke-Native $keytool @( + '-genkeypair', '-v', + '-keystore', $KeystoreFile, + '-alias', $KeyAlias, + '-keyalg', 'RSA', '-keysize', '4096', + '-validity', '10000', + '-storepass', $KeystorePassword, + '-keypass', $KeystorePassword, + '-dname', 'CN=NetDesk, O=Akronlogic, OU=Release, C=DE' + ) + Write-Ok "Created keystore: $KeystoreFile" + } + + # Write key.properties (git-ignored; holds the local password). + @" +# NetDesk release signing - LOCAL ONLY, never commit (git-ignored). +# Regenerate the keystore with: build-android.ps1 -GenerateKeystore +storePassword=$KeystorePassword +keyPassword=$KeystorePassword +keyAlias=$KeyAlias +storeFile=$($KeystoreFile -replace '\\','/') +"@ | Set-Content -Encoding ASCII -Path $KeyPropsFile + Write-Ok "Wrote $KeyPropsFile" +} + +if (-not (Test-Path $KeyPropsFile)) { + Write-Warn2 'No key.properties found - release artifacts will be DEBUG-signed.' + Write-Warn2 'Run with -GenerateKeystore (and a password) to produce upload-signed builds.' +} + +# --------------------------------------------------------------------------- # +# Flutter build +# --------------------------------------------------------------------------- # +if (-not (Test-Path $FlutterBat)) { Die "flutter.bat not found at $FlutterBat (use -FlutterBat)." } +if (-not (Test-Path $AndroidDir)) { + Die "Flutter android/ project missing at $AndroidDir. Run: flutter create --platforms=android . (in ui/)" +} + +Write-Step 'flutter pub get' +Invoke-Native $FlutterBat @('pub', 'get') $UiDir + +function Invoke-FlutterBuild { + param([string[]]$ArgList, [string]$Label) + Write-Step "flutter build $Label" + Invoke-Native $FlutterBat (@('build') + $ArgList) $UiDir + Write-Ok "$Label done" +} + +switch ($Target) { + 'apk' { Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' } + 'split' { Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' } + 'aab' { Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' } + 'all' { + Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' + Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' + Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' + } +} + +# --------------------------------------------------------------------------- # +# Report artifacts +# --------------------------------------------------------------------------- # +Write-Step 'Artifacts' +$outApk = Join-Path $UiDir 'build\app\outputs\flutter-apk' +$outAab = Join-Path $UiDir 'build\app\outputs\bundle\release' +foreach ($dir in @($outApk, $outAab)) { + if (Test-Path $dir) { + Get-ChildItem $dir -Filter '*.ap*' -ErrorAction SilentlyContinue | ForEach-Object { + $mb = [math]::Round($_.Length / 1MB, 2) + Write-Host (" {0,-34} {1,8} MB" -f $_.Name, $mb) -ForegroundColor Green + Write-Host (" {0}" -f $_.FullName) -ForegroundColor DarkGray + } + } +} +Write-Host "`n==> NetDesk Android build complete.`n" -ForegroundColor Cyan diff --git a/native/packaging/build-brand.ps1 b/native/packaging/build-brand.ps1 new file mode 100644 index 0000000..17f4b6a --- /dev/null +++ b/native/packaging/build-brand.ps1 @@ -0,0 +1,58 @@ +<# + NetDesk by Akronlogic -- white-label / custom-client generator. + + Produces a pre-configured, re-brandable portable client by RENAMING the exe so + it self-configures on first launch (no rebuild, no per-brand signing key + needed — the single signed NetDesk.exe is reused): + + powershell -File build-brand.ps1 -Name AcmeRemote -Server vpn.acme.io -Key ACME9 [-Port 8443] + + Output: dist\brand-\-server=[,port=][,key=].exe + + the runtime DLLs. Ship the folder as-is; on first launch the client writes + the relay + registration key into config.json (see core::brand + + viewer apply_filename_selfconfig). Logo / palette / title theming and a signed + MSI with a reseller cert are the remaining white-label steps (see BUILD-QUEUE). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Server, + [string]$Key = "", + [string]$Port = "", + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$OutDir = "", + [switch]$SkipStage +) +$ErrorActionPreference = "Stop" +$Pkg = $PSScriptRoot +$Stage = Join-Path $Pkg "dist\NetDesk-Portable" + +# 1. stage the signed portable payload unless told to reuse it. +if (-not $SkipStage) { + & (Join-Path $Pkg "build-portable.ps1") -MingwBin $MingwBin -SkipBuild +} +if (-not (Test-Path (Join-Path $Stage "NetDesk.exe"))) { + throw "staged payload missing: $Stage (run build-portable.ps1 first, or omit -SkipStage)" +} + +# 2. compose the self-configuring exe name. +$cfg = "server=$Server" +if ($Port) { $cfg += ",port=$Port" } +if ($Key) { $cfg += ",key=$Key" } +$exeName = "$Name-$cfg.exe" + +if (-not $OutDir) { $OutDir = Join-Path $Pkg ("dist\brand-" + $Name) } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# 3. the single signed NetDesk.exe is reused verbatim, just renamed; the runtime +# DLLs travel with it. (The signature stays valid — we rename, not patch.) +Copy-Item (Join-Path $Stage "NetDesk.exe") (Join-Path $OutDir $exeName) -Force +foreach ($dll in "libgcc_s_seh-1.dll","libstdc++-6.dll","libwinpthread-1.dll") { + $src = Join-Path $Stage $dll + if (Test-Path $src) { Copy-Item $src (Join-Path $OutDir $dll) -Force } +} + +$scheme = "wss://$Server" +if ($Port) { $scheme = "wss://${Server}:$Port" } +Write-Host "white-label client -> $OutDir\$exeName" -ForegroundColor Green +Write-Host " pre-pointed at $scheme/ws; self-configures config.json on first launch." diff --git a/native/packaging/build-installer.ps1 b/native/packaging/build-installer.ps1 new file mode 100644 index 0000000..e785e9d --- /dev/null +++ b/native/packaging/build-installer.ps1 @@ -0,0 +1,145 @@ +<# + NetDesk by Akronlogic -- INSTALLER build script. + + 1. Runs build-portable.ps1 to build the release viewer and stage the files + (dist\NetDesk-Portable\). + 2. Detects makensis (NSIS). If found, compiles netdesk.nsi into + dist\NetDesk-Setup-win64.exe. If NOT found, prints install instructions + and still leaves the staging folder ready. + 3. Code-signs NetDesk.exe (in the staging folder) and the setup .exe via + certs\sign-release.ps1, when signing is available. + + Usage: + powershell -ExecutionPolicy Bypass -File build-installer.ps1 + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -SkipBuild + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -NoSign + powershell -ExecutionPolicy Bypass -File build-installer.ps1 -Thumbprint +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$Thumbprint = "YOUR_CODE_SIGNING_CERT_THUMBPRINT", + [switch]$SkipBuild, + [switch]$NoSign +) + +$ErrorActionPreference = "Stop" + +# ---- paths ----------------------------------------------------------------- +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$NsiScript = Join-Path $PkgDir "netdesk.nsi" +$SetupExe = Join-Path $DistDir "NetDesk-Setup-win64.exe" +$SignPs1 = Join-Path $RootDir "certs\sign-release.ps1" +$BrandIco = Join-Path $PkgDir "netdesk.ico" # branded setup-exe icon +$AssetIco = Join-Path $RootDir "assets\icon.ico" # source NetDesk mark + +Write-Host "=== NetDesk installer build (Akronlogic) ===" -ForegroundColor Cyan + +# ---- 1. stage via build-portable.ps1 -------------------------------------- +Write-Host "`n[1/4] staging portable payload" -ForegroundColor Yellow +$portableArgs = @{ MingwBin = $MingwBin } +if ($SkipBuild) { $portableArgs.SkipBuild = $true } +& (Join-Path $PkgDir "build-portable.ps1") @portableArgs +if ($LASTEXITCODE) { throw "build-portable.ps1 failed (exit $LASTEXITCODE)" } +if (-not (Test-Path $StageDir)) { throw "staging folder missing: $StageDir" } + +# ---- sign the staged NetDesk.exe BEFORE packaging it into the installer ---- +function Invoke-Sign([string[]]$Files) { + if ($NoSign) { Write-Host " -NoSign set: skipping signing." -ForegroundColor DarkYellow; return } + if (-not (Test-Path $SignPs1)) { Write-Host " signing helper not found ($SignPs1) -- skipping." -ForegroundColor DarkYellow; return } + $signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue + foreach ($f in $Files) { + if (-not (Test-Path $f)) { continue } + Write-Host " signing: $f" -ForegroundColor Gray + try { + if ($signtool) { + & $signtool.Source sign /sha1 $Thumbprint /fd SHA256 /tr "http://timestamp.sectigo.com" /td SHA256 "$f" + if ($LASTEXITCODE) { Write-Host " signtool returned $LASTEXITCODE (continuing)" -ForegroundColor DarkYellow } + } else { + # Fall back to Set-AuthenticodeSignature via the project helper logic. + $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Thumbprint } | Select-Object -First 1 + if (-not $cert) { Write-Host " cert $Thumbprint not in CurrentUser\My -- skipping." -ForegroundColor DarkYellow; continue } + $r = Set-AuthenticodeSignature -FilePath $f -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.sectigo.com" + Write-Host (" sign: {0}" -f $r.Status) -ForegroundColor Gray + } + } catch { + Write-Host " signing failed: $($_.Exception.Message) (continuing)" -ForegroundColor DarkYellow + } + } +} + +Write-Host "`n[2/4] signing staged NetDesk.exe" -ForegroundColor Yellow +# Run the official project helper on the release tree (signs netdesk-viewer.exe +# etc. exactly as Akronlogic ships them). Non-fatal if the cert isn't present. +if (-not $NoSign -and (Test-Path $SignPs1)) { + try { + & powershell -NoProfile -ExecutionPolicy Bypass -File $SignPs1 -Thumbprint $Thumbprint + } catch { + Write-Host " sign-release.ps1 note: $($_.Exception.Message) (continuing)" -ForegroundColor DarkYellow + } +} +# The staged NetDesk.exe is a renamed COPY, so it needs its own signature using +# the same certificate + timestamp mechanism that sign-release.ps1 uses. +Invoke-Sign @( (Join-Path $StageDir "NetDesk.exe") ) + +# Re-zip the portable artifact so it, too, ships the now-signed NetDesk.exe +# (build-portable.ps1 zipped the staging folder before this signing step). +if (-not $NoSign) { + $zip = Join-Path $DistDir "NetDesk-Portable-win64.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $StageDir "*") -DestinationPath $zip -CompressionLevel Optimal + Write-Host " re-zipped portable with signed NetDesk.exe -> $zip" -ForegroundColor Gray +} + +# ---- 3. compile the installer with makensis ------------------------------- +Write-Host "`n[3/4] locating makensis (NSIS)" -ForegroundColor Yellow +$makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue +if (-not $makensis) { + foreach ($p in @("$env:ProgramFiles\NSIS\makensis.exe", "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) { + if (Test-Path $p) { $makensis = Get-Item $p; break } + } +} + +if (-not $makensis) { + Write-Host "" + Write-Host "makensis (NSIS) is NOT installed -- the installer .exe was NOT built." -ForegroundColor Red + Write-Host "The portable zip and the installer staging folder are ready." -ForegroundColor Red + Write-Host "" + Write-Host "Install NSIS, then re-run this script:" -ForegroundColor Yellow + Write-Host " winget install --id NSIS.NSIS -e" + Write-Host " or download from https://nsis.sourceforge.io/Download" + Write-Host "" + Write-Host "After installing, build the installer manually with:" -ForegroundColor Yellow + Write-Host (" makensis /DSTAGEDIR={0} /DOUTFILE={1} `"{2}`"" -f $StageDir, $SetupExe, $NsiScript) + exit 0 +} + +Write-Host " found: $($makensis.Source)" -ForegroundColor Gray +if (Test-Path $SetupExe) { Remove-Item $SetupExe -Force } + +# Brand the setup .exe with the green NetDesk icon (copy the asset next to the +# .nsi and pass its absolute path as /DBRANDICON). +$nsisArgs = @("/DSTAGEDIR=$StageDir", "/DOUTFILE=$SetupExe") +if (Test-Path $AssetIco) { + Copy-Item $AssetIco $BrandIco -Force + $nsisArgs += "/DBRANDICON=$BrandIco" + Write-Host " branding setup icon: $BrandIco" -ForegroundColor Gray +} else { + Write-Host " asset icon not found ($AssetIco) -- setup will use the default NSIS icon." -ForegroundColor DarkYellow +} +$nsisArgs += "$NsiScript" +& $makensis.Source @nsisArgs +if ($LASTEXITCODE) { throw "makensis failed (exit $LASTEXITCODE)" } +if (-not (Test-Path $SetupExe)) { throw "makensis reported success but $SetupExe is missing" } + +# ---- 4. sign the setup .exe ------------------------------------------------ +Write-Host "`n[4/4] signing the setup installer" -ForegroundColor Yellow +Invoke-Sign @( $SetupExe ) + +$si = Get-Item $SetupExe +Write-Host "`nInstaller build complete." -ForegroundColor Green +Write-Host (" setup : {0}" -f $si.FullName) +Write-Host (" size : {0:N0} bytes ({1:N2} MB)" -f $si.Length, ($si.Length/1MB)) diff --git a/native/packaging/build-ios.sh b/native/packaging/build-ios.sh new file mode 100644 index 0000000..8696858 --- /dev/null +++ b/native/packaging/build-ios.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# ============================================================================= +# NetDesk by Akronlogic -- iOS build (Flutter + Rust core). +# +# Runs on macOS ONLY (needs Xcode + the iOS SDKs). It: +# 1. adds the Rust iOS targets (aarch64-apple-ios + aarch64-apple-ios-sim) +# 2. builds the Rust core static lib for device AND simulator +# 3. assembles NetDeskCore.xcframework (xcodebuild -create-xcframework) +# 4. ensures ui/ios/ exists (flutter create --platforms=ios .) +# 5. wires the xcframework into the Flutter iOS project (CocoaPods podspec) +# 6. flutter build ipa (signed with your Apple team) +# +# The Rust FFI static lib comes from the workspace `core` crate +# (package netdesk-core, lib netdesk_core -> libnetdesk_core.a). When the +# Phase-3 flutter_rust_bridge wrapper crate lands under a different name +# (e.g. "netdesk-frb"), point the script at it with: +# CORE_CRATE=netdesk-frb LIB_NAME=netdesk_frb ./build-ios.sh ... +# +# See docs/build-ios.md for prerequisites (Apple Developer Program, signing +# cert + provisioning profile) and the full walkthrough. +# +# Usage: +# packaging/build-ios.sh --team ABCDE12345 +# packaging/build-ios.sh --team ABCDE12345 --method ad-hoc --org com.akronlogic +# packaging/build-ios.sh --skip-ipa # just build the xcframework +# packaging/build-ios.sh --help +# +# Env overrides: CORE_CRATE LIB_NAME DEVICE_TARGET SIM_TARGET IOS_MIN +# FLUTTER TEAM_ID EXPORT_METHOD FLUTTER_ORG +# ============================================================================= +set -euo pipefail + +# ---- configuration (env-overridable) --------------------------------------- +CORE_CRATE="${CORE_CRATE:-netdesk-core}" # cargo package to build +LIB_NAME="${LIB_NAME:-netdesk_core}" # -> lib.a +DEVICE_TARGET="${DEVICE_TARGET:-aarch64-apple-ios}" +SIM_TARGET="${SIM_TARGET:-aarch64-apple-ios-sim}" # Intel Macs: x86_64-apple-ios +IOS_MIN="${IOS_MIN:-13.0}" +FLUTTER="${FLUTTER:-flutter}" +FLUTTER_ORG="${FLUTTER_ORG:-space.netdesk}" # -> bundle id .netdesk +EXPORT_METHOD="${EXPORT_METHOD:-app-store}" # app-store | ad-hoc | development | enterprise +TEAM_ID="${TEAM_ID:-${DEVELOPMENT_TEAM:-}}" # 10-char Apple Team ID +XCFRAMEWORK="NetDeskCore.xcframework" + +SKIP_IPA=0 + +# ---- args ------------------------------------------------------------------ +usage() { sed -n '3,29p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } +while [ $# -gt 0 ]; do + case "$1" in + --team) TEAM_ID="${2:?--team needs an Apple Team ID}"; shift 2 ;; + --method) EXPORT_METHOD="${2:?--method needs a value}"; shift 2 ;; + --org) FLUTTER_ORG="${2:?--org needs a value}"; shift 2 ;; + --skip-ipa|--xcframework-only) SKIP_IPA=1; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown argument: $1" >&2; usage 1 ;; + esac +done + +# ---- paths ----------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +UI="$ROOT/ui" +IOSDIR="$UI/ios" +TEMPLATES="$SCRIPT_DIR/ios" +DIST="$SCRIPT_DIR/dist/ios" +DEV_LIB="$ROOT/target/$DEVICE_TARGET/release/lib$LIB_NAME.a" +SIM_LIB="$ROOT/target/$SIM_TARGET/release/lib$LIB_NAME.a" + +say() { printf '\033[36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[32m %s\033[0m\n' "$*"; } +die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +cat <}") +BANNER + +# ---- 0. preflight ---------------------------------------------------------- +say "0/6 preflight" +[ "$(uname -s)" = "Darwin" ] || die "iOS builds require macOS. Run this on the Mac." +command -v rustup >/dev/null || die "rustup not found. Install Rust: https://rustup.rs" +command -v cargo >/dev/null || die "cargo not found on PATH (try: source \$HOME/.cargo/env)" +command -v xcrun >/dev/null || die "xcrun not found. Install Xcode + command line tools (xcode-select --install)." +xcode-select -p >/dev/null 2>&1 || die "no active Xcode. Run: sudo xcode-select -s /Applications/Xcode.app" +command -v "$FLUTTER" >/dev/null || die "flutter not found. Install the Flutter SDK and add it to PATH (or set FLUTTER=/path/to/flutter)." +if [ "$SKIP_IPA" = 0 ] && [ -z "$TEAM_ID" ]; then + die "no Apple Team ID. Pass --team ABCDE12345 (or TEAM_ID=...), or use --skip-ipa to only build the xcframework." +fi +ok "macOS $(sw_vers -productVersion 2>/dev/null || true), Xcode $(xcodebuild -version 2>/dev/null | head -1 || true)" + +# ---- 1. rust ios targets --------------------------------------------------- +say "1/6 rustup targets" +rustup target add "$DEVICE_TARGET" "$SIM_TARGET" +ok "$DEVICE_TARGET, $SIM_TARGET installed" + +# ---- 2. build the rust core static lib for both targets -------------------- +say "2/6 cargo build (release, staticlib) for device + simulator" +# Match Flutter's deployment target so the slices are link-compatible. +export IPHONEOS_DEPLOYMENT_TARGET="$IOS_MIN" +( cd "$ROOT" && cargo build --release -p "$CORE_CRATE" --target "$DEVICE_TARGET" ) +( cd "$ROOT" && cargo build --release -p "$CORE_CRATE" --target "$SIM_TARGET" ) +[ -f "$DEV_LIB" ] || die "expected device lib not found: $DEV_LIB" +[ -f "$SIM_LIB" ] || die "expected simulator lib not found: $SIM_LIB" +ok "device: $DEV_LIB" +ok "sim : $SIM_LIB" + +# ---- 3. assemble the xcframework ------------------------------------------- +say "3/6 assemble $XCFRAMEWORK" +mkdir -p "$DIST" +rm -rf "$DIST/$XCFRAMEWORK" # xcodebuild refuses to overwrite +# No -headers yet: the C ABI header (cbindgen over core/src/ffi.rs) is Phase 3. +# When it exists, add: -headers "$ROOT/target/include" to each -library pair. +xcodebuild -create-xcframework \ + -library "$DEV_LIB" \ + -library "$SIM_LIB" \ + -output "$DIST/$XCFRAMEWORK" +ok "built $DIST/$XCFRAMEWORK" + +if [ "$SKIP_IPA" = 1 ]; then + # Still stage it into ui/ios if that project already exists, so a later + # `flutter build ipa` picks up the fresh binary. + if [ -d "$IOSDIR" ]; then + cp -R "$DIST/$XCFRAMEWORK" "$IOSDIR/" + ok "staged into $IOSDIR/$XCFRAMEWORK" + fi + say "done (--skip-ipa). xcframework at: $DIST/$XCFRAMEWORK" + exit 0 +fi + +# ---- 4. ensure the Flutter iOS project exists ------------------------------ +say "4/6 ensure ui/ios Flutter project" +if [ ! -d "$IOSDIR" ]; then + ok "ui/ios missing — generating with flutter create" + ( cd "$UI" && "$FLUTTER" create --org "$FLUTTER_ORG" --project-name netdesk --platforms=ios . ) +else + ok "ui/ios already present — leaving it in place" +fi +( cd "$UI" && "$FLUTTER" pub get ) + +# ---- 5. wire the xcframework into ui/ios (CocoaPods) ----------------------- +say "5/6 wire xcframework into the iOS project" +cp -R "$DIST/$XCFRAMEWORK" "$IOSDIR/" +ok "copied $XCFRAMEWORK into ui/ios" + +cp "$TEMPLATES/netdesk_core.podspec" "$IOSDIR/netdesk_core.podspec" +ok "installed ui/ios/netdesk_core.podspec" + +# Podfile: install the template if there is none, otherwise inject the pod line. +if [ ! -f "$IOSDIR/Podfile" ]; then + cp "$TEMPLATES/Podfile" "$IOSDIR/Podfile" + ok "installed ui/ios/Podfile (from template)" +else + # Uncomment any commented `platform :ios` line so it matches the xcframework. + if grep -qE '^\s*#\s*platform :ios' "$IOSDIR/Podfile"; then + sed -i '' -E "s/^\s*#\s*platform :ios.*/platform :ios, '$IOS_MIN'/" "$IOSDIR/Podfile" + fi + # Inject the netdesk_core pod into the Runner target if it is not there yet. + if ! grep -q "pod 'netdesk_core'" "$IOSDIR/Podfile"; then + awk ' + /target '\''Runner'\'' do/ && !done { + print + print " # NetDesk Rust core (added by packaging/build-ios.sh)." + print " pod '\''netdesk_core'\'', :path => '\''.'\''" + done = 1 + next + } + { print } + ' "$IOSDIR/Podfile" > "$IOSDIR/Podfile.tmp" && mv "$IOSDIR/Podfile.tmp" "$IOSDIR/Podfile" + ok "injected pod 'netdesk_core' into existing Podfile" + else + ok "Podfile already references netdesk_core" + fi +fi + +# Signing for the archive step: set the team + automatic signing via the +# Flutter xcconfig layer (applies because a fresh Runner target has no explicit +# DEVELOPMENT_TEAM in project.pbxproj). Idempotent. +for cfg in Release Debug; do + XC="$IOSDIR/Flutter/$cfg.xcconfig" + [ -f "$XC" ] || continue + if ! grep -q '^DEVELOPMENT_TEAM' "$XC"; then + { + echo "" + echo "// NetDesk signing (added by packaging/build-ios.sh)" + echo "DEVELOPMENT_TEAM = $TEAM_ID" + echo "CODE_SIGN_STYLE = Automatic" + } >> "$XC" + ok "set DEVELOPMENT_TEAM in Flutter/$cfg.xcconfig" + else + sed -i '' -E "s/^DEVELOPMENT_TEAM.*/DEVELOPMENT_TEAM = $TEAM_ID/" "$XC" + ok "updated DEVELOPMENT_TEAM in Flutter/$cfg.xcconfig" + fi +done + +# ExportOptions.plist consumed by `flutter build ipa` for the export phase. +cat > "$IOSDIR/ExportOptions.plist" < + + + + method$EXPORT_METHOD + teamID$TEAM_ID + signingStyleautomatic + uploadSymbols + stripSwiftSymbols + destinationexport + + +PLIST +ok "wrote ui/ios/ExportOptions.plist (method=$EXPORT_METHOD)" + +# Resolve pods now so any CocoaPods error surfaces before the long archive. +if command -v pod >/dev/null; then + ( cd "$IOSDIR" && pod install ) + ok "pod install complete" +else + ok "CocoaPods 'pod' not on PATH — flutter will run it during the build (sudo gem install cocoapods to install)" +fi + +# ---- 6. flutter build ipa -------------------------------------------------- +say "6/6 flutter build ipa" +( cd "$UI" && "$FLUTTER" build ipa --release --export-options-plist=ios/ExportOptions.plist ) + +IPA="$(ls -t "$UI/build/ios/ipa/"*.ipa 2>/dev/null | head -1 || true)" +echo "" +echo "=== DONE ===" +echo "xcframework : $DIST/$XCFRAMEWORK (also staged in ui/ios/)" +if [ -n "$IPA" ]; then + echo "ipa : $IPA" + echo "" + echo "Upload to App Store Connect / TestFlight with:" + echo " xcrun altool --upload-app -f \"$IPA\" -t ios --apiKey --apiIssuer " + echo "or open ui/build/ios/archive/Runner.xcarchive in Xcode > Organizer > Distribute App." +else + echo "ipa : (not found under ui/build/ios/ipa — check the flutter output above)" +fi diff --git a/native/packaging/build-linux.sh b/native/packaging/build-linux.sh new file mode 100644 index 0000000..b3d523d --- /dev/null +++ b/native/packaging/build-linux.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# +# NetDesk by Akronlogic -- LINUX build + packaging script. +# +# Builds the release viewer (cargo build --release -p netdesk-viewer) for the +# x86_64-unknown-linux-gnu target, then bundles TWO Linux distributions: +# +# * AppImage (NetDesk--x86_64.AppImage) -- single-file, run anywhere, +# libav* bundled via linuxdeploy; no install needed. +# * .deb (netdesk__amd64.deb) -- dpkg package with a +# .desktop launcher + hicolor icons; apt/dpkg install. +# +# The Linux decode path (core/src/decode_vaapi.rs) hardware-decodes H.264 with +# VAAPI through FFmpeg/libavcodec, so BUILDING needs the system FFmpeg + VAAPI +# development libraries present (see docs/build-linux.md). At RUNTIME VAAPI is +# optional -- the core falls back to the openh264 software decoder when no GPU +# decoder is available. +# +# Outputs land in packaging/dist/linux/ . +# +# Usage: +# export PATH="$HOME/.cargo/bin:$PATH" +# packaging/build-linux.sh # build + AppImage + .deb +# packaging/build-linux.sh --skip-build # reuse an existing binary +# packaging/build-linux.sh --no-appimage # .deb only +# packaging/build-linux.sh --no-deb # AppImage only +# packaging/build-linux.sh --target +# +# (c) Akronlogic. All rights reserved. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# product / packaging identity +# --------------------------------------------------------------------------- +APP_NAME="NetDesk" +BIN_NAME="netdesk-viewer" # cargo [[bin]] name +APP_ID="com.akronlogic.netdesk" # == viewer ViewportBuilder::with_app_id +PKG_NAME="netdesk" # dpkg package name +TARGET="x86_64-unknown-linux-gnu" +DEB_ARCH="amd64" +MAINTAINER="Akronlogic " +HOMEPAGE="https://akronlogic.com" + +SKIP_BUILD=0 +DO_APPIMAGE=1 +DO_DEB=1 + +# --------------------------------------------------------------------------- +# tiny coloured logging helpers +# --------------------------------------------------------------------------- +say() { printf '\033[36m%s\033[0m\n' "$*"; } # cyan (headline) +step() { printf '\033[33m%s\033[0m\n' "$*"; } # yellow(step) +ok() { printf '\033[32m%s\033[0m\n' "$*"; } # green (success) +warn() { printf '\033[33m%s\033[0m\n' "WARN: $*" >&2; } +die() { printf '\033[31m%s\033[0m\n' "ERROR: $*" >&2; exit 1; } + +usage() { + sed -n '2,40p' "$0" | sed 's/^#\s\{0,1\}//' +} + +# --------------------------------------------------------------------------- +# args +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1 ;; + --no-appimage) DO_APPIMAGE=0 ;; + --no-deb) DO_DEB=0 ;; + --target) shift; [ $# -gt 0 ] || die "--target needs a value"; TARGET="$1" ;; + --target=*) TARGET="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac + shift +done + +# --------------------------------------------------------------------------- +# paths +# --------------------------------------------------------------------------- +PKG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$PKG_DIR")" +ASSETS_DIR="$ROOT_DIR/assets" +DIST_DIR="$PKG_DIR/dist/linux" +TOOLS_DIR="$PKG_DIR/dist/.tools" + +VERSION="$(grep -m1 '^version' "$ROOT_DIR/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')" +[ -n "$VERSION" ] || VERSION="0.0.0" + +BIN="$ROOT_DIR/target/$TARGET/release/$BIN_NAME" +DESKTOP_SRC="$DIST_DIR/$APP_ID.desktop" + +say "=== NetDesk Linux build (Akronlogic) ===" +echo " root : $ROOT_DIR" +echo " version : $VERSION" +echo " target : $TARGET" +echo " dist : $DIST_DIR" +echo " outputs : $([ $DO_APPIMAGE -eq 1 ] && echo -n 'AppImage ')$([ $DO_DEB -eq 1 ] && echo -n '.deb')" + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- +[ "$(uname -s)" = "Linux" ] || die "this script must run on Linux (building the x86_64-unknown-linux-gnu viewer)." + +# make sure a rustup/cargo install under ~/.cargo is reachable +case ":$PATH:" in *":$HOME/.cargo/bin:"*) : ;; *) PATH="$HOME/.cargo/bin:$PATH" ;; esac +export PATH + +if [ "$SKIP_BUILD" -eq 0 ]; then + command -v cargo >/dev/null 2>&1 || die "cargo not found on PATH. Install Rust (https://rustup.rs) and/or 'export PATH=\$HOME/.cargo/bin:\$PATH'." + + # The decode_vaapi.rs backend links FFmpeg via the ffmpeg-next crate; its + # build script (ffmpeg-sys-next) locates libav* with pkg-config and needs + # libclang (bindgen). Fail fast with a copy-pasteable install hint. + if ! command -v pkg-config >/dev/null 2>&1 || ! pkg-config --exists libavcodec 2>/dev/null; then + warn "FFmpeg development libraries (libavcodec) were not found via pkg-config." + echo " Debian/Ubuntu:" + echo " sudo apt install -y build-essential clang pkg-config nasm \\" + echo " libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \\" + echo " libavfilter-dev libavdevice-dev libswresample-dev \\" + echo " libva-dev libgl1-mesa-dev" + echo " Fedora:" + echo " sudo dnf install -y gcc gcc-c++ clang pkgconf-pkg-config nasm \\" + echo " ffmpeg-devel libva-devel mesa-libGL-devel" + die "install the build dependencies above, then re-run (see docs/build-linux.md)." + fi + command -v clang >/dev/null 2>&1 || warn "clang/libclang not found -- ffmpeg-sys-next bindgen will fail. Install 'clang'." +fi + +mkdir -p "$DIST_DIR" "$TOOLS_DIR" + +# --------------------------------------------------------------------------- +# 1. build the release viewer +# --------------------------------------------------------------------------- +if [ "$SKIP_BUILD" -eq 0 ]; then + step "[1] cargo build --release -p $BIN_NAME --target $TARGET" + # add the target when rustup is in use; a no-op / harmless on a native host. + command -v rustup >/dev/null 2>&1 && rustup target add "$TARGET" >/dev/null 2>&1 || true + ( cd "$ROOT_DIR" && cargo build --release -p "$BIN_NAME" --target "$TARGET" ) +else + step "[1] --skip-build: reusing existing binary" +fi +[ -x "$BIN" ] || die "viewer binary not found: $BIN (remove --skip-build to build it)." +echo " binary : $BIN ($(du -h "$BIN" | cut -f1))" + +# --------------------------------------------------------------------------- +# 2. shared .desktop launcher (used by BOTH the AppImage and the .deb) +# --------------------------------------------------------------------------- +# The .desktop filename and the Icon= key both use the app-id so GNOME/KDE +# associate the running window (winit sets WM_CLASS / Wayland app_id from +# with_app_id("com.akronlogic.netdesk")) with this launcher + its icon. +step "[2] writing $APP_ID.desktop" +cat > "$DESKTOP_SRC" <<'DESKTOP' +[Desktop Entry] +Type=Application +Version=1.0 +Name=NetDesk +GenericName=Remote Desktop +Comment=Secure remote desktop viewer by Akronlogic +Exec=netdesk-viewer +TryExec=netdesk-viewer +Icon=com.akronlogic.netdesk +Terminal=false +Categories=Network;RemoteAccess; +Keywords=remote;desktop;screen;share;vnc;rdp;netdesk; +StartupNotify=true +StartupWMClass=com.akronlogic.netdesk +DESKTOP + +ARTIFACTS=() + +# =========================================================================== +# 3. .deb (built first -- needs no network and no FUSE) +# =========================================================================== +if [ "$DO_DEB" -eq 1 ]; then + step "[3] building .deb" + command -v dpkg-deb >/dev/null 2>&1 || die "dpkg-deb not found (install the 'dpkg' package) -- cannot build the .deb. Use --no-deb to skip." + + DEB_ROOT="$DIST_DIR/deb" + rm -rf "$DEB_ROOT" + + # payload ---------------------------------------------------------------- + install -Dm755 "$BIN" "$DEB_ROOT/usr/bin/$BIN_NAME" + ln -sf "$BIN_NAME" "$DEB_ROOT/usr/bin/netdesk" # convenience alias + install -Dm644 "$DESKTOP_SRC" "$DEB_ROOT/usr/share/applications/$APP_ID.desktop" + install -Dm644 "$ASSETS_DIR/icon-512.png" "$DEB_ROOT/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/icon.png" "$DEB_ROOT/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/logo.svg" "$DEB_ROOT/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" + + # copyright -------------------------------------------------------------- + install -d "$DEB_ROOT/usr/share/doc/$PKG_NAME" + cat > "$DEB_ROOT/usr/share/doc/$PKG_NAME/copyright" <<'COPYRIGHT' +NetDesk -- native remote desktop viewer. +Developed by Akronlogic (https://akronlogic.com). + +Copyright (c) Akronlogic. All rights reserved. +License: Proprietary. Full IP ownership, no AGPL. +COPYRIGHT + + # Depends ----------------------------------------------------------------- + # Curated runtime dependency set. NOTE: libGL, libva, libX11/xcb, Wayland and + # libxkbcommon are dlopen()ed at runtime (by glutin/winit and by libavcodec's + # VAAPI backend), so ldd/dpkg-shlibdeps would MISS them -- they are listed + # explicitly here. FFmpeg sonames differ per release, so each libav* uses + # alternatives ending in the generic 'ffmpeg' package as a catch-all. + BASE_DEPS="libc6, libgcc-s1, libstdc++6" + FFMPEG_DEPS="libavcodec60 | libavcodec59 | libavcodec58 | ffmpeg, libavutil58 | libavutil57 | libavutil56 | ffmpeg, libswscale7 | libswscale6 | libswscale5 | ffmpeg" + DLOPEN_DEPS="libgl1, libva2, libva-drm2, libva-x11-2, libxkbcommon0, libx11-6, libxcb1" + DEPENDS="$BASE_DEPS, $FFMPEG_DEPS, $DLOPEN_DEPS" + + INSTALLED_KB="$(du -sk "$DEB_ROOT" | cut -f1)" + + install -d "$DEB_ROOT/DEBIAN" + cat > "$DEB_ROOT/DEBIAN/control" < "$DEB_ROOT/DEBIAN/postinst" <<'POSTINST' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications 2>/dev/null || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor 2>/dev/null || true +fi +exit 0 +POSTINST + + cat > "$DEB_ROOT/DEBIAN/postrm" <<'POSTRM' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications 2>/dev/null || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor 2>/dev/null || true +fi +exit 0 +POSTRM + chmod 0755 "$DEB_ROOT/DEBIAN/postinst" "$DEB_ROOT/DEBIAN/postrm" + + OUT_DEB="$DIST_DIR/${PKG_NAME}_${VERSION}_${DEB_ARCH}.deb" + rm -f "$OUT_DEB" + # --root-owner-group => root:root ownership with no fakeroot (dpkg >= 1.19). + if ! dpkg-deb --build --root-owner-group "$DEB_ROOT" "$OUT_DEB" 2>/dev/null; then + warn "dpkg-deb --root-owner-group unavailable; building without it (files owned by the current uid)." + dpkg-deb --build "$DEB_ROOT" "$OUT_DEB" + fi + [ -f "$OUT_DEB" ] || die "dpkg-deb reported success but $OUT_DEB is missing." + ARTIFACTS+=("$OUT_DEB") + ok " .deb : $OUT_DEB ($(du -h "$OUT_DEB" | cut -f1))" +fi + +# =========================================================================== +# 4. AppImage (via linuxdeploy; bundles libav* etc.) +# =========================================================================== +if [ "$DO_APPIMAGE" -eq 1 ]; then + step "[4] building AppImage" + + # 4a. obtain linuxdeploy (cache under dist/.tools, or set LINUXDEPLOY=/path) + LINUXDEPLOY="${LINUXDEPLOY:-$TOOLS_DIR/linuxdeploy-x86_64.AppImage}" + if [ ! -x "$LINUXDEPLOY" ]; then + url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" + echo " fetching linuxdeploy -> $LINUXDEPLOY" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 -o "$LINUXDEPLOY" "$url" || die "failed to download linuxdeploy (set LINUXDEPLOY=/path to a local copy, or --no-appimage)." + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$LINUXDEPLOY" "$url" || die "failed to download linuxdeploy (set LINUXDEPLOY=/path to a local copy, or --no-appimage)." + else + die "need curl or wget to download linuxdeploy. Install one, set LINUXDEPLOY=/path, or pass --no-appimage." + fi + chmod +x "$LINUXDEPLOY" + fi + # run AppImage tools without FUSE (CI / containers / minimal hosts). + export APPIMAGE_EXTRACT_AND_RUN=1 + + # 4b. assemble the AppDir + APPDIR="$DIST_DIR/AppDir" + rm -rf "$APPDIR" + install -Dm755 "$BIN" "$APPDIR/usr/bin/$BIN_NAME" + install -Dm644 "$DESKTOP_SRC" "$APPDIR/usr/share/applications/$APP_ID.desktop" + install -Dm644 "$ASSETS_DIR/icon-512.png" "$APPDIR/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/icon.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" + install -Dm644 "$ASSETS_DIR/logo.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" + # top-level icon + .DirIcon + desktop that appimagetool expects at the root + cp "$ASSETS_DIR/icon-512.png" "$APPDIR/$APP_ID.png" + cp "$DESKTOP_SRC" "$APPDIR/$APP_ID.desktop" + ( cd "$APPDIR" && ln -sf "$APP_ID.png" .DirIcon ) + + # 4c. run linuxdeploy: it ldd-scans usr/bin, copies the app's shared libs + # (libav*, etc.) into the AppDir, writes AppRun, and emits the AppImage. + # libGL / libX11 / Wayland / libva are intentionally NOT bundled (they are + # dlopen()ed and must come from the host graphics stack). + OUT_APPIMAGE="$DIST_DIR/${APP_NAME}-${VERSION}-x86_64.AppImage" + rm -f "$OUT_APPIMAGE" + if ( cd "$DIST_DIR" && OUTPUT="$(basename "$OUT_APPIMAGE")" VERSION="$VERSION" \ + "$LINUXDEPLOY" \ + --appdir "$APPDIR" \ + -e "$APPDIR/usr/bin/$BIN_NAME" \ + -d "$APPDIR/usr/share/applications/$APP_ID.desktop" \ + -i "$APPDIR/usr/share/icons/hicolor/512x512/apps/$APP_ID.png" \ + --icon-filename "$APP_ID" \ + --output appimage ) && [ -f "$OUT_APPIMAGE" ]; then + chmod +x "$OUT_APPIMAGE" + ARTIFACTS+=("$OUT_APPIMAGE") + ok " AppImage: $OUT_APPIMAGE ($(du -h "$OUT_APPIMAGE" | cut -f1))" + else + warn "linuxdeploy did not produce an AppImage (network / FUSE / tool error). The .deb above is unaffected." + fi +fi + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- +echo +if [ "${#ARTIFACTS[@]}" -gt 0 ]; then + ok "Linux build complete. Artifacts:" + for a in "${ARTIFACTS[@]}"; do echo " $a ($(du -h "$a" | cut -f1))"; done +else + die "no artifacts were produced." +fi diff --git a/native/packaging/build-macos.sh b/native/packaging/build-macos.sh new file mode 100644 index 0000000..ccd5628 --- /dev/null +++ b/native/packaging/build-macos.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# NetDesk by Akronlogic -- macOS DESKTOP build script. +# +# Builds the SAME egui viewer/host-panel binary the Windows editions ship +# (crate `netdesk-viewer`) for macOS, as a signed + notarized universal app: +# +# 1. rustup: add the aarch64-apple-darwin + x86_64-apple-darwin std targets. +# 2. cargo build --release -p netdesk-viewer for BOTH targets. +# 3. lipo the two binaries into one universal (arm64 + x86_64) executable. +# 4. iconutil: turn assets/icon*.png into NetDesk.icns. +# 5. Assemble NetDesk.app (Contents/MacOS + Info.plist + Resources/.icns). +# 6. codesign with a "Developer ID Application" identity (hardened runtime). +# 7. notarize the .app via `xcrun notarytool submit --wait`, then staple it. +# 8. Build a .dmg from the stapled .app, sign + notarize + staple the .dmg. +# +# On macOS the H.264 stream is decoded on the Apple video engine through the +# VideoToolbox path (core/src/decode_vt.rs, VTDecompressionSession); it falls +# back to the portable openh264 software decoder if the hardware path can't +# initialize. Nothing here is Mac-specific beyond packaging -- the viewer code +# is identical to Windows. See docs/build-macos.md. +# +# THIS SCRIPT RUNS ON macOS (the user's M4 Mac), NOT on the Windows dev box. +# All outputs land in packaging/dist/. +# +# Usage (from the repo root or anywhere): +# bash packaging/build-macos.sh --notary-profile netdesk +# bash packaging/build-macos.sh --skip-build # reuse existing targets +# bash packaging/build-macos.sh --no-sign # unsigned .app + .dmg +# bash packaging/build-macos.sh --no-notarize # sign but don't notarize +# bash packaging/build-macos.sh --identity "Developer ID Application: Akronlogic (TEAMID)" \ +# --apple-id you@akronlogic.com --team-id TEAMID +# +# Notarization credentials (pick ONE): +# --notary-profile a keychain profile created once with: +# xcrun notarytool store-credentials --apple-id --team-id --password +# --apple-id --team-id + env NETDESK_NOTARY_PASSWORD= +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# defaults / args +# --------------------------------------------------------------------------- +BUNDLE_ID="com.akronlogic.netdesk" +APP_NAME="NetDesk" +MIN_MACOS="11.0" # Big Sur: oldest OS the universal app targets +CATEGORY="public.app-category.utilities" +COPYRIGHT="© Akronlogic. All rights reserved." + +IDENTITY="" # "Developer ID Application: ... (TEAMID)" (empty = auto-detect) +APPLE_ID="" +TEAM_ID="" +NOTARY_PROFILE="" +ENTITLEMENTS="" # optional path to an entitlements .plist +ICON_SRC="" # optional path to a master PNG (>= 512px, ideally 1024) +VERSION="" # override the Cargo workspace version + +SKIP_BUILD=0 +NO_SIGN=0 +NO_NOTARIZE=0 +STAPLE_APP=1 # also notarize+staple the standalone .app (not just the .dmg) + +while [ $# -gt 0 ]; do + case "$1" in + --identity) IDENTITY="$2"; shift 2 ;; + --apple-id) APPLE_ID="$2"; shift 2 ;; + --team-id) TEAM_ID="$2"; shift 2 ;; + --notary-profile) NOTARY_PROFILE="$2"; shift 2 ;; + --entitlements) ENTITLEMENTS="$2"; shift 2 ;; + --icon) ICON_SRC="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + --no-sign) NO_SIGN=1; shift ;; + --no-notarize) NO_NOTARIZE=1; shift ;; + --no-staple-app) STAPLE_APP=0; shift ;; + -h|--help) + sed -n '2,46p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +# --------------------------------------------------------------------------- +# pretty output +# --------------------------------------------------------------------------- +if [ -t 1 ]; then + C_CYAN='\033[36m'; C_YEL='\033[33m'; C_GRN='\033[32m'; C_RED='\033[31m'; C_DIM='\033[2m'; C_OFF='\033[0m' +else + C_CYAN=''; C_YEL=''; C_GRN=''; C_RED=''; C_DIM=''; C_OFF='' +fi +say() { printf "%b%s%b\n" "$C_CYAN" "$*" "$C_OFF"; } +step() { printf "\n%b%s%b\n" "$C_YEL" "$*" "$C_OFF"; } +ok() { printf "%b%s%b\n" "$C_GRN" "$*" "$C_OFF"; } +dim() { printf "%b %s%b\n" "$C_DIM" "$*" "$C_OFF"; } +warn() { printf "%b%s%b\n" "$C_RED" "$*" "$C_OFF"; } +die() { printf "%b%s%b\n" "$C_RED" "$*" "$C_OFF" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# paths +# --------------------------------------------------------------------------- +PKG_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$PKG_DIR/.." && pwd)" +DIST_DIR="$PKG_DIR/dist" +ASSETS_DIR="$ROOT_DIR/assets" + +APP_DIR="$DIST_DIR/$APP_NAME.app" +MACOS_DIR="$APP_DIR/Contents/MacOS" +RES_DIR="$APP_DIR/Contents/Resources" +PLIST="$APP_DIR/Contents/Info.plist" +EXE_OUT="$MACOS_DIR/$APP_NAME" + +TARGET_ARM="aarch64-apple-darwin" +TARGET_X64="x86_64-apple-darwin" +BIN_ARM="$ROOT_DIR/target/$TARGET_ARM/release/netdesk-viewer" +BIN_X64="$ROOT_DIR/target/$TARGET_X64/release/netdesk-viewer" + +DMG_STAGE="$DIST_DIR/dmg-stage" +DMG_OUT="$DIST_DIR/$APP_NAME-macOS-universal.dmg" +APP_ZIP="$DIST_DIR/$APP_NAME-macOS-universal.zip" + +say "=== NetDesk macOS build (Akronlogic) ===" +dim "root : $ROOT_DIR" +dim "dist : $DIST_DIR" + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- +[ "$(uname -s)" = "Darwin" ] || die "This script must run on macOS (uname is '$(uname -s)'). Build on the Mac, not the Windows box." + +need() { command -v "$1" >/dev/null 2>&1 || die "required tool not found on PATH: $1"; } +need cargo +need rustup +need lipo +need iconutil +need sips +need hdiutil +need codesign +need xcrun + +xcode-select -p >/dev/null 2>&1 || die "Xcode command-line tools missing. Run: xcode-select --install" + +# version from the Cargo workspace unless overridden +if [ -z "$VERSION" ]; then + VERSION="$(awk -F'"' '/^\[workspace.package\]/{f=1} f&&/^version[[:space:]]*=/{print $2; exit}' "$ROOT_DIR/Cargo.toml")" + [ -n "$VERSION" ] || VERSION="0.0.0" +fi +dim "version : $VERSION" +dim "bundle : $BUNDLE_ID" + +mkdir -p "$DIST_DIR" + +# --------------------------------------------------------------------------- +# 1 + 2. rust targets + release builds (both arches) +# --------------------------------------------------------------------------- +if [ "$SKIP_BUILD" -eq 0 ]; then + step "[1/8] rustup target add ($TARGET_ARM, $TARGET_X64)" + rustup target add "$TARGET_ARM" "$TARGET_X64" + + step "[2/8] cargo build --release -p netdesk-viewer (both arches)" + ( cd "$ROOT_DIR" && cargo build --release -p netdesk-viewer --target "$TARGET_ARM" ) + ( cd "$ROOT_DIR" && cargo build --release -p netdesk-viewer --target "$TARGET_X64" ) +else + step "[1-2/8] --skip-build: reusing existing per-target binaries" +fi +[ -f "$BIN_ARM" ] || die "missing arm64 binary: $BIN_ARM" +[ -f "$BIN_X64" ] || die "missing x86_64 binary: $BIN_X64" + +# --------------------------------------------------------------------------- +# 3. lipo -> universal binary +# --------------------------------------------------------------------------- +step "[3/8] lipo -> universal (arm64 + x86_64)" +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" "$RES_DIR" +lipo -create -output "$EXE_OUT" "$BIN_ARM" "$BIN_X64" +chmod +x "$EXE_OUT" +dim "$(lipo -info "$EXE_OUT")" + +# --------------------------------------------------------------------------- +# 4. icns via iconutil +# --------------------------------------------------------------------------- +step "[4/8] iconutil -> $APP_NAME.icns" +# Prefer the largest master we have: an explicit --icon, else a 1024 PNG if it +# exists, else the 512, else the 256 (assets/icon.png). iconutil needs a full +# .iconset of fixed-named PNGs; sips resizes the master down into each slot. +if [ -z "$ICON_SRC" ]; then + for c in "$ASSETS_DIR/icon-1024.png" "$ASSETS_DIR/icon-512.png" "$ASSETS_DIR/icon.png"; do + if [ -f "$c" ]; then ICON_SRC="$c"; break; fi + done +fi +[ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ] || die "no icon PNG found (looked for assets/icon-1024.png, icon-512.png, icon.png; or pass --icon)" +dim "icon master: $ICON_SRC" + +ICONSET="$DIST_DIR/$APP_NAME.iconset" +rm -rf "$ICONSET"; mkdir -p "$ICONSET" +# name px +gen() { sips -z "$2" "$2" "$ICON_SRC" --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16 +gen icon_16x16@2x.png 32 +gen icon_32x32.png 32 +gen icon_32x32@2x.png 64 +gen icon_128x128.png 128 +gen icon_128x128@2x.png 256 +gen icon_256x256.png 256 +gen icon_256x256@2x.png 512 +gen icon_512x512.png 512 +gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o "$RES_DIR/$APP_NAME.icns" +rm -rf "$ICONSET" +dim "wrote $RES_DIR/$APP_NAME.icns" + +# --------------------------------------------------------------------------- +# 5. Info.plist + PkgInfo (assemble the bundle) +# --------------------------------------------------------------------------- +step "[5/8] assemble $APP_NAME.app" +cat > "$PLIST" < + + + + CFBundleName $APP_NAME + CFBundleDisplayName $APP_NAME + CFBundleIdentifier $BUNDLE_ID + CFBundleExecutable $APP_NAME + CFBundleIconFile $APP_NAME + CFBundleVersion $VERSION + CFBundleShortVersionString $VERSION + CFBundlePackageType APPL + CFBundleSignature ???? + CFBundleInfoDictionaryVersion 6.0 + LSMinimumSystemVersion $MIN_MACOS + LSApplicationCategoryType $CATEGORY + NSHighResolutionCapable + NSHumanReadableCopyright $COPYRIGHT + NSSupportsAutomaticGraphicsSwitching + + +PLIST_EOF + +printf 'APPL????' > "$APP_DIR/Contents/PkgInfo" +ok "bundle staged: $APP_DIR" + +# --------------------------------------------------------------------------- +# resolve signing identity +# --------------------------------------------------------------------------- +if [ "$NO_SIGN" -eq 0 ] && [ -z "$IDENTITY" ]; then + # auto-detect a single "Developer ID Application" identity from the keychain + IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n1 || true)" +fi + +CAN_SIGN=0 +if [ "$NO_SIGN" -eq 0 ] && [ -n "$IDENTITY" ]; then CAN_SIGN=1; fi + +# --------------------------------------------------------------------------- +# 6. codesign (hardened runtime + secure timestamp) +# --------------------------------------------------------------------------- +if [ "$CAN_SIGN" -eq 1 ]; then + step "[6/8] codesign ($IDENTITY)" + CS_ARGS="--force --timestamp --options runtime --sign $IDENTITY" + if [ -n "$ENTITLEMENTS" ]; then + [ -f "$ENTITLEMENTS" ] || die "entitlements file not found: $ENTITLEMENTS" + dim "entitlements: $ENTITLEMENTS" + fi + # single-binary bundle (no nested frameworks/dylibs): sign the executable, + # then the bundle. --options runtime is required for notarization. + if [ -n "$ENTITLEMENTS" ]; then + codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$EXE_OUT" + codesign --force --timestamp --options runtime --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$APP_DIR" + else + codesign --force --timestamp --options runtime --sign "$IDENTITY" "$EXE_OUT" + codesign --force --timestamp --options runtime --sign "$IDENTITY" "$APP_DIR" + fi + codesign --verify --deep --strict --verbose=2 "$APP_DIR" + dim "signed + verified" +else + step "[6/8] codesign -- SKIPPED" + if [ "$NO_SIGN" -eq 1 ]; then + warn " --no-sign set: the .app and .dmg are UNSIGNED (Gatekeeper will block them on other Macs)." + else + warn " No \"Developer ID Application\" identity found in the keychain -- producing an UNSIGNED bundle." + warn " Install your Developer ID Application cert, or pass --identity \"Developer ID Application: NAME (TEAMID)\"." + fi +fi + +# --------------------------------------------------------------------------- +# notarization helper +# --------------------------------------------------------------------------- +notary_auth_ok=0 +notary_args="" +if [ -n "$NOTARY_PROFILE" ]; then + notary_args="--keychain-profile $NOTARY_PROFILE"; notary_auth_ok=1 +elif [ -n "$APPLE_ID" ] && [ -n "$TEAM_ID" ] && [ -n "${NETDESK_NOTARY_PASSWORD:-}" ]; then + notary_args="--apple-id $APPLE_ID --team-id $TEAM_ID --password $NETDESK_NOTARY_PASSWORD"; notary_auth_ok=1 +fi + +CAN_NOTARIZE=0 +if [ "$CAN_SIGN" -eq 1 ] && [ "$NO_NOTARIZE" -eq 0 ] && [ "$notary_auth_ok" -eq 1 ]; then CAN_NOTARIZE=1; fi + +notarize() { # $1 = path to .zip/.dmg to submit + # shellcheck disable=SC2086 + xcrun notarytool submit "$1" $notary_args --wait +} + +# --------------------------------------------------------------------------- +# 7. notarize + staple the standalone .app (via a zip; stapling needs the app) +# --------------------------------------------------------------------------- +if [ "$CAN_NOTARIZE" -eq 1 ] && [ "$STAPLE_APP" -eq 1 ]; then + step "[7/8] notarize + staple $APP_NAME.app" + rm -f "$APP_ZIP" + /usr/bin/ditto -c -k --keepParent "$APP_DIR" "$APP_ZIP" + notarize "$APP_ZIP" + xcrun stapler staple "$APP_DIR" + xcrun stapler validate "$APP_DIR" + dim "stapled $APP_NAME.app" +else + step "[7/8] notarize + staple .app -- SKIPPED" + if [ "$CAN_SIGN" -eq 0 ]; then + dim "(unsigned bundle cannot be notarized)" + elif [ "$NO_NOTARIZE" -eq 1 ]; then + dim "(--no-notarize set)" + elif [ "$notary_auth_ok" -eq 0 ]; then + warn " No notarization credentials. Pass --notary-profile , or" + warn " --apple-id --team-id with env NETDESK_NOTARY_PASSWORD=." + warn " Create a profile once with: xcrun notarytool store-credentials --apple-id --team-id --password " + fi +fi + +# --------------------------------------------------------------------------- +# 8. build the .dmg, then sign + notarize + staple it +# --------------------------------------------------------------------------- +step "[8/8] build $APP_NAME-macOS-universal.dmg" +rm -rf "$DMG_STAGE"; mkdir -p "$DMG_STAGE" +/usr/bin/ditto "$APP_DIR" "$DMG_STAGE/$APP_NAME.app" +ln -s /Applications "$DMG_STAGE/Applications" +rm -f "$DMG_OUT" +hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGE" -fs HFS+ -format UDZO -ov "$DMG_OUT" >/dev/null +rm -rf "$DMG_STAGE" +dim "wrote $DMG_OUT" + +if [ "$CAN_SIGN" -eq 1 ]; then + codesign --force --timestamp --sign "$IDENTITY" "$DMG_OUT" + dim "signed the .dmg" +fi +if [ "$CAN_NOTARIZE" -eq 1 ]; then + dim "notarizing the .dmg ..." + notarize "$DMG_OUT" + xcrun stapler staple "$DMG_OUT" + xcrun stapler validate "$DMG_OUT" + dim "stapled the .dmg" +fi + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- +printf "\n" +ok "macOS build complete." +dim "app : $APP_DIR" +[ -f "$DMG_OUT" ] && dim "dmg : $DMG_OUT ($(du -h "$DMG_OUT" | cut -f1))" +if [ "$CAN_SIGN" -eq 0 ]; then + warn "NOTE: artifacts are UNSIGNED -- for distribution, re-run on a Mac with a Developer ID Application cert." +elif [ "$CAN_NOTARIZE" -eq 0 ]; then + warn "NOTE: signed but NOT notarized -- re-run with notarization credentials (see --help) before distributing." +else + ok "Signed, notarized, and stapled -- ready to distribute." +fi diff --git a/native/packaging/build-msi.ps1 b/native/packaging/build-msi.ps1 new file mode 100644 index 0000000..2da3e51 --- /dev/null +++ b/native/packaging/build-msi.ps1 @@ -0,0 +1,73 @@ +<# + NetDesk by Akronlogic -- MSI build script (WiX v4). + + Produces dist\NetDesk.msi for enterprise / unattended deployment (GPO / SCCM / + Intune). The MSI installs the portable payload and runs `NetDesk.exe --config` + to write config.json non-interactively from the deployment properties: + + msiexec /i NetDesk.msi /qn SERVER="wss://relay.corp/ws" REGKEY="CORP1" ` + PASSWORD="Sup3rSecret!" HOSTID="123456789" + + Prerequisite (one time) -- the WiX toolset, which needs the .NET SDK: + winget install Microsoft.DotNet.SDK.8 # or any current .NET SDK + dotnet tool install --global wix + + NOTE: this box has only the .NET RUNTIME (no SDK), so WiX cannot be installed + here and the MSI has not been built/verified in this environment. The + unattended `--config` injection the MSI drives IS proven (viewer + config_cli_tests + a live `NetDesk.exe --config` run). Run this on a build box + that has WiX. + + Usage: + powershell -ExecutionPolicy Bypass -File build-msi.ps1 + powershell -ExecutionPolicy Bypass -File build-msi.ps1 -SkipBuild # reuse staged payload +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [switch]$SkipBuild, + [switch]$NoSign, + [string]$Thumbprint = "YOUR_CODE_SIGNING_CERT_THUMBPRINT" +) +$ErrorActionPreference = "Stop" + +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$Wxs = Join-Path $PkgDir "netdesk.wxs" +$MsiOut = Join-Path $DistDir "NetDesk.msi" +$SignPs1 = Join-Path $RootDir "certs\sign-release.ps1" + +Write-Host "=== NetDesk MSI build (Akronlogic) ===" -ForegroundColor Cyan + +# 1. stage the signed portable payload (NetDesk.exe + runtime DLLs). +$portableArgs = @{ MingwBin = $MingwBin } +if ($SkipBuild) { $portableArgs.SkipBuild = $true } +& (Join-Path $PkgDir "build-portable.ps1") @portableArgs +if (-not (Test-Path (Join-Path $StageDir "NetDesk.exe"))) { throw "payload missing: $StageDir" } + +# 2. locate WiX. +$wix = Get-Command wix -ErrorAction SilentlyContinue +if (-not $wix) { + throw "WiX toolset not found. Install it (needs the .NET SDK): dotnet tool install --global wix" +} + +# 3. build the MSI, passing the payload dir as the $(Payload) preprocessor var. +New-Item -ItemType Directory -Force -Path $DistDir | Out-Null +& $wix.Source build $Wxs -d "Payload=$StageDir" -arch x64 -o $MsiOut +if ($LASTEXITCODE) { throw "wix build failed (exit $LASTEXITCODE)" } +Write-Host " built: $MsiOut" + +# 4. sign the MSI with the same cert as the portable / installer. +if (-not $NoSign -and (Test-Path $SignPs1)) { + $signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($signtool) { + & $signtool.Source sign /sha1 $Thumbprint /fd SHA256 /tr "http://timestamp.sectigo.com" /td SHA256 "$MsiOut" + if ($LASTEXITCODE) { Write-Host " signtool returned $LASTEXITCODE (continuing)" -ForegroundColor DarkYellow } + else { Write-Host " signed: $MsiOut" -ForegroundColor Green } + } +} + +Write-Host "`nMSI build complete -> $MsiOut" -ForegroundColor Green +Write-Host "Deploy: msiexec /i NetDesk.msi /qn SERVER=... REGKEY=... PASSWORD=... HOSTID=..." diff --git a/native/packaging/build-portable.ps1 b/native/packaging/build-portable.ps1 new file mode 100644 index 0000000..c4e8e2d --- /dev/null +++ b/native/packaging/build-portable.ps1 @@ -0,0 +1,129 @@ +<# + NetDesk by Akronlogic -- PORTABLE build + staging script. + + Builds the release viewer (cargo build --release -p netdesk-viewer) with the + MinGW GNU toolchain on PATH, then assembles a self-contained portable folder + (NetDesk.exe + NetDeskHost.exe host engine + the 3 MinGW runtime DLLs + + README) and zips it. No install needed -- the user unzips it anywhere and + runs NetDesk.exe from the folder. One app = full host + remote-control viewer. + + Outputs land in packaging\dist\ : + NetDesk-Portable\ (staging folder, also consumed by the installer) + NetDesk-Portable-win64.zip (the portable artifact) + + Usage: + powershell -ExecutionPolicy Bypass -File build-portable.ps1 + powershell -ExecutionPolicy Bypass -File build-portable.ps1 -SkipBuild # reuse existing exe +#> +[CmdletBinding()] +param( + [string]$MingwBin = "C:\mingw-netdesk\mingw64\bin", + [string]$CargoBin = (Join-Path $env:USERPROFILE ".cargo\bin"), + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" + +# ---- paths ----------------------------------------------------------------- +$PkgDir = $PSScriptRoot +$RootDir = Split-Path -Parent $PkgDir +$DistDir = Join-Path $PkgDir "dist" +$StageDir = Join-Path $DistDir "NetDesk-Portable" +$ZipPath = Join-Path $DistDir "NetDesk-Portable-win64.zip" +$ExeSrc = Join-Path $RootDir "target\x86_64-pc-windows-gnu\release\netdesk-viewer.exe" + +$Dlls = @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll") + +Write-Host "=== NetDesk portable build (Akronlogic) ===" -ForegroundColor Cyan +Write-Host "root : $RootDir" +Write-Host "dist : $DistDir" + +# ---- 1. build -------------------------------------------------------------- +if (-not $SkipBuild) { + if (-not (Test-Path (Join-Path $MingwBin "gcc.exe"))) { + throw "MinGW toolchain not found at '$MingwBin' (no gcc.exe). Pass -MingwBin or install it." + } + $env:PATH = "$MingwBin;$CargoBin;$env:PATH" + Write-Host "`n[1/4] cargo build --release -p netdesk-viewer" -ForegroundColor Yellow + Push-Location $RootDir + try { + & cargo build --release -p netdesk-viewer --target x86_64-pc-windows-gnu + if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)" } + } finally { + Pop-Location + } +} else { + Write-Host "`n[1/4] -SkipBuild set: reusing existing binary" -ForegroundColor Yellow +} + +if (-not (Test-Path $ExeSrc)) { throw "viewer binary not found: $ExeSrc" } + +# ---- 2. assemble staging folder ------------------------------------------- +Write-Host "[2/4] assembling portable folder: $StageDir" -ForegroundColor Yellow +if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force } +New-Item -ItemType Directory -Path $StageDir -Force | Out-Null + +# viewer renamed to the product name +Copy-Item $ExeSrc (Join-Path $StageDir "NetDesk.exe") -Force + +# the 3 MinGW runtime DLLs beside the exe +foreach ($d in $Dlls) { + $src = Join-Path $MingwBin $d + if (-not (Test-Path $src)) { throw "missing runtime DLL: $src" } + Copy-Item $src (Join-Path $StageDir $d) -Force +} + +# NOTE: NetDesk is now ONE pure-Rust binary. Start Service runs the native host +# engine (DXGI capture + hardware H.264 + SendInput) IN-PROCESS, so there is no +# separate NetDeskHost.exe to bundle any more — NetDesk.exe + the 3 runtime DLLs +# are the whole app (host panel + remote-control viewer). + +# ---- 3. README.txt --------------------------------------------------------- +# Built from an array of lines (no here-string: Windows PowerShell 5.1 +# mis-parses here-strings in LF-only script files). Joined with CRLF and +# written BOM-less so Notepad and every editor render it cleanly. +Write-Host "[3/4] writing README.txt" -ForegroundColor Yellow +$readmeLines = @( + 'NetDesk -- Portable Edition (Full Client)', + 'Developed by Akronlogic (https://akronlogic.com)', + '', + 'NetDesk is ONE unified app. It is both the HOST (let someone control this PC)', + 'and the remote-control VIEWER (control another PC) -- a full client, like', + 'TeamViewer. Open it, and the home screen is your host panel; enter a Partner', + 'ID to connect out and it becomes the viewer.', + '', + 'HOW TO RUN', + ' 1. Unzip this folder anywhere you like (Desktop, a USB stick, etc.).', + ' 2. Double-click NetDesk.exe.', + ' There is nothing to install and no admin rights are required. Keep every', + ' file in this folder together -- NetDesk.exe needs the three *.dll files', + ' beside it to run.', + '', + 'WHAT''S IN THIS FOLDER', + ' NetDesk.exe The NetDesk full client (host engine + viewer, all in one).', + ' libstdc++-6.dll MinGW C++ runtime (required).', + ' libgcc_s_seh-1.dll MinGW GCC runtime (required).', + ' libwinpthread-1.dll MinGW threading runtime (required).', + ' README.txt This file.', + '', + 'NOTES', + ' * Connects to the NetDesk server (netdesk.space) using the existing protocol.', + ' * To remove NetDesk, just delete this folder. Nothing is written elsewhere.', + ' * Prefer a Start Menu entry / Desktop shortcut + uninstaller? Use the', + ' installer edition (NetDesk-Setup-win64.exe) instead.', + '', + '(c) Akronlogic. All rights reserved.' +) +$readme = ($readmeLines -join "`r`n") + "`r`n" +[System.IO.File]::WriteAllText((Join-Path $StageDir "README.txt"), $readme, (New-Object System.Text.UTF8Encoding($false))) + +# ---- 4. zip ---------------------------------------------------------------- +Write-Host "[4/4] zipping -> $ZipPath" -ForegroundColor Yellow +if (Test-Path $ZipPath) { Remove-Item $ZipPath -Force } +Compress-Archive -Path (Join-Path $StageDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal + +$zi = Get-Item $ZipPath +Write-Host "`nPortable build complete." -ForegroundColor Green +Write-Host (" zip : {0}" -f $zi.FullName) +Write-Host (" size : {0:N0} bytes ({1:N2} MB)" -f $zi.Length, ($zi.Length/1MB)) +Write-Host (" stage : {0}" -f $StageDir) diff --git a/native/packaging/ios/Podfile b/native/packaging/ios/Podfile new file mode 100644 index 0000000..5932d61 --- /dev/null +++ b/native/packaging/ios/Podfile @@ -0,0 +1,60 @@ +# NetDesk by Akronlogic — iOS Podfile TEMPLATE. +# +# packaging/build-ios.sh installs this as ui/ios/Podfile when `flutter create` +# has not already produced one. It is the stock Flutter iOS Podfile with two +# NetDesk changes: the platform min is uncommented (13.0, to match +# NetDeskCore.xcframework) and the netdesk_core pod is linked into Runner. +# +# If a Podfile already exists, build-ios.sh leaves it in place and only injects +# the `pod 'netdesk_core'` line (and uncomments the platform) rather than +# overwriting it. + +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # NetDesk Rust core — vendored static xcframework assembled by + # packaging/build-ios.sh. Provides VideoToolbox H.264 decode, E2E crypto, + # signaling and transport; the Flutter UI binds to it in Phase 3. + pod 'netdesk_core', :path => '.' + + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/native/packaging/ios/netdesk_core.podspec b/native/packaging/ios/netdesk_core.podspec new file mode 100644 index 0000000..8a4ad48 --- /dev/null +++ b/native/packaging/ios/netdesk_core.podspec @@ -0,0 +1,65 @@ +# +# NetDesk by Akronlogic — CocoaPods spec that links the compiled Rust core +# (NetDeskCore.xcframework) into the Flutter iOS Runner. +# +# This file is a TEMPLATE kept under version control in packaging/ios/. +# packaging/build-ios.sh copies it to ui/ios/netdesk_core.podspec next to the +# freshly-assembled NetDeskCore.xcframework, then adds +# pod 'netdesk_core', :path => '.' +# to ui/ios/Podfile so `flutter build ipa` links it automatically. +# +# The xcframework is a *static* library (libnetdesk_core.a, one slice per +# platform: ios-arm64 device + ios-arm64-simulator). CocoaPods auto-selects the +# right slice per build, so there are no hardcoded slice paths here. +# +Pod::Spec.new do |s| + s.name = 'netdesk_core' + s.version = '0.1.0' + s.summary = 'NetDesk native Rust core (static) for iOS — Akronlogic.' + s.description = <<-DESC + The compiled NetDesk engine: signaling, end-to-end crypto, the media + transport and VideoToolbox H.264 hardware decode (core/src/decode_vt.rs). + Vendored as a static xcframework; the Flutter UI binds to it over dart:ffi / + flutter_rust_bridge in Phase 3. + DESC + s.homepage = 'https://akronlogic.com' + s.license = { :type => 'Proprietary', :text => 'Copyright Akronlogic. All rights reserved.' } + s.author = { 'Akronlogic' => 'https://akronlogic.com' } + s.platform = :ios, '13.0' + s.source = { :path => '.' } + + # The Rust core, assembled by packaging/build-ios.sh (xcodebuild -create-xcframework). + s.vendored_frameworks = 'NetDeskCore.xcframework' + + # System frameworks the VideoToolbox decode path links with + # #[link(kind = "framework")] in core/src/decode_vt.rs. A static Rust lib + # records these in its metadata but does NOT pull them in at the final app + # link, so they must be named here. + # CoreMedia — CMVideoFormatDescription / CMSampleBuffer / CMBlockBuffer + # VideoToolbox — VTDecompressionSession* + # CoreVideo — CVPixelBuffer* + # CoreFoundation — CFDictionary/CFString/CFRelease (core-foundation-sys) + s.frameworks = 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'CoreFoundation' + + # Vendored static xcframework, no public headers yet (the C ABI header is + # generated with cbindgen when Phase 3's core/src/ffi.rs lands — see + # docs/build-ios.md). CocoaPods links the archive normally; once the Phase-3 + # Dart bindings reference the symbols they are retained automatically. + # + # Before those bindings exist, nothing references the archive, so the linker + # may drop it. That is harmless for the build. If you want to *prove* the + # archive links end-to-end before Phase 3, force it in by uncommenting: + # + # s.user_target_xcconfig = { + # 'OTHER_LDFLAGS' => '-force_load "${PODS_XCFRAMEWORKS_BUILD_DIR}/netdesk_core/libnetdesk_core.a"' + # } + # + # (Do not combine -force_load with Phase-3 symbol references, or you may hit + # duplicate-symbol errors — pick one.) + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Keep the Rust/FFI symbols discoverable for dart:ffi lookups. + 'STRIP_STYLE' => 'non-global', + } +end diff --git a/native/packaging/netdesk.ico b/native/packaging/netdesk.ico new file mode 100644 index 0000000000000000000000000000000000000000..1efc53b8db125f920eacf4ae816f3ad5bccd135c GIT binary patch literal 17077 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 Z{|BBe6Fk;&4($K{002ovPDHLkV1g6vk_i9+ literal 0 HcmV?d00001 diff --git a/native/packaging/netdesk.nsi b/native/packaging/netdesk.nsi new file mode 100644 index 0000000..4ff9659 --- /dev/null +++ b/native/packaging/netdesk.nsi @@ -0,0 +1,218 @@ +; ============================================================================ +; NetDesk by Akronlogic -- Windows installer (NSIS / Modern UI 2) +; +; Installs NetDesk.exe + the 3 MinGW runtime DLLs into %ProgramFiles%\NetDesk, +; creates Start Menu + Desktop shortcuts, registers an uninstaller (Add/Remove +; Programs), and offers an OPTIONAL per-user autostart (HKCU Run). +; +; Build (normally driven by build-installer.ps1): +; makensis netdesk.nsi +; makensis /DSTAGEDIR=dist\NetDesk-Portable /DOUTFILE=dist\NetDesk-Setup-win64.exe netdesk.nsi +; ============================================================================ + +Unicode true + +;---------------------------------------------------------------------------- +; Product metadata +;---------------------------------------------------------------------------- +!define PRODUCT "NetDesk" +!define COMPANY "Akronlogic" +!define VERSION "0.1.0" +!define URL "https://akronlogic.com" +!define EXE "NetDesk.exe" +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}" +!define AUTORUN_KEY "Software\Microsoft\Windows\CurrentVersion\Run" + +; Staging folder that holds the files to install (produced by build-portable.ps1). +; Overridable from the command line with /DSTAGEDIR=... +!ifndef STAGEDIR + !define STAGEDIR "dist\NetDesk-Portable" +!endif +!ifndef OUTFILE + !define OUTFILE "dist\NetDesk-Setup-win64.exe" +!endif + +;---------------------------------------------------------------------------- +; General +;---------------------------------------------------------------------------- +Name "${PRODUCT} by ${COMPANY}" +OutFile "${OUTFILE}" +InstallDir "$PROGRAMFILES64\${PRODUCT}" +InstallDirRegKey HKLM "Software\${PRODUCT}" "InstallDir" +RequestExecutionLevel admin +SetCompressor /SOLID lzma +BrandingText "${PRODUCT} by ${COMPANY} -- ${URL}" + +; Version info stamped on the setup .exe +VIProductVersion "${VERSION}.0" +VIAddVersionKey "ProductName" "${PRODUCT}" +VIAddVersionKey "CompanyName" "${COMPANY}" +VIAddVersionKey "FileDescription" "${PRODUCT} Setup" +VIAddVersionKey "FileVersion" "${VERSION}.0" +VIAddVersionKey "ProductVersion" "${VERSION}.0" +VIAddVersionKey "LegalCopyright" "(c) ${COMPANY}. All rights reserved." + +;---------------------------------------------------------------------------- +; Modern UI 2 +;---------------------------------------------------------------------------- +!include "MUI2.nsh" +!include "FileFunc.nsh" +!insertmacro GetSize + +!define MUI_ABORTWARNING +; Branded NetDesk icon for the setup .exe and the uninstaller (so Windows Search, +; Explorer and the installer window all show the green NetDesk mark -- not the +; generic NSIS rainbow). Overridable; build-installer.ps1 passes an absolute path +; to packaging\netdesk.ico (copied from assets\icon.ico at build time). +!ifndef BRANDICON + !define BRANDICON "netdesk.ico" +!endif +!define MUI_ICON "${BRANDICON}" +!define MUI_UNICON "${BRANDICON}" + +; Offer to launch NetDesk at the end of setup +!define MUI_FINISHPAGE_RUN "$INSTDIR\${EXE}" +!define MUI_FINISHPAGE_RUN_TEXT "Launch ${PRODUCT} now" + +; Install pages +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +; Uninstall pages +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +;---------------------------------------------------------------------------- +; Clean (re)install: a running NetDesk locks its own files, so close it first. +; Reinstalling then overwrites the existing install in place (same directory), +; upgrading an older build to this one. Full removal is via the uninstaller. +;---------------------------------------------------------------------------- +Function .onInit + ExecWait 'taskkill /F /IM ${EXE} /T' +FunctionEnd + +;---------------------------------------------------------------------------- +; Sections +;---------------------------------------------------------------------------- +Section "!${PRODUCT} (required)" SEC_CORE + SectionIn RO + SetOverwrite on + SetOutPath "$INSTDIR" + + ; --- Clean (re)install: stop a running host so its binaries unlock ---------- + ; NetDeskHost.exe is kept alive two ways: a per-user engine/GUI child, AND the + ; privileged boot-time Scheduled Task "NetDesk" (the host's SERVICE_TASK, + ; created by `NetDeskHost.exe --install`, running as SYSTEM) whose broker + ; respawns a session-agent ~every 2s. This installer is elevated + ; (RequestExecutionLevel admin), so END THE TASK FIRST (stops the respawner), + ; then force-kill every NetDeskHost.exe tree and the GUI. Errors are ignored + ; (nothing running on a fresh install is normal). We only /End the task here + ; (transient) -- never /Disable -- and /Run it again at the end of this + ; section, so unattended autostart is preserved. + nsExec::Exec 'schtasks /End /TN "NetDesk"' + Pop $0 + nsExec::Exec 'taskkill /F /IM NetDeskHost.exe /T' + Pop $0 + nsExec::Exec 'taskkill /F /IM ${EXE} /T' + Pop $0 + + ; Program + the 3 MinGW runtime DLLs that must sit beside it + File "${STAGEDIR}\${EXE}" + File "${STAGEDIR}\libstdc++-6.dll" + File "${STAGEDIR}\libgcc_s_seh-1.dll" + File "${STAGEDIR}\libwinpthread-1.dll" + File "${STAGEDIR}\README.txt" + ; NetDesk is one pure-Rust binary now: the native host engine runs in-process, + ; so there is no separate NetDeskHost.exe to install. + + ; Remember where we installed + WriteRegStr HKLM "Software\${PRODUCT}" "InstallDir" "$INSTDIR" + WriteRegStr HKLM "Software\${PRODUCT}" "Version" "${VERSION}" + + ; Start Menu shortcut + CreateDirectory "$SMPROGRAMS\${PRODUCT}" + CreateShortCut "$SMPROGRAMS\${PRODUCT}\${PRODUCT}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\${EXE}" 0 + CreateShortCut "$SMPROGRAMS\${PRODUCT}\Uninstall ${PRODUCT}.lnk" "$INSTDIR\uninstall.exe" + + ; Uninstaller + Add/Remove Programs registration + WriteUninstaller "$INSTDIR\uninstall.exe" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT} by ${COMPANY}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}" + WriteRegStr HKLM "${UNINST_KEY}" "URLInfoAbout" "${URL}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${EXE}" + WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' + WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1 + + ; Estimated size for Add/Remove Programs + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + + ; --- Bring the host engine back online ------------------------------------ + ; A fresh host `--install` finishes with `schtasks /Run /TN NetDesk`, so do the + ; same: relaunch the now-updated boot-time broker. No-op if there is no task + ; yet (first-time install, before the user enables unattended access); the GUI + ; itself is relaunched by the Finish page's optional "Launch NetDesk now". + nsExec::Exec 'schtasks /Run /TN "NetDesk"' + Pop $0 +SectionEnd + +Section "Desktop shortcut" SEC_DESKTOP + CreateShortCut "$DESKTOP\${PRODUCT}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\${EXE}" 0 +SectionEnd + +Section /o "Start ${PRODUCT} when I sign in" SEC_AUTOSTART + ; Per-user autostart (HKCU). Optional, OFF by default. Starts the native host + ; engine headlessly on login so the PC is reachable unattended. + WriteRegStr HKCU "${AUTORUN_KEY}" "${PRODUCT}" '"$INSTDIR\${EXE}" --host' +SectionEnd + +;---------------------------------------------------------------------------- +; Component descriptions +;---------------------------------------------------------------------------- +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_CORE} "The ${PRODUCT} application and its required runtime files." + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_DESKTOP} "Place a ${PRODUCT} shortcut on your Desktop." + !insertmacro MUI_DESCRIPTION_TEXT ${SEC_AUTOSTART} "Launch ${PRODUCT} automatically every time you sign in (current user only)." +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +;---------------------------------------------------------------------------- +; Uninstaller +;---------------------------------------------------------------------------- +Section "Uninstall" + ; Stop the host completely: end the SYSTEM task (stops the broker's respawner), + ; kill the engine and the GUI, then REMOVE the autostart task entirely so no + ; orphaned boot-time task is left pointing at a deleted exe. + nsExec::Exec 'schtasks /End /TN "NetDesk"' + Pop $0 + ExecWait 'taskkill /F /IM NetDeskHost.exe /T' + ExecWait 'taskkill /F /IM ${EXE} /T' + nsExec::Exec 'schtasks /Delete /TN "NetDesk" /F' + Pop $0 + Delete "$INSTDIR\${EXE}" + Delete "$INSTDIR\NetDeskHost.exe" + Delete "$INSTDIR\libstdc++-6.dll" + Delete "$INSTDIR\libgcc_s_seh-1.dll" + Delete "$INSTDIR\libwinpthread-1.dll" + Delete "$INSTDIR\README.txt" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" + + Delete "$SMPROGRAMS\${PRODUCT}\${PRODUCT}.lnk" + Delete "$SMPROGRAMS\${PRODUCT}\Uninstall ${PRODUCT}.lnk" + RMDir "$SMPROGRAMS\${PRODUCT}" + Delete "$DESKTOP\${PRODUCT}.lnk" + + DeleteRegValue HKCU "${AUTORUN_KEY}" "${PRODUCT}" + DeleteRegKey HKLM "${UNINST_KEY}" + DeleteRegKey HKLM "Software\${PRODUCT}" +SectionEnd diff --git a/native/packaging/netdesk.wxs b/native/packaging/netdesk.wxs new file mode 100644 index 0000000..6aa0996 --- /dev/null +++ b/native/packaging/netdesk.wxs @@ -0,0 +1,81 @@ + + + + + + + + + + + + + diff --git a/native/third_party/ucrt64/include/opus/opus.h b/native/third_party/ucrt64/include/opus/opus.h new file mode 100644 index 0000000..c053736 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus.h @@ -0,0 +1,1175 @@ +/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited + Written by Jean-Marc Valin and Koen Vos */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus.h + * @brief Opus reference implementation API + */ + +#ifndef OPUS_H +#define OPUS_H + +#include "opus_types.h" +#include "opus_defines.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @mainpage Opus + * + * The Opus codec is designed for interactive speech and audio transmission over the Internet. + * It is designed by the IETF Codec Working Group and incorporates technology from + * Skype's SILK codec and Xiph.Org's CELT codec. + * + * The Opus codec is designed to handle a wide range of interactive audio applications, + * including Voice over IP, videoconferencing, in-game chat, and even remote live music + * performances. It can scale from low bit-rate narrowband speech to very high quality + * stereo music. Its main features are: + + * @li Sampling rates from 8 to 48 kHz + * @li Bit-rates from 6 kb/s to 510 kb/s + * @li Support for both constant bit-rate (CBR) and variable bit-rate (VBR) + * @li Audio bandwidth from narrowband to full-band + * @li Support for speech and music + * @li Support for mono and stereo + * @li Support for multichannel (up to 255 channels) + * @li Frame sizes from 2.5 ms to 60 ms + * @li Good loss robustness and packet loss concealment (PLC) + * @li Floating point and fixed-point implementation + * + * Documentation sections: + * @li @ref opus_encoder + * @li @ref opus_decoder + * @li @ref opus_repacketizer + * @li @ref opus_multistream + * @li @ref opus_libinfo + * @li @ref opus_custom + */ + +/** @defgroup opus_encoder Opus Encoder + * @{ + * + * @brief This page describes the process and functions used to encode Opus. + * + * Since Opus is a stateful codec, the encoding process starts with creating an encoder + * state. This can be done with: + * + * @code + * int error; + * OpusEncoder *enc; + * enc = opus_encoder_create(Fs, channels, application, &error); + * @endcode + * + * From this point, @c enc can be used for encoding an audio stream. An encoder state + * @b must @b not be used for more than one stream at the same time. Similarly, the encoder + * state @b must @b not be re-initialized for each frame. + * + * While opus_encoder_create() allocates memory for the state, it's also possible + * to initialize pre-allocated memory: + * + * @code + * int size; + * int error; + * OpusEncoder *enc; + * size = opus_encoder_get_size(channels); + * enc = malloc(size); + * error = opus_encoder_init(enc, Fs, channels, application); + * @endcode + * + * where opus_encoder_get_size() returns the required size for the encoder state. Note that + * future versions of this code may change the size, so no assumptions should be made about it. + * + * The encoder state is always continuous in memory and only a shallow copy is sufficient + * to copy it (e.g. memcpy()) + * + * It is possible to change some of the encoder's settings using the opus_encoder_ctl() + * interface. All these settings already default to the recommended value, so they should + * only be changed when necessary. The most common settings one may want to change are: + * + * @code + * opus_encoder_ctl(enc, OPUS_SET_BITRATE(bitrate)); + * opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(complexity)); + * opus_encoder_ctl(enc, OPUS_SET_SIGNAL(signal_type)); + * @endcode + * + * where + * + * @arg bitrate is in bits per second (b/s) + * @arg complexity is a value from 1 to 10, where 1 is the lowest complexity and 10 is the highest + * @arg signal_type is either OPUS_AUTO (default), OPUS_SIGNAL_VOICE, or OPUS_SIGNAL_MUSIC + * + * See @ref opus_encoderctls and @ref opus_genericctls for a complete list of parameters that can be set or queried. Most parameters can be set or changed at any time during a stream. + * + * To encode a frame, opus_encode() or opus_encode_float() must be called with exactly one frame (2.5, 5, 10, 20, 40 or 60 ms) of audio data: + * @code + * len = opus_encode(enc, audio_frame, frame_size, packet, max_packet); + * @endcode + * + * where + *
    + *
  • audio_frame is the audio data in opus_int16 (or float for opus_encode_float())
  • + *
  • frame_size is the duration of the frame in samples (per channel)
  • + *
  • packet is the byte array to which the compressed data is written
  • + *
  • max_packet is the maximum number of bytes that can be written in the packet (4000 bytes is recommended). + * Do not use max_packet to control VBR target bitrate, instead use the #OPUS_SET_BITRATE CTL.
  • + *
+ * + * opus_encode() and opus_encode_float() return the number of bytes actually written to the packet. + * The return value can be negative, which indicates that an error has occurred. If the return value + * is 2 bytes or less, then the packet does not need to be transmitted (DTX). + * + * Once the encoder state if no longer needed, it can be destroyed with + * + * @code + * opus_encoder_destroy(enc); + * @endcode + * + * If the encoder was created with opus_encoder_init() rather than opus_encoder_create(), + * then no action is required aside from potentially freeing the memory that was manually + * allocated for it (calling free(enc) for the example above) + * + */ + +/** Opus encoder state. + * This contains the complete state of an Opus encoder. + * It is position independent and can be freely copied. + * @see opus_encoder_create,opus_encoder_init + */ +typedef struct OpusEncoder OpusEncoder; + +/** Gets the size of an OpusEncoder structure. + * @param[in] channels int: Number of channels. + * This must be 1 or 2. + * @returns The size in bytes. + * @note Since this function does not take the application as input, it will overestimate + * the size required for OPUS_APPLICATION_RESTRICTED_SILK and OPUS_APPLICATION_RESTRICTED_CELT. + * That is generally not a problem, except when trying to know the size to use for a copy. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_encoder_get_size(int channels); + +/** + */ + +/** Allocates and initializes an encoder state. + * There are three coding modes: + * + * @ref OPUS_APPLICATION_VOIP gives best quality at a given bitrate for voice + * signals. It enhances the input signal by high-pass filtering and + * emphasizing formants and harmonics. Optionally it includes in-band + * forward error correction to protect against packet loss. Use this + * mode for typical VoIP applications. Because of the enhancement, + * even at high bitrates the output may sound different from the input. + * + * @ref OPUS_APPLICATION_AUDIO gives best quality at a given bitrate for most + * non-voice signals like music. Use this mode for music and mixed + * (music/voice) content, broadcast, and applications requiring less + * than 15 ms of coding delay. + * + * @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY configures low-delay mode that + * disables the speech-optimized mode in exchange for slightly reduced delay. + * This mode can only be set on an newly initialized or freshly reset encoder + * because it changes the codec delay. + * + * This is useful when the caller knows that the speech-optimized modes will not be needed (use with caution). + * @param [in] Fs opus_int32: Sampling rate of input signal (Hz) + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) in input signal + * @param [in] application int: Coding mode (one of @ref OPUS_APPLICATION_VOIP, @ref OPUS_APPLICATION_AUDIO, or @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY) + * @param [out] error int*: @ref opus_errorcodes + * @note Regardless of the sampling rate and number channels selected, the Opus encoder + * can switch to a lower audio bandwidth or number of channels if the bitrate + * selected is too low. This also means that it is safe to always use 48 kHz stereo input + * and let the encoder optimize the encoding. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusEncoder *opus_encoder_create( + opus_int32 Fs, + int channels, + int application, + int *error +); + +/** Initializes a previously allocated encoder state + * The memory pointed to by st must be at least the size returned by opus_encoder_get_size(). + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_encoder_create(),opus_encoder_get_size() + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] Fs opus_int32: Sampling rate of input signal (Hz) + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) in input signal + * @param [in] application int: Coding mode (one of OPUS_APPLICATION_VOIP, OPUS_APPLICATION_AUDIO, or OPUS_APPLICATION_RESTRICTED_LOWDELAY) + * @retval #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_encoder_init( + OpusEncoder *st, + opus_int32 Fs, + int channels, + int application +) OPUS_ARG_NONNULL(1); + +/** Encodes an Opus frame. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm opus_int16*: Input signal (interleaved if 2 channels). length is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode( + OpusEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes an Opus frame. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm opus_int32*: Input signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode24( + OpusEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes an Opus frame from floating point input. + * @param [in] st OpusEncoder*: Encoder state + * @param [in] pcm float*: Input in float format (interleaved if 2 channels), with a normal range of +/-1.0. + * Samples with a range beyond +/-1.0 are supported but will + * be clipped by decoders using the integer API and should + * only be used if it is known that the far end supports + * extended dynamic range. + * length is frame_size*channels*sizeof(float) + * @param [in] frame_size int: Number of samples per channel in the + * input signal. + * This must be an Opus frame size for + * the encoder's sampling rate. + * For example, at 48 kHz the permitted + * values are 120, 240, 480, 960, 1920, + * and 2880. + * Passing in a duration of less than + * 10 ms (480 samples at 48 kHz) will + * prevent the encoder from using the LPC + * or hybrid modes. + * @param [out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode_float( + OpusEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Frees an OpusEncoder allocated by opus_encoder_create(). + * @param[in] st OpusEncoder*: State to be freed. + */ +OPUS_EXPORT void opus_encoder_destroy(OpusEncoder *st); + +/** Perform a CTL function on an Opus encoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param st OpusEncoder*: Encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_encoderctls. + * @see opus_genericctls + * @see opus_encoderctls + */ +OPUS_EXPORT int opus_encoder_ctl(OpusEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); +/**@}*/ + +/** @defgroup opus_decoder Opus Decoder + * @{ + * + * @brief This page describes the process and functions used to decode Opus. + * + * The decoding process also starts with creating a decoder + * state. This can be done with: + * @code + * int error; + * OpusDecoder *dec; + * dec = opus_decoder_create(Fs, channels, &error); + * @endcode + * where + * @li Fs is the sampling rate and must be 8000, 12000, 16000, 24000, or 48000 + * @li channels is the number of channels (1 or 2) + * @li error will hold the error code in case of failure (or #OPUS_OK on success) + * @li the return value is a newly created decoder state to be used for decoding + * + * While opus_decoder_create() allocates memory for the state, it's also possible + * to initialize pre-allocated memory: + * @code + * int size; + * int error; + * OpusDecoder *dec; + * size = opus_decoder_get_size(channels); + * dec = malloc(size); + * error = opus_decoder_init(dec, Fs, channels); + * @endcode + * where opus_decoder_get_size() returns the required size for the decoder state. Note that + * future versions of this code may change the size, so no assumptions should be made about it. + * + * The decoder state is always continuous in memory and only a shallow copy is sufficient + * to copy it (e.g. memcpy()) + * + * To decode a frame, opus_decode() or opus_decode_float() must be called with a packet of compressed audio data: + * @code + * frame_size = opus_decode(dec, packet, len, decoded, max_size, 0); + * @endcode + * where + * + * @li packet is the byte array containing the compressed data + * @li len is the exact number of bytes contained in the packet + * @li decoded is the decoded audio data in opus_int16 (or float for opus_decode_float()) + * @li max_size is the max duration of the frame in samples (per channel) that can fit into the decoded_frame array + * + * opus_decode() and opus_decode_float() return the number of samples (per channel) decoded from the packet. + * If that value is negative, then an error has occurred. This can occur if the packet is corrupted or if the audio + * buffer is too small to hold the decoded audio. + * + * Opus is a stateful codec with overlapping blocks and as a result Opus + * packets are not coded independently of each other. Packets must be + * passed into the decoder serially and in the correct order for a correct + * decode. Lost packets can be replaced with loss concealment by calling + * the decoder with a null pointer and zero length for the missing packet. + * + * A single codec state may only be accessed from a single thread at + * a time and any required locking must be performed by the caller. Separate + * streams must be decoded with separate decoder states and can be decoded + * in parallel unless the library was compiled with NONTHREADSAFE_PSEUDOSTACK + * defined. + * + */ + +/** Opus decoder state. + * This contains the complete state of an Opus decoder. + * It is position independent and can be freely copied. + * @see opus_decoder_create,opus_decoder_init + */ +typedef struct OpusDecoder OpusDecoder; + +/** Opus DRED decoder. + * This contains the complete state of an Opus DRED decoder. + * It is position independent and can be freely copied. + * @see opus_dred_decoder_create,opus_dred_decoder_init + */ +typedef struct OpusDREDDecoder OpusDREDDecoder; + + +/** Opus DRED state. + * This contains the complete state of an Opus DRED packet. + * It is position independent and can be freely copied. + * @see opus_dred_create,opus_dred_init + */ +typedef struct OpusDRED OpusDRED; + +/** Gets the size of an OpusDecoder structure. + * @param [in] channels int: Number of channels. + * This must be 1 or 2. + * @returns The size in bytes. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_size(int channels); + +/** Allocates and initializes a decoder state. + * @param [in] Fs opus_int32: Sample rate to decode at (Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) to decode + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + * + * Internally Opus stores data at 48000 Hz, so that should be the default + * value for Fs. However, the decoder can efficiently decode to buffers + * at 8, 12, 16, and 24 kHz so if for some reason the caller cannot use + * data at the full sample rate, or knows the compressed data doesn't + * use the full frequency range, it can request decoding at a reduced + * rate. Likewise, the decoder is capable of filling in either mono or + * interleaved stereo pcm buffers, at the caller's request. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusDecoder *opus_decoder_create( + opus_int32 Fs, + int channels, + int *error +); + +/** Initializes a previously allocated decoder state. + * The state must be at least the size returned by opus_decoder_get_size(). + * This is intended for applications which use their own allocator instead of malloc. @see opus_decoder_create,opus_decoder_get_size + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @param [in] st OpusDecoder*: Decoder state. + * @param [in] Fs opus_int32: Sampling rate to decode to (Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param [in] channels int: Number of channels (1 or 2) to decode + * @retval #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_init( + OpusDecoder *st, + opus_int32 Fs, + int channels +) OPUS_ARG_NONNULL(1); + +/** Decode an Opus packet. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload* + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available, the frame is decoded as if it were lost. + * @returns Number of decoded samples per channel or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an Opus packet. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload* + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length + * is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available, the frame is decoded as if it were lost. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode24( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an Opus packet with floating point output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len opus_int32: Number of bytes in payload + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel of available space in \a pcm. + * If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will + * not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1), + * then frame_size needs to be exactly the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the next incoming packet. For the PLC and + * FEC cases, frame_size must be a multiple of 2.5 ms. + * @param [in] decode_fec int: Flag (0 or 1) to request that any in-band forward error correction data be + * decoded. If no such data is available the frame is decoded as if it were lost. + * @returns Number of decoded samples per channel or @ref opus_errorcodes + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode_float( + OpusDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param st OpusDecoder*: Decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_decoderctls. + * @see opus_genericctls + * @see opus_decoderctls + */ +OPUS_EXPORT int opus_decoder_ctl(OpusDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/** Frees an OpusDecoder allocated by opus_decoder_create(). + * @param[in] st OpusDecoder*: State to be freed. + */ +OPUS_EXPORT void opus_decoder_destroy(OpusDecoder *st); + +/** Gets the size of an OpusDREDDecoder structure. + * @returns The size in bytes. + */ +OPUS_EXPORT int opus_dred_decoder_get_size(void); + +/** Allocates and initializes an OpusDREDDecoder state. + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT OpusDREDDecoder *opus_dred_decoder_create(int *error); + +/** Initializes an OpusDREDDecoder state. + * @param[in] dec OpusDREDDecoder*: State to be initialized. + */ +OPUS_EXPORT int opus_dred_decoder_init(OpusDREDDecoder *dec); + +/** Frees an OpusDREDDecoder allocated by opus_dred_decoder_create(). + * @param[in] dec OpusDREDDecoder*: State to be freed. + */ +OPUS_EXPORT void opus_dred_decoder_destroy(OpusDREDDecoder *dec); + +/** Perform a CTL function on an Opus DRED decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @param dred_dec OpusDREDDecoder*: DRED Decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls or + * @ref opus_decoderctls. + * @see opus_genericctls + * @see opus_decoderctls + */ +OPUS_EXPORT int opus_dred_decoder_ctl(OpusDREDDecoder *dred_dec, int request, ...); + +/** Gets the size of an OpusDRED structure. + * @returns The size in bytes. + */ +OPUS_EXPORT int opus_dred_get_size(void); + +/** Allocates and initializes a DRED state. + * @param [out] error int*: #OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_EXPORT OpusDRED *opus_dred_alloc(int *error); + +/** Frees an OpusDRED allocated by opus_dred_create(). + * @param[in] dec OpusDRED*: State to be freed. + */ +OPUS_EXPORT void opus_dred_free(OpusDRED *dec); + +/** Decode an Opus DRED packet. + * @param [in] dred_dec OpusDRED*: DRED Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] data char*: Input payload + * @param [in] len opus_int32: Number of bytes in payload + * @param [in] max_dred_samples opus_int32: Maximum number of DRED samples that may be needed (if available in the packet). + * @param [in] sampling_rate opus_int32: Sampling rate used for max_dred_samples argument. Needs not match the actual sampling rate of the decoder. + * @param [out] dred_end opus_int32*: Number of non-encoded (silence) samples between the DRED timestamp and the last DRED sample. + * @param [in] defer_processing int: Flag (0 or 1). If set to one, the CPU-intensive part of the DRED decoding is deferred until opus_dred_process() is called. + * @returns Offset (positive) of the first decoded DRED samples, zero if no DRED is present, or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_dred_parse(OpusDREDDecoder *dred_dec, OpusDRED *dred, const unsigned char *data, opus_int32 len, opus_int32 max_dred_samples, opus_int32 sampling_rate, int *dred_end, int defer_processing) OPUS_ARG_NONNULL(1); + +/** Finish decoding an Opus DRED packet. The function only needs to be called if opus_dred_parse() was called with defer_processing=1. + * The source and destination will often be the same DRED state. + * @param [in] dred_dec OpusDRED*: DRED Decoder state + * @param [in] src OpusDRED*: Source DRED state to start the processing from. + * @param [out] dst OpusDRED*: Destination DRED state to store the updated state after processing. + * @returns @ref opus_errorcodes + */ +OPUS_EXPORT int opus_dred_process(OpusDREDDecoder *dred_dec, const OpusDRED *src, OpusDRED *dst); + +/** Decode audio from an Opus DRED packet with 16-bit output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, opus_int16 *pcm, opus_int32 frame_size); + +/** Decode audio from an Opus DRED packet with 24-bit output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode24(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, opus_int32 *pcm, opus_int32 frame_size); + +/** Decode audio from an Opus DRED packet with floating point output. + * @param [in] st OpusDecoder*: Decoder state + * @param [in] dred OpusDRED*: DRED state + * @param [in] dred_offset opus_int32: position of the redundancy to decode (in samples before the beginning of the real audio data in the packet). + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel to decode in \a pcm. + * frame_size must be a multiple of 2.5 ms. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_EXPORT int opus_decoder_dred_decode_float(OpusDecoder *st, const OpusDRED *dred, opus_int32 dred_offset, float *pcm, opus_int32 frame_size); + + +/** Parse an opus packet into one or more frames. + * Opus_decode will perform this operation internally so most applications do + * not need to use this function. + * This function does not copy the frames, the returned pointers are pointers into + * the input packet. + * @param [in] data char*: Opus packet to be parsed + * @param [in] len opus_int32: size of data + * @param [out] out_toc char*: TOC pointer + * @param [out] frames char*[48] encapsulated frames + * @param [out] size opus_int16[48] sizes of the encapsulated frames + * @param [out] payload_offset int*: returns the position of the payload within the packet (in bytes) + * @returns number of frames + */ +OPUS_EXPORT int opus_packet_parse( + const unsigned char *data, + opus_int32 len, + unsigned char *out_toc, + const unsigned char *frames[48], + opus_int16 size[48], + int *payload_offset +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5); + +/** Gets the bandwidth of an Opus packet. + * @param [in] data char*: Opus packet + * @retval OPUS_BANDWIDTH_NARROWBAND Narrowband (4kHz bandpass) + * @retval OPUS_BANDWIDTH_MEDIUMBAND Mediumband (6kHz bandpass) + * @retval OPUS_BANDWIDTH_WIDEBAND Wideband (8kHz bandpass) + * @retval OPUS_BANDWIDTH_SUPERWIDEBAND Superwideband (12kHz bandpass) + * @retval OPUS_BANDWIDTH_FULLBAND Fullband (20kHz bandpass) + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_bandwidth(const unsigned char *data) OPUS_ARG_NONNULL(1); + +/** Gets the number of samples per frame from an Opus packet. + * @param [in] data char*: Opus packet. + * This must contain at least one byte of + * data. + * @param [in] Fs opus_int32: Sampling rate in Hz. + * This must be a multiple of 400, or + * inaccurate results will be returned. + * @returns Number of samples per frame. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_samples_per_frame(const unsigned char *data, opus_int32 Fs) OPUS_ARG_NONNULL(1); + +/** Gets the number of channels from an Opus packet. + * @param [in] data char*: Opus packet + * @returns Number of channels + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_channels(const unsigned char *data) OPUS_ARG_NONNULL(1); + +/** Gets the number of frames in an Opus packet. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns Number of frames + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_frames(const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1); + +/** Gets the number of samples of an Opus packet. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @param [in] Fs opus_int32: Sampling rate in Hz. + * This must be a multiple of 400, or + * inaccurate results will be returned. + * @returns Number of samples + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_samples(const unsigned char packet[], opus_int32 len, opus_int32 Fs) OPUS_ARG_NONNULL(1); + +/** Checks whether an Opus packet has LBRR. + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns 1 is LBRR is present, 0 otherwise + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_has_lbrr(const unsigned char packet[], opus_int32 len); + +/** Gets the number of samples of an Opus packet. + * @param [in] dec OpusDecoder*: Decoder state + * @param [in] packet char*: Opus packet + * @param [in] len opus_int32: Length of packet + * @returns Number of samples + * @retval OPUS_BAD_ARG Insufficient data was passed to the function + * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_nb_samples(const OpusDecoder *dec, const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + +/** Applies soft-clipping to bring a float signal within the [-1,1] range. If + * the signal is already in that range, nothing is done. If there are values + * outside of [-1,1], then the signal is clipped as smoothly as possible to + * both fit in the range and avoid creating excessive distortion in the + * process. + * @param [in,out] pcm float*: Input PCM and modified PCM + * @param [in] frame_size int Number of samples per channel to process + * @param [in] channels int: Number of channels + * @param [in,out] softclip_mem float*: State memory for the soft clipping process (one float per channel, initialized to zero) + */ +OPUS_EXPORT void opus_pcm_soft_clip(float *pcm, int frame_size, int channels, float *softclip_mem); + + +/**@}*/ + +/** @defgroup opus_repacketizer Repacketizer + * @{ + * + * The repacketizer can be used to merge multiple Opus packets into a single + * packet or alternatively to split Opus packets that have previously been + * merged. Splitting valid Opus packets is always guaranteed to succeed, + * whereas merging valid packets only succeeds if all frames have the same + * mode, bandwidth, and frame size, and when the total duration of the merged + * packet is no more than 120 ms. The 120 ms limit comes from the + * specification and limits decoder memory requirements at a point where + * framing overhead becomes negligible. + * + * The repacketizer currently only operates on elementary Opus + * streams. It will not manipulate multistream packets successfully, except in + * the degenerate case where they consist of data from a single stream. + * + * The repacketizing process starts with creating a repacketizer state, either + * by calling opus_repacketizer_create() or by allocating the memory yourself, + * e.g., + * @code + * OpusRepacketizer *rp; + * rp = (OpusRepacketizer*)malloc(opus_repacketizer_get_size()); + * if (rp != NULL) + * opus_repacketizer_init(rp); + * @endcode + * + * Then the application should submit packets with opus_repacketizer_cat(), + * extract new packets with opus_repacketizer_out() or + * opus_repacketizer_out_range(), and then reset the state for the next set of + * input packets via opus_repacketizer_init(). + * + * For example, to split a sequence of packets into individual frames: + * @code + * unsigned char *data; + * int len; + * while (get_next_packet(&data, &len)) + * { + * unsigned char out[1276]; + * opus_int32 out_len; + * int nb_frames; + * int err; + * int i; + * err = opus_repacketizer_cat(rp, data, len); + * if (err != OPUS_OK) + * { + * release_packet(data); + * return err; + * } + * nb_frames = opus_repacketizer_get_nb_frames(rp); + * for (i = 0; i < nb_frames; i++) + * { + * out_len = opus_repacketizer_out_range(rp, i, i+1, out, sizeof(out)); + * if (out_len < 0) + * { + * release_packet(data); + * return (int)out_len; + * } + * output_next_packet(out, out_len); + * } + * opus_repacketizer_init(rp); + * release_packet(data); + * } + * @endcode + * + * Alternatively, to combine a sequence of frames into packets that each + * contain up to TARGET_DURATION_MS milliseconds of data: + * @code + * // The maximum number of packets with duration TARGET_DURATION_MS occurs + * // when the frame size is 2.5 ms, for a total of (TARGET_DURATION_MS*2/5) + * // packets. + * unsigned char *data[(TARGET_DURATION_MS*2/5)+1]; + * opus_int32 len[(TARGET_DURATION_MS*2/5)+1]; + * int nb_packets; + * unsigned char out[1277*(TARGET_DURATION_MS*2/2)]; + * opus_int32 out_len; + * int prev_toc; + * nb_packets = 0; + * while (get_next_packet(data+nb_packets, len+nb_packets)) + * { + * int nb_frames; + * int err; + * nb_frames = opus_packet_get_nb_frames(data[nb_packets], len[nb_packets]); + * if (nb_frames < 1) + * { + * release_packets(data, nb_packets+1); + * return nb_frames; + * } + * nb_frames += opus_repacketizer_get_nb_frames(rp); + * // If adding the next packet would exceed our target, or it has an + * // incompatible TOC sequence, output the packets we already have before + * // submitting it. + * // N.B., The nb_packets > 0 check ensures we've submitted at least one + * // packet since the last call to opus_repacketizer_init(). Otherwise a + * // single packet longer than TARGET_DURATION_MS would cause us to try to + * // output an (invalid) empty packet. It also ensures that prev_toc has + * // been set to a valid value. Additionally, len[nb_packets] > 0 is + * // guaranteed by the call to opus_packet_get_nb_frames() above, so the + * // reference to data[nb_packets][0] should be valid. + * if (nb_packets > 0 && ( + * ((prev_toc & 0xFC) != (data[nb_packets][0] & 0xFC)) || + * opus_packet_get_samples_per_frame(data[nb_packets], 48000)*nb_frames > + * TARGET_DURATION_MS*48)) + * { + * out_len = opus_repacketizer_out(rp, out, sizeof(out)); + * if (out_len < 0) + * { + * release_packets(data, nb_packets+1); + * return (int)out_len; + * } + * output_next_packet(out, out_len); + * opus_repacketizer_init(rp); + * release_packets(data, nb_packets); + * data[0] = data[nb_packets]; + * len[0] = len[nb_packets]; + * nb_packets = 0; + * } + * err = opus_repacketizer_cat(rp, data[nb_packets], len[nb_packets]); + * if (err != OPUS_OK) + * { + * release_packets(data, nb_packets+1); + * return err; + * } + * prev_toc = data[nb_packets][0]; + * nb_packets++; + * } + * // Output the final, partial packet. + * if (nb_packets > 0) + * { + * out_len = opus_repacketizer_out(rp, out, sizeof(out)); + * release_packets(data, nb_packets); + * if (out_len < 0) + * return (int)out_len; + * output_next_packet(out, out_len); + * } + * @endcode + * + * An alternate way of merging packets is to simply call opus_repacketizer_cat() + * unconditionally until it fails. At that point, the merged packet can be + * obtained with opus_repacketizer_out() and the input packet for which + * opus_repacketizer_cat() needs to be re-added to a newly reinitialized + * repacketizer state. + */ + +typedef struct OpusRepacketizer OpusRepacketizer; + +/** Gets the size of an OpusRepacketizer structure. + * @returns The size in bytes. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_size(void); + +/** (Re)initializes a previously allocated repacketizer state. + * The state must be at least the size returned by opus_repacketizer_get_size(). + * This can be used for applications which use their own allocator instead of + * malloc(). + * It must also be called to reset the queue of packets waiting to be + * repacketized, which is necessary if the maximum packet duration of 120 ms + * is reached or if you wish to submit packets with a different Opus + * configuration (coding mode, audio bandwidth, frame size, or channel count). + * Failure to do so will prevent a new packet from being added with + * opus_repacketizer_cat(). + * @see opus_repacketizer_create + * @see opus_repacketizer_get_size + * @see opus_repacketizer_cat + * @param rp OpusRepacketizer*: The repacketizer state to + * (re)initialize. + * @returns A pointer to the same repacketizer state that was passed in. + */ +OPUS_EXPORT OpusRepacketizer *opus_repacketizer_init(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1); + +/** Allocates memory and initializes the new repacketizer with + * opus_repacketizer_init(). + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusRepacketizer *opus_repacketizer_create(void); + +/** Frees an OpusRepacketizer allocated by + * opus_repacketizer_create(). + * @param[in] rp OpusRepacketizer*: State to be freed. + */ +OPUS_EXPORT void opus_repacketizer_destroy(OpusRepacketizer *rp); + +/** Add a packet to the current repacketizer state. + * This packet must match the configuration of any packets already submitted + * for repacketization since the last call to opus_repacketizer_init(). + * This means that it must have the same coding mode, audio bandwidth, frame + * size, and channel count. + * This can be checked in advance by examining the top 6 bits of the first + * byte of the packet, and ensuring they match the top 6 bits of the first + * byte of any previously submitted packet. + * The total duration of audio in the repacketizer state also must not exceed + * 120 ms, the maximum duration of a single packet, after adding this packet. + * + * The contents of the current repacketizer state can be extracted into new + * packets using opus_repacketizer_out() or opus_repacketizer_out_range(). + * + * In order to add a packet with a different configuration or to add more + * audio beyond 120 ms, you must clear the repacketizer state by calling + * opus_repacketizer_init(). + * If a packet is too large to add to the current repacketizer state, no part + * of it is added, even if it contains multiple frames, some of which might + * fit. + * If you wish to be able to add parts of such packets, you should first use + * another repacketizer to split the packet into pieces and add them + * individually. + * @see opus_repacketizer_out_range + * @see opus_repacketizer_out + * @see opus_repacketizer_init + * @param rp OpusRepacketizer*: The repacketizer state to which to + * add the packet. + * @param[in] data const unsigned char*: The packet data. + * The application must ensure + * this pointer remains valid + * until the next call to + * opus_repacketizer_init() or + * opus_repacketizer_destroy(). + * @param len opus_int32: The number of bytes in the packet data. + * @returns An error code indicating whether or not the operation succeeded. + * @retval #OPUS_OK The packet's contents have been added to the repacketizer + * state. + * @retval #OPUS_INVALID_PACKET The packet did not have a valid TOC sequence, + * the packet's TOC sequence was not compatible + * with previously submitted packets (because + * the coding mode, audio bandwidth, frame size, + * or channel count did not match), or adding + * this packet would increase the total amount of + * audio stored in the repacketizer state to more + * than 120 ms. + */ +OPUS_EXPORT int opus_repacketizer_cat(OpusRepacketizer *rp, const unsigned char *data, opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + + +/** Construct a new packet from data previously submitted to the repacketizer + * state via opus_repacketizer_cat(). + * @param rp OpusRepacketizer*: The repacketizer state from which to + * construct the new packet. + * @param begin int: The index of the first frame in the current + * repacketizer state to include in the output. + * @param end int: One past the index of the last frame in the + * current repacketizer state to include in the + * output. + * @param[out] data const unsigned char*: The buffer in which to + * store the output packet. + * @param maxlen opus_int32: The maximum number of bytes to store in + * the output buffer. In order to guarantee + * success, this should be at least + * 1276 for a single frame, + * or for multiple frames, + * 1277*(end-begin). + * However, 1*(end-begin) plus + * the size of all packet data submitted to + * the repacketizer since the last call to + * opus_repacketizer_init() or + * opus_repacketizer_create() is also + * sufficient, and possibly much smaller. + * @returns The total size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG [begin,end) was an invalid range of + * frames (begin < 0, begin >= end, or end > + * opus_repacketizer_get_nb_frames()). + * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the + * complete output packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out_range(OpusRepacketizer *rp, int begin, int end, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Return the total number of frames contained in packet data submitted to + * the repacketizer state so far via opus_repacketizer_cat() since the last + * call to opus_repacketizer_init() or opus_repacketizer_create(). + * This defines the valid range of packets that can be extracted with + * opus_repacketizer_out_range() or opus_repacketizer_out(). + * @param rp OpusRepacketizer*: The repacketizer state containing the + * frames. + * @returns The total number of frames contained in the packet data submitted + * to the repacketizer state. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_nb_frames(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1); + +/** Construct a new packet from data previously submitted to the repacketizer + * state via opus_repacketizer_cat(). + * This is a convenience routine that returns all the data submitted so far + * in a single packet. + * It is equivalent to calling + * @code + * opus_repacketizer_out_range(rp, 0, opus_repacketizer_get_nb_frames(rp), + * data, maxlen) + * @endcode + * @param rp OpusRepacketizer*: The repacketizer state from which to + * construct the new packet. + * @param[out] data const unsigned char*: The buffer in which to + * store the output packet. + * @param maxlen opus_int32: The maximum number of bytes to store in + * the output buffer. In order to guarantee + * success, this should be at least + * 1277*opus_repacketizer_get_nb_frames(rp). + * However, + * 1*opus_repacketizer_get_nb_frames(rp) + * plus the size of all packet data + * submitted to the repacketizer since the + * last call to opus_repacketizer_init() or + * opus_repacketizer_create() is also + * sufficient, and possibly much smaller. + * @returns The total size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the + * complete output packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out(OpusRepacketizer *rp, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1); + +/** Pads a given Opus packet to a larger size (possibly changing the TOC sequence). + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to pad. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param new_len opus_int32: The desired size of the packet after padding. + * This must be at least as large as len. + * @returns an error code + * @retval #OPUS_OK \a on success. + * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT int opus_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len); + +/** Remove all padding from a given Opus packet and rewrite the TOC sequence to + * minimize space usage. + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to strip. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @returns The new size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG \a len was less than 1. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_packet_unpad(unsigned char *data, opus_int32 len); + +/** Pads a given Opus multi-stream packet to a larger size (possibly changing the TOC sequence). + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to pad. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param new_len opus_int32: The desired size of the packet after padding. + * This must be at least 1. + * @param nb_streams opus_int32: The number of streams (not channels) in the packet. + * This must be at least as large as len. + * @returns an error code + * @retval #OPUS_OK \a on success. + * @retval #OPUS_BAD_ARG \a len was less than 1. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT int opus_multistream_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len, int nb_streams); + +/** Remove all padding from a given Opus multi-stream packet and rewrite the TOC sequence to + * minimize space usage. + * @param[in,out] data const unsigned char*: The buffer containing the + * packet to strip. + * @param len opus_int32: The size of the packet. + * This must be at least 1. + * @param nb_streams opus_int32: The number of streams (not channels) in the packet. + * This must be at least 1. + * @returns The new size of the output packet on success, or an error code + * on failure. + * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len. + * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_packet_unpad(unsigned char *data, opus_int32 len, int nb_streams); + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_custom.h b/native/third_party/ucrt64/include/opus/opus_custom.h new file mode 100644 index 0000000..e3f7cf2 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_custom.h @@ -0,0 +1,381 @@ +/* Copyright (c) 2007-2008 CSIRO + Copyright (c) 2007-2009 Xiph.Org Foundation + Copyright (c) 2008-2012 Gregory Maxwell + Written by Jean-Marc Valin and Gregory Maxwell */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + @file opus_custom.h + @brief Opus-Custom reference implementation API + */ + +#ifndef OPUS_CUSTOM_H +#define OPUS_CUSTOM_H + +#include "opus_defines.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#if defined(CUSTOM_MODES) || defined(ENABLE_OPUS_CUSTOM_API) +# define OPUS_CUSTOM_EXPORT OPUS_EXPORT +# define OPUS_CUSTOM_EXPORT_STATIC OPUS_EXPORT +#else +# define OPUS_CUSTOM_EXPORT +# ifdef OPUS_BUILD +# define OPUS_CUSTOM_EXPORT_STATIC static OPUS_INLINE +# else +# define OPUS_CUSTOM_EXPORT_STATIC +# endif +#endif + +/** @defgroup opus_custom Opus Custom + * @{ + * Opus Custom is an optional part of the Opus specification and + * reference implementation which uses a distinct API from the regular + * API and supports frame sizes that are not normally supported.\ Use + * of Opus Custom is discouraged for all but very special applications + * for which a frame size different from 2.5, 5, 10, or 20 ms is needed + * (for either complexity or latency reasons) and where interoperability + * is less important. + * + * In addition to the interoperability limitations the use of Opus custom + * disables a substantial chunk of the codec and generally lowers the + * quality available at a given bitrate. Normally when an application needs + * a different frame size from the codec it should buffer to match the + * sizes but this adds a small amount of delay which may be important + * in some very low latency applications. Some transports (especially + * constant rate RF transports) may also work best with frames of + * particular durations. + * + * Libopus only supports custom modes if they are enabled at compile time. + * + * The Opus Custom API is similar to the regular API but the + * @ref opus_encoder_create and @ref opus_decoder_create calls take + * an additional mode parameter which is a structure produced by + * a call to @ref opus_custom_mode_create. Both the encoder and decoder + * must create a mode using the same sample rate (fs) and frame size + * (frame size) so these parameters must either be signaled out of band + * or fixed in a particular implementation. + * + * Similar to regular Opus the custom modes support on the fly frame size + * switching, but the sizes available depend on the particular frame size in + * use. For some initial frame sizes on a single on the fly size is available. + */ + +/** Contains the state of an encoder. One encoder state is needed + for each stream. It is initialized once at the beginning of the + stream. Do *not* re-initialize the state for every frame. + @brief Encoder state + */ +typedef struct OpusCustomEncoder OpusCustomEncoder; + +/** State of the decoder. One decoder state is needed for each stream. + It is initialized once at the beginning of the stream. Do *not* + re-initialize the state for every frame. + @brief Decoder state + */ +typedef struct OpusCustomDecoder OpusCustomDecoder; + +/** The mode contains all the information necessary to create an + encoder. Both the encoder and decoder need to be initialized + with exactly the same mode, otherwise the output will be + corrupted. The mode MUST NOT BE DESTROYED until the encoders and + decoders that use it are destroyed as well. + @brief Mode configuration + */ +typedef struct OpusCustomMode OpusCustomMode; + +/** Creates a new mode struct. This will be passed to an encoder or + * decoder. The mode MUST NOT BE DESTROYED until the encoders and + * decoders that use it are destroyed as well. + * @param [in] Fs int: Sampling rate (8000 to 96000 Hz) + * @param [in] frame_size int: Number of samples (per channel) to encode in each + * packet (64 - 1024, prime factorization must contain zero or more 2s, 3s, or 5s and no other primes) + * @param [out] error int*: Returned error code (if NULL, no error will be returned) + * @return A newly created mode + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomMode *opus_custom_mode_create(opus_int32 Fs, int frame_size, int *error); + +/** Destroys a mode struct. Only call this after all encoders and + * decoders using this mode are destroyed as well. + * @param [in] mode OpusCustomMode*: Mode to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_mode_destroy(OpusCustomMode *mode); + + +#if !defined(OPUS_BUILD) || defined(CELT_ENCODER_C) + +/* Encoder */ +/** Gets the size of an OpusCustomEncoder structure. + * @param [in] mode OpusCustomMode *: Mode configuration + * @param [in] channels int: Number of channels + * @returns size + */ +OPUS_CUSTOM_EXPORT_STATIC OPUS_WARN_UNUSED_RESULT int opus_custom_encoder_get_size( + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1); + +#if defined(CUSTOM_MODES) || defined(ENABLE_OPUS_CUSTOM_API) +/** Initializes a previously allocated encoder state + * The memory pointed to by st must be the size returned by opus_custom_encoder_get_size. + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_custom_encoder_create(),opus_custom_encoder_get_size() + * To reset a previously initialized state use the OPUS_RESET_STATE CTL. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] mode OpusCustomMode *: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * decoder) + * @param [in] channels int: Number of channels + * @return OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT int opus_custom_encoder_init( + OpusCustomEncoder *st, + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); +# endif +#endif + + +/** Creates a new encoder state. Each stream needs its own encoder + * state (can't be shared across simultaneous streams). + * @param [in] mode OpusCustomMode*: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * decoder) + * @param [in] channels int: Number of channels + * @param [out] error int*: Returns an error code + * @return Newly created encoder state. +*/ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomEncoder *opus_custom_encoder_create( + const OpusCustomMode *mode, + int channels, + int *error +) OPUS_ARG_NONNULL(1); + + +/** Destroys an encoder state. + * @param[in] st OpusCustomEncoder*: State to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_encoder_destroy(OpusCustomEncoder *st); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm float*: PCM audio in float format, with a normal range of +/-1.0. + * Samples with a range beyond +/-1.0 are supported but will + * be clipped by decoders using the integer API and should + * only be used if it is known that the far end supports + * extended dynamic range. There must be exactly + * frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode_float( + OpusCustomEncoder *st, + const float *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm opus_int16*: PCM audio in signed 16-bit format (native endian). + * There must be exactly frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode( + OpusCustomEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a frame of audio. + * @param [in] st OpusCustomEncoder*: Encoder state + * @param [in] pcm opus_int32*: PCM audio in signed 32-bit format (native endian) representing (or slightly exceeding) 24-bit values. + * There must be exactly frame_size samples per channel. + * @param [in] frame_size int: Number of samples per frame of input signal + * @param [out] compressed char *: The compressed data is written here. This may not alias pcm and must be at least maxCompressedBytes long. + * @param [in] maxCompressedBytes int: Maximum number of bytes to use for compressing the frame + * (can change from one frame to another) + * @return Number of bytes written to "compressed". + * If negative, an error has occurred (see error codes). It is IMPORTANT that + * the length returned be somehow transmitted to the decoder. Otherwise, no + * decoding is possible. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_encode24( + OpusCustomEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *compressed, + int maxCompressedBytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus custom encoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @see opus_encoderctls + */ +OPUS_CUSTOM_EXPORT int opus_custom_encoder_ctl(OpusCustomEncoder * OPUS_RESTRICT st, int request, ...) OPUS_ARG_NONNULL(1); + + +#if !defined(OPUS_BUILD) || defined(CELT_DECODER_C) +/* Decoder */ + +/** Gets the size of an OpusCustomDecoder structure. + * @param [in] mode OpusCustomMode *: Mode configuration + * @param [in] channels int: Number of channels + * @returns size + */ +OPUS_CUSTOM_EXPORT_STATIC OPUS_WARN_UNUSED_RESULT int opus_custom_decoder_get_size( + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1); + +/** Initializes a previously allocated decoder state + * The memory pointed to by st must be the size returned by opus_custom_decoder_get_size. + * This is intended for applications which use their own allocator instead of malloc. + * @see opus_custom_decoder_create(),opus_custom_decoder_get_size() + * To reset a previously initialized state use the OPUS_RESET_STATE CTL. + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] mode OpusCustomMode *: Contains all the information about the characteristics of + * the stream (must be the same characteristics as used for the + * encoder) + * @param [in] channels int: Number of channels + * @return OPUS_OK Success or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT_STATIC int opus_custom_decoder_init( + OpusCustomDecoder *st, + const OpusCustomMode *mode, + int channels +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2); + +#endif + + +/** Creates a new decoder state. Each stream needs its own decoder state (can't + * be shared across simultaneous streams). + * @param [in] mode OpusCustomMode: Contains all the information about the characteristics of the + * stream (must be the same characteristics as used for the encoder) + * @param [in] channels int: Number of channels + * @param [out] error int*: Returns an error code + * @return Newly created decoder state. + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT OpusCustomDecoder *opus_custom_decoder_create( + const OpusCustomMode *mode, + int channels, + int *error +) OPUS_ARG_NONNULL(1); + +/** Destroys a decoder state. + * @param[in] st OpusCustomDecoder*: State to be freed. + */ +OPUS_CUSTOM_EXPORT void opus_custom_decoder_destroy(OpusCustomDecoder *st); + +/** Decode an opus custom frame with floating point output + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm float*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(float) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode_float( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + float *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an opus custom frame + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm opus_int16*: Output signal (interleaved if 2 channels). length + * is frame_size*channels*sizeof(opus_int16) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + opus_int16 *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode an opus custom frame + * @param [in] st OpusCustomDecoder*: Decoder state + * @param [in] data char*: Input payload. Use a NULL pointer to indicate packet loss + * @param [in] len int: Number of bytes in payload + * @param [out] pcm opus_int32*: Output signal (interleaved if 2 channels) representing (or slightly exceeding) 24-bit values. length + * is frame_size*channels*sizeof(opus_int32) + * @param [in] frame_size Number of samples per channel of available space in *pcm. + * @returns Number of decoded samples or @ref opus_errorcodes + */ +OPUS_CUSTOM_EXPORT OPUS_WARN_UNUSED_RESULT int opus_custom_decode24( + OpusCustomDecoder *st, + const unsigned char *data, + int len, + opus_int32 *pcm, + int frame_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on an Opus custom decoder. + * + * Generally the request and subsequent arguments are generated + * by a convenience macro. + * @see opus_genericctls + */ +OPUS_CUSTOM_EXPORT int opus_custom_decoder_ctl(OpusCustomDecoder * OPUS_RESTRICT st, int request, ...) OPUS_ARG_NONNULL(1); + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_CUSTOM_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_defines.h b/native/third_party/ucrt64/include/opus/opus_defines.h new file mode 100644 index 0000000..0d103a7 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_defines.h @@ -0,0 +1,867 @@ +/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited + Written by Jean-Marc Valin and Koen Vos */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_defines.h + * @brief Opus reference implementation constants + */ + +#ifndef OPUS_DEFINES_H +#define OPUS_DEFINES_H + +#include "opus_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup opus_errorcodes Error codes + * @{ + */ +/** No error @hideinitializer*/ +#define OPUS_OK 0 +/** One or more invalid/out of range arguments @hideinitializer*/ +#define OPUS_BAD_ARG -1 +/** Not enough bytes allocated in the buffer @hideinitializer*/ +#define OPUS_BUFFER_TOO_SMALL -2 +/** An internal error was detected @hideinitializer*/ +#define OPUS_INTERNAL_ERROR -3 +/** The compressed data passed is corrupted @hideinitializer*/ +#define OPUS_INVALID_PACKET -4 +/** Invalid/unsupported request number @hideinitializer*/ +#define OPUS_UNIMPLEMENTED -5 +/** An encoder or decoder structure is invalid or already freed @hideinitializer*/ +#define OPUS_INVALID_STATE -6 +/** Memory allocation has failed @hideinitializer*/ +#define OPUS_ALLOC_FAIL -7 +/**@}*/ + +/** @cond OPUS_INTERNAL_DOC */ +/**Export control for opus functions */ + +#ifndef OPUS_EXPORT +# if defined(_WIN32) +# if defined(OPUS_BUILD) && defined(DLL_EXPORT) +# define OPUS_EXPORT __declspec(dllexport) +# else +# define OPUS_EXPORT +# endif +# elif defined(__GNUC__) && defined(OPUS_BUILD) +# define OPUS_EXPORT __attribute__ ((visibility ("default"))) +# else +# define OPUS_EXPORT +# endif +#endif + +# if !defined(OPUS_GNUC_PREREQ) +# if defined(__GNUC__)&&defined(__GNUC_MINOR__) +# define OPUS_GNUC_PREREQ(_maj,_min) \ + ((__GNUC__<<16)+__GNUC_MINOR__>=((_maj)<<16)+(_min)) +# else +# define OPUS_GNUC_PREREQ(_maj,_min) 0 +# endif +# endif + +#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) ) +# if OPUS_GNUC_PREREQ(3,0) +# define OPUS_RESTRICT __restrict__ +# elif (defined(_MSC_VER) && _MSC_VER >= 1400) +# define OPUS_RESTRICT __restrict +# else +# define OPUS_RESTRICT +# endif +#else +# define OPUS_RESTRICT restrict +#endif + +#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) ) +# if OPUS_GNUC_PREREQ(2,7) +# define OPUS_INLINE __inline__ +# elif (defined(_MSC_VER)) +# define OPUS_INLINE __inline +# else +# define OPUS_INLINE +# endif +#else +# define OPUS_INLINE inline +#endif + +/**Warning attributes for opus functions + * NONNULL is not used in OPUS_BUILD to avoid the compiler optimizing out + * some paranoid null checks. */ +#if defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4) +# define OPUS_WARN_UNUSED_RESULT __attribute__ ((__warn_unused_result__)) +#else +# define OPUS_WARN_UNUSED_RESULT +#endif +#if !defined(OPUS_BUILD) && defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4) +# define OPUS_ARG_NONNULL(_x) __attribute__ ((__nonnull__(_x))) +#else +# define OPUS_ARG_NONNULL(_x) +#endif + +/** These are the actual Encoder CTL ID numbers. + * They should not be used directly by applications. + * In general, SETs should be even and GETs should be odd.*/ +#define OPUS_SET_APPLICATION_REQUEST 4000 +#define OPUS_GET_APPLICATION_REQUEST 4001 +#define OPUS_SET_BITRATE_REQUEST 4002 +#define OPUS_GET_BITRATE_REQUEST 4003 +#define OPUS_SET_MAX_BANDWIDTH_REQUEST 4004 +#define OPUS_GET_MAX_BANDWIDTH_REQUEST 4005 +#define OPUS_SET_VBR_REQUEST 4006 +#define OPUS_GET_VBR_REQUEST 4007 +#define OPUS_SET_BANDWIDTH_REQUEST 4008 +#define OPUS_GET_BANDWIDTH_REQUEST 4009 +#define OPUS_SET_COMPLEXITY_REQUEST 4010 +#define OPUS_GET_COMPLEXITY_REQUEST 4011 +#define OPUS_SET_INBAND_FEC_REQUEST 4012 +#define OPUS_GET_INBAND_FEC_REQUEST 4013 +#define OPUS_SET_PACKET_LOSS_PERC_REQUEST 4014 +#define OPUS_GET_PACKET_LOSS_PERC_REQUEST 4015 +#define OPUS_SET_DTX_REQUEST 4016 +#define OPUS_GET_DTX_REQUEST 4017 +#define OPUS_SET_VBR_CONSTRAINT_REQUEST 4020 +#define OPUS_GET_VBR_CONSTRAINT_REQUEST 4021 +#define OPUS_SET_FORCE_CHANNELS_REQUEST 4022 +#define OPUS_GET_FORCE_CHANNELS_REQUEST 4023 +#define OPUS_SET_SIGNAL_REQUEST 4024 +#define OPUS_GET_SIGNAL_REQUEST 4025 +#define OPUS_GET_LOOKAHEAD_REQUEST 4027 +/* #define OPUS_RESET_STATE 4028 */ +#define OPUS_GET_SAMPLE_RATE_REQUEST 4029 +#define OPUS_GET_FINAL_RANGE_REQUEST 4031 +#define OPUS_GET_PITCH_REQUEST 4033 +#define OPUS_SET_GAIN_REQUEST 4034 +#define OPUS_GET_GAIN_REQUEST 4045 /* Should have been 4035 */ +#define OPUS_SET_LSB_DEPTH_REQUEST 4036 +#define OPUS_GET_LSB_DEPTH_REQUEST 4037 +#define OPUS_GET_LAST_PACKET_DURATION_REQUEST 4039 +#define OPUS_SET_EXPERT_FRAME_DURATION_REQUEST 4040 +#define OPUS_GET_EXPERT_FRAME_DURATION_REQUEST 4041 +#define OPUS_SET_PREDICTION_DISABLED_REQUEST 4042 +#define OPUS_GET_PREDICTION_DISABLED_REQUEST 4043 +/* Don't use 4045, it's already taken by OPUS_GET_GAIN_REQUEST */ +#define OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST 4046 +#define OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST 4047 +#define OPUS_GET_IN_DTX_REQUEST 4049 +#define OPUS_SET_DRED_DURATION_REQUEST 4050 +#define OPUS_GET_DRED_DURATION_REQUEST 4051 +#define OPUS_SET_DNN_BLOB_REQUEST 4052 +/*#define OPUS_GET_DNN_BLOB_REQUEST 4053 */ +#define OPUS_SET_OSCE_BWE_REQUEST 4054 +#define OPUS_GET_OSCE_BWE_REQUEST 4055 +#define OPUS_SET_QEXT_REQUEST 4056 +#define OPUS_GET_QEXT_REQUEST 4057 +#define OPUS_SET_IGNORE_EXTENSIONS_REQUEST 4058 +#define OPUS_GET_IGNORE_EXTENSIONS_REQUEST 4059 + +/** Defines for the presence of extended APIs. */ +#define OPUS_HAVE_OPUS_PROJECTION_H + +/* Macros to trigger compilation errors when the wrong types are provided to a CTL */ +#define opus_check_int(x) (((void)((x) == (opus_int32)0)), (opus_int32)(x)) + +#ifdef DISABLE_PTR_CHECK +/* Disable checks to prevent ubsan from complaining about NULL checks + in test_opus_api. */ +#define opus_check_int_ptr(ptr) (ptr) +#define opus_check_uint_ptr(ptr) (ptr) +#define opus_check_uint8_ptr(ptr) (ptr) +#define opus_check_val16_ptr(ptr) (ptr) +#define opus_check_void_ptr(ptr) (ptr) +#else +#define opus_check_int_ptr(ptr) ((ptr) + ((ptr) - (opus_int32*)(ptr))) +#define opus_check_uint_ptr(ptr) ((ptr) + ((ptr) - (opus_uint32*)(ptr))) +#define opus_check_uint8_ptr(ptr) ((ptr) + ((ptr) - (opus_uint8*)(ptr))) +#define opus_check_val16_ptr(ptr) ((ptr) + ((ptr) - (opus_val16*)(ptr))) +#define opus_check_void_ptr(x) ((void)((void *)0 == (x)), (x)) +#endif +/** @endcond */ + +/** @defgroup opus_ctlvalues Pre-defined values for CTL interface + * @see opus_genericctls, opus_encoderctls + * @{ + */ +/* Values for the various encoder CTLs */ +#define OPUS_AUTO -1000 /**opus_int32: Allowed values: 0-10, inclusive. + * + * @hideinitializer */ +#define OPUS_SET_COMPLEXITY(x) OPUS_SET_COMPLEXITY_REQUEST, opus_check_int(x) +/** Gets the encoder's complexity configuration. + * @see OPUS_SET_COMPLEXITY + * @param[out] x opus_int32 *: Returns a value in the range 0-10, + * inclusive. + * @hideinitializer */ +#define OPUS_GET_COMPLEXITY(x) OPUS_GET_COMPLEXITY_REQUEST, opus_check_int_ptr(x) + +/** Configures the bitrate in the encoder. + * Rates from 500 to 512000 bits per second are meaningful, as well as the + * special values #OPUS_AUTO and #OPUS_BITRATE_MAX. + * The value #OPUS_BITRATE_MAX can be used to cause the codec to use as much + * rate as it can, which is useful for controlling the rate by adjusting the + * output buffer size. + * @see OPUS_GET_BITRATE + * @param[in] x opus_int32: Bitrate in bits per second. The default + * is determined based on the number of + * channels and the input sampling rate. + * @hideinitializer */ +#define OPUS_SET_BITRATE(x) OPUS_SET_BITRATE_REQUEST, opus_check_int(x) +/** Gets the encoder's bitrate configuration. + * @see OPUS_SET_BITRATE + * @param[out] x opus_int32 *: Returns the bitrate in bits per second. + * The default is determined based on the + * number of channels and the input + * sampling rate. + * @hideinitializer */ +#define OPUS_GET_BITRATE(x) OPUS_GET_BITRATE_REQUEST, opus_check_int_ptr(x) + +/** Enables or disables variable bitrate (VBR) in the encoder. + * The configured bitrate may not be met exactly because frames must + * be an integer number of bytes in length. + * @see OPUS_GET_VBR + * @see OPUS_SET_VBR_CONSTRAINT + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Hard CBR. For LPC/hybrid modes at very low bit-rate, this can + * cause noticeable quality degradation.
+ *
1
VBR (default). The exact type of VBR is controlled by + * #OPUS_SET_VBR_CONSTRAINT.
+ *
+ * @hideinitializer */ +#define OPUS_SET_VBR(x) OPUS_SET_VBR_REQUEST, opus_check_int(x) +/** Determine if variable bitrate (VBR) is enabled in the encoder. + * @see OPUS_SET_VBR + * @see OPUS_GET_VBR_CONSTRAINT + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Hard CBR.
+ *
1
VBR (default). The exact type of VBR may be retrieved via + * #OPUS_GET_VBR_CONSTRAINT.
+ *
+ * @hideinitializer */ +#define OPUS_GET_VBR(x) OPUS_GET_VBR_REQUEST, opus_check_int_ptr(x) + +/** Enables or disables constrained VBR in the encoder. + * This setting is ignored when the encoder is in CBR mode. + * @warning Only the MDCT mode of Opus currently heeds the constraint. + * Speech mode ignores it completely, hybrid mode may fail to obey it + * if the LPC layer uses more bitrate than the constraint would have + * permitted. + * @see OPUS_GET_VBR_CONSTRAINT + * @see OPUS_SET_VBR + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Unconstrained VBR.
+ *
1
Constrained VBR (default). This creates a maximum of one + * frame of buffering delay assuming a transport with a + * serialization speed of the nominal bitrate.
+ *
+ * @hideinitializer */ +#define OPUS_SET_VBR_CONSTRAINT(x) OPUS_SET_VBR_CONSTRAINT_REQUEST, opus_check_int(x) +/** Determine if constrained VBR is enabled in the encoder. + * @see OPUS_SET_VBR_CONSTRAINT + * @see OPUS_GET_VBR + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Unconstrained VBR.
+ *
1
Constrained VBR (default).
+ *
+ * @hideinitializer */ +#define OPUS_GET_VBR_CONSTRAINT(x) OPUS_GET_VBR_CONSTRAINT_REQUEST, opus_check_int_ptr(x) + +/** Configures mono/stereo forcing in the encoder. + * This can force the encoder to produce packets encoded as either mono or + * stereo, regardless of the format of the input audio. This is useful when + * the caller knows that the input signal is currently a mono source embedded + * in a stereo stream. + * @see OPUS_GET_FORCE_CHANNELS + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
Not forced (default)
+ *
1
Forced mono
+ *
2
Forced stereo
+ *
+ * @hideinitializer */ +#define OPUS_SET_FORCE_CHANNELS(x) OPUS_SET_FORCE_CHANNELS_REQUEST, opus_check_int(x) +/** Gets the encoder's forced channel configuration. + * @see OPUS_SET_FORCE_CHANNELS + * @param[out] x opus_int32 *: + *
+ *
#OPUS_AUTO
Not forced (default)
+ *
1
Forced mono
+ *
2
Forced stereo
+ *
+ * @hideinitializer */ +#define OPUS_GET_FORCE_CHANNELS(x) OPUS_GET_FORCE_CHANNELS_REQUEST, opus_check_int_ptr(x) + +/** Configures the maximum bandpass that the encoder will select automatically. + * Applications should normally use this instead of #OPUS_SET_BANDWIDTH + * (leaving that set to the default, #OPUS_AUTO). This allows the + * application to set an upper bound based on the type of input it is + * providing, but still gives the encoder the freedom to reduce the bandpass + * when the bitrate becomes too low, for better overall quality. + * @see OPUS_GET_MAX_BANDWIDTH + * @param[in] x opus_int32: Allowed values: + *
+ *
OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
OPUS_BANDWIDTH_FULLBAND
20 kHz passband (default)
+ *
+ * @hideinitializer */ +#define OPUS_SET_MAX_BANDWIDTH(x) OPUS_SET_MAX_BANDWIDTH_REQUEST, opus_check_int(x) + +/** Gets the encoder's configured maximum allowed bandpass. + * @see OPUS_SET_MAX_BANDWIDTH + * @param[out] x opus_int32 *: Allowed values: + *
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband (default)
+ *
+ * @hideinitializer */ +#define OPUS_GET_MAX_BANDWIDTH(x) OPUS_GET_MAX_BANDWIDTH_REQUEST, opus_check_int_ptr(x) + +/** Sets the encoder's bandpass to a specific value. + * This prevents the encoder from automatically selecting the bandpass based + * on the available bitrate. If an application knows the bandpass of the input + * audio it is providing, it should normally use #OPUS_SET_MAX_BANDWIDTH + * instead, which still gives the encoder the freedom to reduce the bandpass + * when the bitrate becomes too low, for better overall quality. + * @see OPUS_GET_BANDWIDTH + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband
+ *
+ * @hideinitializer */ +#define OPUS_SET_BANDWIDTH(x) OPUS_SET_BANDWIDTH_REQUEST, opus_check_int(x) + +/** Configures the type of signal being encoded. + * This is a hint which helps the encoder's mode selection. + * @see OPUS_GET_SIGNAL + * @param[in] x opus_int32: Allowed values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_SIGNAL_VOICE
Bias thresholds towards choosing LPC or Hybrid modes.
+ *
#OPUS_SIGNAL_MUSIC
Bias thresholds towards choosing MDCT modes.
+ *
+ * @hideinitializer */ +#define OPUS_SET_SIGNAL(x) OPUS_SET_SIGNAL_REQUEST, opus_check_int(x) +/** Gets the encoder's configured signal type. + * @see OPUS_SET_SIGNAL + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_SIGNAL_VOICE
Bias thresholds towards choosing LPC or Hybrid modes.
+ *
#OPUS_SIGNAL_MUSIC
Bias thresholds towards choosing MDCT modes.
+ *
+ * @hideinitializer */ +#define OPUS_GET_SIGNAL(x) OPUS_GET_SIGNAL_REQUEST, opus_check_int_ptr(x) + + +/** Configures the encoder's intended application. + * The initial value is a mandatory argument to the encoder_create function. + * @see OPUS_GET_APPLICATION + * @param[in] x opus_int32: Returns one of the following values: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @hideinitializer */ +#define OPUS_SET_APPLICATION(x) OPUS_SET_APPLICATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured application. + * @see OPUS_SET_APPLICATION + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @hideinitializer */ +#define OPUS_GET_APPLICATION(x) OPUS_GET_APPLICATION_REQUEST, opus_check_int_ptr(x) + +/** Gets the total samples of delay added by the entire codec. + * This can be queried by the encoder and then the provided number of samples can be + * skipped on from the start of the decoder's output to provide time aligned input + * and output. From the perspective of a decoding application the real data begins this many + * samples late. + * + * The decoder contribution to this delay is identical for all decoders, but the + * encoder portion of the delay may vary from implementation to implementation, + * version to version, or even depend on the encoder's initial configuration. + * Applications needing delay compensation should call this CTL rather than + * hard-coding a value. + * @param[out] x opus_int32 *: Number of lookahead samples + * @hideinitializer */ +#define OPUS_GET_LOOKAHEAD(x) OPUS_GET_LOOKAHEAD_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of inband forward error correction (FEC). + * @note This is only applicable to the LPC layer + * @see OPUS_GET_INBAND_FEC + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Disable inband FEC (default).
+ *
1
Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.
+ *
2
Inband FEC enabled, but does not necessarily switch to SILK if we have music.
+ *
+ * @hideinitializer */ +#define OPUS_SET_INBAND_FEC(x) OPUS_SET_INBAND_FEC_REQUEST, opus_check_int(x) +/** Gets encoder's configured use of inband forward error correction. + * @see OPUS_SET_INBAND_FEC + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Inband FEC disabled (default).
+ *
1
Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.
+ *
2
Inband FEC enabled, but does not necessarily switch to SILK if we have music.
+ *
+ * @hideinitializer */ +#define OPUS_GET_INBAND_FEC(x) OPUS_GET_INBAND_FEC_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's expected packet loss percentage. + * Higher values trigger progressively more loss resistant behavior in the encoder + * at the expense of quality at a given bitrate in the absence of packet loss, but + * greater quality under loss. + * @see OPUS_GET_PACKET_LOSS_PERC + * @param[in] x opus_int32: Loss percentage in the range 0-100, inclusive (default: 0). + * @hideinitializer */ +#define OPUS_SET_PACKET_LOSS_PERC(x) OPUS_SET_PACKET_LOSS_PERC_REQUEST, opus_check_int(x) +/** Gets the encoder's configured packet loss percentage. + * @see OPUS_SET_PACKET_LOSS_PERC + * @param[out] x opus_int32 *: Returns the configured loss percentage + * in the range 0-100, inclusive (default: 0). + * @hideinitializer */ +#define OPUS_GET_PACKET_LOSS_PERC(x) OPUS_GET_PACKET_LOSS_PERC_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of discontinuous transmission (DTX). + * @note This is only applicable to the LPC layer + * @see OPUS_GET_DTX + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Disable DTX (default).
+ *
1
Enabled DTX.
+ *
+ * @hideinitializer */ +#define OPUS_SET_DTX(x) OPUS_SET_DTX_REQUEST, opus_check_int(x) +/** Gets encoder's configured use of discontinuous transmission. + * @see OPUS_SET_DTX + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
DTX disabled (default).
+ *
1
DTX enabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_DTX(x) OPUS_GET_DTX_REQUEST, opus_check_int_ptr(x) +/** Configures the depth of signal being encoded. + * + * This is a hint which helps the encoder identify silence and near-silence. + * It represents the number of significant bits of linear intensity below + * which the signal contains ignorable quantization or other noise. + * + * For example, OPUS_SET_LSB_DEPTH(14) would be an appropriate setting + * for G.711 u-law input. OPUS_SET_LSB_DEPTH(16) would be appropriate + * for 16-bit linear pcm input with opus_encode_float(). + * + * When using opus_encode() instead of opus_encode_float(), or when libopus + * is compiled for fixed-point, the encoder uses the minimum of the value + * set here and the value 16. + * + * @see OPUS_GET_LSB_DEPTH + * @param[in] x opus_int32: Input precision in bits, between 8 and 24 + * (default: 24). + * @hideinitializer */ +#define OPUS_SET_LSB_DEPTH(x) OPUS_SET_LSB_DEPTH_REQUEST, opus_check_int(x) +/** Gets the encoder's configured signal depth. + * @see OPUS_SET_LSB_DEPTH + * @param[out] x opus_int32 *: Input precision in bits, between 8 and + * 24 (default: 24). + * @hideinitializer */ +#define OPUS_GET_LSB_DEPTH(x) OPUS_GET_LSB_DEPTH_REQUEST, opus_check_int_ptr(x) + +/** Configures the encoder's use of variable duration frames. + * When variable duration is enabled, the encoder is free to use a shorter frame + * size than the one requested in the opus_encode*() call. + * It is then the user's responsibility + * to verify how much audio was encoded by checking the ToC byte of the encoded + * packet. The part of the audio that was not encoded needs to be resent to the + * encoder for the next call. Do not use this option unless you really + * know what you are doing. + * @see OPUS_GET_EXPERT_FRAME_DURATION + * @param[in] x opus_int32: Allowed values: + *
+ *
OPUS_FRAMESIZE_ARG
Select frame size from the argument (default).
+ *
OPUS_FRAMESIZE_2_5_MS
Use 2.5 ms frames.
+ *
OPUS_FRAMESIZE_5_MS
Use 5 ms frames.
+ *
OPUS_FRAMESIZE_10_MS
Use 10 ms frames.
+ *
OPUS_FRAMESIZE_20_MS
Use 20 ms frames.
+ *
OPUS_FRAMESIZE_40_MS
Use 40 ms frames.
+ *
OPUS_FRAMESIZE_60_MS
Use 60 ms frames.
+ *
OPUS_FRAMESIZE_80_MS
Use 80 ms frames.
+ *
OPUS_FRAMESIZE_100_MS
Use 100 ms frames.
+ *
OPUS_FRAMESIZE_120_MS
Use 120 ms frames.
+ *
+ * @hideinitializer */ +#define OPUS_SET_EXPERT_FRAME_DURATION(x) OPUS_SET_EXPERT_FRAME_DURATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured use of variable duration frames. + * @see OPUS_SET_EXPERT_FRAME_DURATION + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
OPUS_FRAMESIZE_ARG
Select frame size from the argument (default).
+ *
OPUS_FRAMESIZE_2_5_MS
Use 2.5 ms frames.
+ *
OPUS_FRAMESIZE_5_MS
Use 5 ms frames.
+ *
OPUS_FRAMESIZE_10_MS
Use 10 ms frames.
+ *
OPUS_FRAMESIZE_20_MS
Use 20 ms frames.
+ *
OPUS_FRAMESIZE_40_MS
Use 40 ms frames.
+ *
OPUS_FRAMESIZE_60_MS
Use 60 ms frames.
+ *
OPUS_FRAMESIZE_80_MS
Use 80 ms frames.
+ *
OPUS_FRAMESIZE_100_MS
Use 100 ms frames.
+ *
OPUS_FRAMESIZE_120_MS
Use 120 ms frames.
+ *
+ * @hideinitializer */ +#define OPUS_GET_EXPERT_FRAME_DURATION(x) OPUS_GET_EXPERT_FRAME_DURATION_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, disables almost all use of prediction, making frames almost + * completely independent. This reduces quality. + * @see OPUS_GET_PREDICTION_DISABLED + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Enable prediction (default).
+ *
1
Disable prediction.
+ *
+ * @hideinitializer */ +#define OPUS_SET_PREDICTION_DISABLED(x) OPUS_SET_PREDICTION_DISABLED_REQUEST, opus_check_int(x) +/** Gets the encoder's configured prediction status. + * @see OPUS_SET_PREDICTION_DISABLED + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Prediction enabled (default).
+ *
1
Prediction disabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_PREDICTION_DISABLED(x) OPUS_GET_PREDICTION_DISABLED_REQUEST, opus_check_int_ptr(x) + +/** If non-zero, enables Deep Redundancy (DRED) and use the specified maximum number of 10-ms redundant frames + * @hideinitializer */ +#define OPUS_SET_DRED_DURATION(x) OPUS_SET_DRED_DURATION_REQUEST, opus_check_int(x) +/** Gets the encoder's configured Deep Redundancy (DRED) maximum number of frames. + * @hideinitializer */ +#define OPUS_GET_DRED_DURATION(x) OPUS_GET_DRED_DURATION_REQUEST, opus_check_int_ptr(x) + +/** Provide external DNN weights from binary object (only when explicitly built without the weights) + * @hideinitializer */ +#define OPUS_SET_DNN_BLOB(data, len) OPUS_SET_DNN_BLOB_REQUEST, opus_check_void_ptr(data), opus_check_int(len) + +/** If set to 1, enables quality extension (QEXT), otherwise disables it (default). Warning: This will *hurt* audio quality unless operating at + a very high bitrate. + * @hideinitializer */ +#define OPUS_SET_QEXT(x) OPUS_SET_QEXT_REQUEST, opus_check_int(x) +/** Gets the encoder's configured quality extension (QEXT). + * @hideinitializer */ +#define OPUS_GET_QEXT(x) OPUS_GET_QEXT_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_genericctls Generic CTLs + * + * These macros are used with the \c opus_decoder_ctl and + * \c opus_encoder_ctl calls to generate a particular + * request. + * + * When called on an \c OpusDecoder they apply to that + * particular decoder instance. When called on an + * \c OpusEncoder they apply to the corresponding setting + * on that encoder instance, if present. + * + * Some usage examples: + * + * @code + * int ret; + * opus_int32 pitch; + * ret = opus_decoder_ctl(dec_ctx, OPUS_GET_PITCH(&pitch)); + * if (ret == OPUS_OK) return ret; + * + * opus_encoder_ctl(enc_ctx, OPUS_RESET_STATE); + * opus_decoder_ctl(dec_ctx, OPUS_RESET_STATE); + * + * opus_int32 enc_bw, dec_bw; + * opus_encoder_ctl(enc_ctx, OPUS_GET_BANDWIDTH(&enc_bw)); + * opus_decoder_ctl(dec_ctx, OPUS_GET_BANDWIDTH(&dec_bw)); + * if (enc_bw != dec_bw) { + * printf("packet bandwidth mismatch!\n"); + * } + * @endcode + * + * @see opus_encoder, opus_decoder_ctl, opus_encoder_ctl, opus_decoderctls, opus_encoderctls + * @{ + */ + +/** Resets the codec state to be equivalent to a freshly initialized state. + * This should be called when switching streams in order to prevent + * the back to back decoding from giving different results from + * one at a time decoding. + * @hideinitializer */ +#define OPUS_RESET_STATE 4028 + +/** Gets the final state of the codec's entropy coder. + * This is used for testing purposes, + * The encoder and decoder state should be identical after coding a payload + * (assuming no data corruption or software bugs) + * + * @param[out] x opus_uint32 *: Entropy coder state + * + * @hideinitializer */ +#define OPUS_GET_FINAL_RANGE(x) OPUS_GET_FINAL_RANGE_REQUEST, opus_check_uint_ptr(x) + +/** Gets the encoder's configured bandpass or the decoder's last bandpass. + * @see OPUS_SET_BANDWIDTH + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
#OPUS_AUTO
(default)
+ *
#OPUS_BANDWIDTH_NARROWBAND
4 kHz passband
+ *
#OPUS_BANDWIDTH_MEDIUMBAND
6 kHz passband
+ *
#OPUS_BANDWIDTH_WIDEBAND
8 kHz passband
+ *
#OPUS_BANDWIDTH_SUPERWIDEBAND
12 kHz passband
+ *
#OPUS_BANDWIDTH_FULLBAND
20 kHz passband
+ *
+ * @hideinitializer */ +#define OPUS_GET_BANDWIDTH(x) OPUS_GET_BANDWIDTH_REQUEST, opus_check_int_ptr(x) + +/** Gets the sampling rate the encoder or decoder was initialized with. + * This simply returns the Fs value passed to opus_encoder_init() + * or opus_decoder_init(). + * @param[out] x opus_int32 *: Sampling rate of encoder or decoder. + * @hideinitializer + */ +#define OPUS_GET_SAMPLE_RATE(x) OPUS_GET_SAMPLE_RATE_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, disables the use of phase inversion for intensity stereo, + * improving the quality of mono downmixes, but slightly reducing normal + * stereo quality. Disabling phase inversion in the decoder does not comply + * with RFC 6716, although it does not cause any interoperability issue and + * is expected to become part of the Opus standard once RFC 6716 is updated + * by draft-ietf-codec-opus-update. + * @see OPUS_GET_PHASE_INVERSION_DISABLED + * @param[in] x opus_int32: Allowed values: + *
+ *
0
Enable phase inversion (default).
+ *
1
Disable phase inversion.
+ *
+ * @hideinitializer */ +#define OPUS_SET_PHASE_INVERSION_DISABLED(x) OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST, opus_check_int(x) +/** Gets the encoder's configured phase inversion status. + * @see OPUS_SET_PHASE_INVERSION_DISABLED + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
Stereo phase inversion enabled (default).
+ *
1
Stereo phase inversion disabled.
+ *
+ * @hideinitializer */ +#define OPUS_GET_PHASE_INVERSION_DISABLED(x) OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST, opus_check_int_ptr(x) +/** Gets the DTX state of the encoder. + * Returns whether the last encoded frame was either a comfort noise update + * during DTX or not encoded because of DTX. + * @param[out] x opus_int32 *: Returns one of the following values: + *
+ *
0
The encoder is not in DTX.
+ *
1
The encoder is in DTX.
+ *
+ * @hideinitializer */ +#define OPUS_GET_IN_DTX(x) OPUS_GET_IN_DTX_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_decoderctls Decoder related CTLs + * @see opus_genericctls, opus_encoderctls, opus_decoder + * @{ + */ + +/** Configures decoder gain adjustment. + * Scales the decoded output by a factor specified in Q8 dB units. + * This has a maximum range of -32768 to 32767 inclusive, and returns + * OPUS_BAD_ARG otherwise. The default is zero indicating no adjustment. + * This setting survives decoder reset. + * + * gain = pow(10, x/(20.0*256)) + * + * @param[in] x opus_int32: Amount to scale PCM signal by in Q8 dB units. + * @hideinitializer */ +#define OPUS_SET_GAIN(x) OPUS_SET_GAIN_REQUEST, opus_check_int(x) +/** Gets the decoder's configured gain adjustment. @see OPUS_SET_GAIN + * + * @param[out] x opus_int32 *: Amount to scale PCM signal by in Q8 dB units. + * @hideinitializer */ +#define OPUS_GET_GAIN(x) OPUS_GET_GAIN_REQUEST, opus_check_int_ptr(x) + +/** Gets the duration (in samples) of the last packet successfully decoded or concealed. + * @param[out] x opus_int32 *: Number of samples (at current sampling rate). + * @hideinitializer */ +#define OPUS_GET_LAST_PACKET_DURATION(x) OPUS_GET_LAST_PACKET_DURATION_REQUEST, opus_check_int_ptr(x) + +/** Gets the pitch of the last decoded frame, if available. + * This can be used for any post-processing algorithm requiring the use of pitch, + * e.g. time stretching/shortening. If the last frame was not voiced, or if the + * pitch was not coded in the frame, then zero is returned. + * + * This CTL is only implemented for decoder instances. + * + * @param[out] x opus_int32 *: pitch period at 48 kHz (or 0 if not available) + * + * @hideinitializer */ +#define OPUS_GET_PITCH(x) OPUS_GET_PITCH_REQUEST, opus_check_int_ptr(x) + +/** Enables blind bandwidth extension for wideband signals if decoding sampling rate is 48 kHz. + * @param[in] x opus_int32 : 1 enables bandwidth extension, 0 disables it. + * The default is 0. + * + * @hideinitializer */ + #define OPUS_SET_OSCE_BWE(x) OPUS_SET_OSCE_BWE_REQUEST, opus_check_int(x) +/** Gets blind bandwidth extension flag for wideband signals if decoding sampling rate is 48 kHz. + * @param[out] x opus_int32 *: 1 if bwe enabled, 0 if disabled. + * + * @hideinitializer */ + #define OPUS_GET_OSCE_BWE(x) OPUS_GET_OSCE_BWE_REQUEST, opus_check_int_ptr(x) + +/** If set to 1, the decoder will ignore all extensions found in the padding area + * (does not affect DRED, which is decoded separately). + * @hideinitializer */ +#define OPUS_SET_IGNORE_EXTENSIONS(x) OPUS_SET_IGNORE_EXTENSIONS_REQUEST, opus_check_int(x) +/** Gets whether the decoder is ignoring extensions. + * @hideinitializer */ +#define OPUS_GET_IGNORE_EXTENSIONS(x) OPUS_GET_IGNORE_EXTENSIONS_REQUEST, opus_check_int_ptr(x) + +/**@}*/ + +/** @defgroup opus_libinfo Opus library information functions + * @{ + */ + +/** Converts an opus error code into a human readable string. + * + * @param[in] error int: Error number + * @returns Error string + */ +OPUS_EXPORT const char *opus_strerror(int error); + +/** Gets the libopus version string. + * + * Applications may look for the substring "-fixed" in the version string to + * determine whether they have a fixed-point or floating-point build at + * runtime. + * + * @returns Version string + */ +OPUS_EXPORT const char *opus_get_version_string(void); +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_DEFINES_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_multistream.h b/native/third_party/ucrt64/include/opus/opus_multistream.h new file mode 100644 index 0000000..9e630b2 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_multistream.h @@ -0,0 +1,736 @@ +/* Copyright (c) 2011 Xiph.Org Foundation + Written by Jean-Marc Valin */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_multistream.h + * @brief Opus reference implementation multistream API + */ + +#ifndef OPUS_MULTISTREAM_H +#define OPUS_MULTISTREAM_H + +#include "opus.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @cond OPUS_INTERNAL_DOC */ + +/** Macros to trigger compilation errors when the wrong types are provided to a + * CTL. */ +/**@{*/ +#define opus_check_encstate_ptr(ptr) ((ptr) + ((ptr) - (OpusEncoder**)(ptr))) +#define opus_check_decstate_ptr(ptr) ((ptr) + ((ptr) - (OpusDecoder**)(ptr))) +/**@}*/ + +/** These are the actual encoder and decoder CTL ID numbers. + * They should not be used directly by applications. + * In general, SETs should be even and GETs should be odd.*/ +/**@{*/ +#define OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST 5120 +#define OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST 5122 +/**@}*/ + +/** @endcond */ + +/** @defgroup opus_multistream_ctls Multistream specific encoder and decoder CTLs + * + * These are convenience macros that are specific to the + * opus_multistream_encoder_ctl() and opus_multistream_decoder_ctl() + * interface. + * The CTLs from @ref opus_genericctls, @ref opus_encoderctls, and + * @ref opus_decoderctls may be applied to a multistream encoder or decoder as + * well. + * In addition, you may retrieve the encoder or decoder state for an specific + * stream via #OPUS_MULTISTREAM_GET_ENCODER_STATE or + * #OPUS_MULTISTREAM_GET_DECODER_STATE and apply CTLs to it individually. + */ +/**@{*/ + +/** Gets the encoder state for an individual stream of a multistream encoder. + * @param[in] x opus_int32: The index of the stream whose encoder you + * wish to retrieve. + * This must be non-negative and less than + * the streams parameter used + * to initialize the encoder. + * @param[out] y OpusEncoder**: Returns a pointer to the given + * encoder state. + * @retval OPUS_BAD_ARG The index of the requested stream was out of range. + * @hideinitializer + */ +#define OPUS_MULTISTREAM_GET_ENCODER_STATE(x,y) OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST, opus_check_int(x), opus_check_encstate_ptr(y) + +/** Gets the decoder state for an individual stream of a multistream decoder. + * @param[in] x opus_int32: The index of the stream whose decoder you + * wish to retrieve. + * This must be non-negative and less than + * the streams parameter used + * to initialize the decoder. + * @param[out] y OpusDecoder**: Returns a pointer to the given + * decoder state. + * @retval OPUS_BAD_ARG The index of the requested stream was out of range. + * @hideinitializer + */ +#define OPUS_MULTISTREAM_GET_DECODER_STATE(x,y) OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST, opus_check_int(x), opus_check_decstate_ptr(y) + +/**@}*/ + +/** @defgroup opus_multistream Opus Multistream API + * @{ + * + * The multistream API allows individual Opus streams to be combined into a + * single packet, enabling support for up to 255 channels. Unlike an + * elementary Opus stream, the encoder and decoder must negotiate the channel + * configuration before the decoder can successfully interpret the data in the + * packets produced by the encoder. Some basic information, such as packet + * duration, can be computed without any special negotiation. + * + * The format for multistream Opus packets is defined in + * RFC 7845 + * and is based on the self-delimited Opus framing described in Appendix B of + * RFC 6716. + * Normal Opus packets are just a degenerate case of multistream Opus packets, + * and can be encoded or decoded with the multistream API by setting + * streams to 1 when initializing the encoder or + * decoder. + * + * Multistream Opus streams can contain up to 255 elementary Opus streams. + * These may be either "uncoupled" or "coupled", indicating that the decoder + * is configured to decode them to either 1 or 2 channels, respectively. + * The streams are ordered so that all coupled streams appear at the + * beginning. + * + * A mapping table defines which decoded channel i + * should be used for each input/output (I/O) channel j. This table is + * typically provided as an unsigned char array. + * Let i = mapping[j] be the index for I/O channel j. + * If i < 2*coupled_streams, then I/O channel j is + * encoded as the left channel of stream (i/2) if i + * is even, or as the right channel of stream (i/2) if + * i is odd. Otherwise, I/O channel j is encoded as + * mono in stream (i - coupled_streams), unless it has the special + * value 255, in which case it is omitted from the encoding entirely (the + * decoder will reproduce it as silence). Each value i must either + * be the special value 255 or be less than streams + coupled_streams. + * + * The output channels specified by the encoder + * should use the + * Vorbis + * channel ordering. A decoder may wish to apply an additional permutation + * to the mapping the encoder used to achieve a different output channel + * order (e.g. for outputting in WAV order). + * + * Each multistream packet contains an Opus packet for each stream, and all of + * the Opus packets in a single multistream packet must have the same + * duration. Therefore the duration of a multistream packet can be extracted + * from the TOC sequence of the first stream, which is located at the + * beginning of the packet, just like an elementary Opus stream: + * + * @code + * int nb_samples; + * int nb_frames; + * nb_frames = opus_packet_get_nb_frames(data, len); + * if (nb_frames < 1) + * return nb_frames; + * nb_samples = opus_packet_get_samples_per_frame(data, 48000) * nb_frames; + * @endcode + * + * The general encoding and decoding process proceeds exactly the same as in + * the normal @ref opus_encoder and @ref opus_decoder APIs. + * See their documentation for an overview of how to use the corresponding + * multistream functions. + */ + +/** Opus multistream encoder state. + * This contains the complete state of a multistream Opus encoder. + * It is position independent and can be freely copied. + * @see opus_multistream_encoder_create + * @see opus_multistream_encoder_init + */ +typedef struct OpusMSEncoder OpusMSEncoder; + +/** Opus multistream decoder state. + * This contains the complete state of a multistream Opus decoder. + * It is position independent and can be freely copied. + * @see opus_multistream_decoder_create + * @see opus_multistream_decoder_init + */ +typedef struct OpusMSDecoder OpusMSDecoder; + +/**\name Multistream encoder functions */ +/**@{*/ + +/** Gets the size of an OpusMSEncoder structure. + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_encoder_get_size( + int streams, + int coupled_streams +); + +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_surround_encoder_get_size( + int channels, + int mapping_family +); + + +/** Allocates and initializes a multistream encoder state. + * Call opus_multistream_encoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param[in] mapping const unsigned char[channels]: Mapping from + * encoded channels to input channels, as described in + * @ref opus_multistream. As an extra constraint, the + * multistream encoder does not allow encoding coupled + * streams for which one channel is unused since this + * is never a good idea. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_encoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int application, + int *error +) OPUS_ARG_NONNULL(5); + +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_surround_encoder_create( + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + unsigned char *mapping, + int application, + int *error +) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6); + +/** Initialize a previously allocated multistream encoder state. + * The memory pointed to by \a st must be at least the size returned by + * opus_multistream_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_multistream_encoder_create + * @see opus_multistream_encoder_get_size + * @param st OpusMSEncoder*: Multistream encoder state to initialize. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param[in] mapping const unsigned char[channels]: Mapping from + * encoded channels to input channels, as described in + * @ref opus_multistream. As an extra constraint, the + * multistream encoder does not allow encoding coupled + * streams for which one channel is unused since this + * is never a good idea. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_multistream_encoder_init( + OpusMSEncoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + +OPUS_EXPORT int opus_multistream_surround_encoder_init( + OpusMSEncoder *st, + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + unsigned char *mapping, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6) OPUS_ARG_NONNULL(7); + +/** Encodes a multistream Opus frame. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const opus_int16*: The input signal as interleaved + * samples. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode( + OpusMSEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a multistream Opus frame. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const opus_int32*: The input signal as interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode24( + OpusMSEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a multistream Opus frame from floating point input. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param[in] pcm const float*: The input signal as interleaved + * samples with a normal range of + * +/-1.0. + * Samples with a range beyond +/-1.0 + * are supported but will be clipped by + * decoders using the integer API and + * should only be used if it is known + * that the far end supports extended + * dynamic range. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode_float( + OpusMSEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Frees an OpusMSEncoder allocated by + * opus_multistream_encoder_create(). + * @param st OpusMSEncoder*: Multistream encoder state to be freed. + */ +OPUS_EXPORT void opus_multistream_encoder_destroy(OpusMSEncoder *st); + +/** Perform a CTL function on a multistream Opus encoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusMSEncoder*: Multistream encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_encoderctls, or @ref opus_multistream_ctls. + * @see opus_genericctls + * @see opus_encoderctls + * @see opus_multistream_ctls + */ +OPUS_EXPORT int opus_multistream_encoder_ctl(OpusMSEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/**@}*/ + +/**\name Multistream decoder functions */ +/**@{*/ + +/** Gets the size of an OpusMSDecoder structure. + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_decoder_get_size( + int streams, + int coupled_streams +); + +/** Allocates and initializes a multistream decoder state. + * Call opus_multistream_decoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] mapping const unsigned char[channels]: Mapping from + * coded channels to output channels, as described in + * @ref opus_multistream. + * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSDecoder *opus_multistream_decoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping, + int *error +) OPUS_ARG_NONNULL(5); + +/** Initialize a previously allocated decoder state object. + * The memory pointed to by \a st must be at least the size returned by + * opus_multistream_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_multistream_decoder_create + * @see opus_multistream_deocder_get_size + * @param st OpusMSEncoder*: Multistream encoder state to initialize. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] mapping const unsigned char[channels]: Mapping from + * coded channels to output channels, as described in + * @ref opus_multistream. + * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_multistream_decoder_init( + OpusMSDecoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + const unsigned char *mapping +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + +/** Decode a multistream Opus packet. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a multistream Opus packet. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int32*: Output signal, with interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode24( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a multistream Opus packet with floating point output. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode_float( + OpusMSDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Perform a CTL function on a multistream Opus decoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusMSDecoder*: Multistream decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_decoderctls, or @ref opus_multistream_ctls. + * @see opus_genericctls + * @see opus_decoderctls + * @see opus_multistream_ctls + */ +OPUS_EXPORT int opus_multistream_decoder_ctl(OpusMSDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + +/** Frees an OpusMSDecoder allocated by + * opus_multistream_decoder_create(). + * @param st OpusMSDecoder: Multistream decoder state to be freed. + */ +OPUS_EXPORT void opus_multistream_decoder_destroy(OpusMSDecoder *st); + +/**@}*/ + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_MULTISTREAM_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_projection.h b/native/third_party/ucrt64/include/opus/opus_projection.h new file mode 100644 index 0000000..a887452 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_projection.h @@ -0,0 +1,643 @@ +/* Copyright (c) 2017 Google Inc. + Written by Andrew Allen */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/** + * @file opus_projection.h + * @brief Opus projection reference API + */ + +#ifndef OPUS_PROJECTION_H +#define OPUS_PROJECTION_H + +#include "opus_multistream.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @cond OPUS_INTERNAL_DOC */ + +/** These are the actual encoder and decoder CTL ID numbers. + * They should not be used directly by applications.c + * In general, SETs should be even and GETs should be odd.*/ +/**@{*/ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST 6001 +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST 6003 +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST 6005 +/**@}*/ + + +/** @endcond */ + +/** @defgroup opus_projection_ctls Projection specific encoder and decoder CTLs + * + * These are convenience macros that are specific to the + * opus_projection_encoder_ctl() and opus_projection_decoder_ctl() + * interface. + * The CTLs from @ref opus_genericctls, @ref opus_encoderctls, + * @ref opus_decoderctls, and @ref opus_multistream_ctls may be applied to a + * projection encoder or decoder as well. + */ +/**@{*/ + +/** Gets the gain (in dB. S7.8-format) of the demixing matrix from the encoder. + * @param[out] x opus_int32 *: Returns the gain (in dB. S7.8-format) + * of the demixing matrix. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST, opus_check_int_ptr(x) + + +/** Gets the size in bytes of the demixing matrix from the encoder. + * @param[out] x opus_int32 *: Returns the size in bytes of the + * demixing matrix. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST, opus_check_int_ptr(x) + + +/** Copies the demixing matrix to the supplied pointer location. + * @param[out] x unsigned char *: Returns the demixing matrix to the + * supplied pointer location. + * @param y opus_int32: The size in bytes of the reserved memory at the + * pointer location. + * @hideinitializer + */ +#define OPUS_PROJECTION_GET_DEMIXING_MATRIX(x,y) OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST, x, opus_check_int(y) + + +/**@}*/ + +/** Opus projection encoder state. + * This contains the complete state of a projection Opus encoder. + * It is position independent and can be freely copied. + * @see opus_projection_ambisonics_encoder_create + */ +typedef struct OpusProjectionEncoder OpusProjectionEncoder; + + +/** Opus projection decoder state. + * This contains the complete state of a projection Opus decoder. + * It is position independent and can be freely copied. + * @see opus_projection_decoder_create + * @see opus_projection_decoder_init + */ +typedef struct OpusProjectionDecoder OpusProjectionDecoder; + + +/**\name Projection encoder functions */ +/**@{*/ + +/** Gets the size of an OpusProjectionEncoder structure. + * @param channels int: The total number of input channels to encode. + * This must be no more than 255. + * @param mapping_family int: The mapping family to use for selecting + * the appropriate projection. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_ambisonics_encoder_get_size( + int channels, + int mapping_family +); + + +/** Allocates and initializes a projection encoder state. + * Call opus_projection_encoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param mapping_family int: The mapping family to use for selecting + * the appropriate projection. + * @param[out] streams int *: The total number of streams that will + * be encoded from the input. + * @param[out] coupled_streams int *: Number of coupled (2 channel) + * streams that will be encoded from the input. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionEncoder *opus_projection_ambisonics_encoder_create( + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + int application, + int *error +) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5); + + +/** Initialize a previously allocated projection encoder state. + * The memory pointed to by \a st must be at least the size returned by + * opus_projection_ambisonics_encoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_projection_ambisonics_encoder_create + * @see opus_projection_ambisonics_encoder_get_size + * @param st OpusProjectionEncoder*: Projection encoder state to initialize. + * @param Fs opus_int32: Sampling rate of the input signal (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels in the input signal. + * This must be at most 255. + * It may be greater than the number of + * coded channels (streams + + * coupled_streams). + * @param streams int: The total number of streams to encode from the + * input. + * This must be no more than the number of channels. + * @param coupled_streams int: Number of coupled (2 channel) streams + * to encode. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * encoded channels (streams + + * coupled_streams) must be no + * more than the number of input channels. + * @param application int: The target encoder application. + * This must be one of the following: + *
+ *
#OPUS_APPLICATION_VOIP
+ *
Process signal for improved speech intelligibility.
+ *
#OPUS_APPLICATION_AUDIO
+ *
Favor faithfulness to the original input.
+ *
#OPUS_APPLICATION_RESTRICTED_LOWDELAY
+ *
Configure the minimum possible coding delay by disabling certain modes + * of operation.
+ *
+ * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_projection_ambisonics_encoder_init( + OpusProjectionEncoder *st, + opus_int32 Fs, + int channels, + int mapping_family, + int *streams, + int *coupled_streams, + int application +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6); + + +/** Encodes a projection Opus frame. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const opus_int16*: The input signal as interleaved + * samples. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode( + OpusProjectionEncoder *st, + const opus_int16 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + +/** Encodes a projection Opus frame. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const opus_int32*: The input signal as interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode24( + OpusProjectionEncoder *st, + const opus_int32 *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + + +/** Encodes a projection Opus frame from floating point input. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param[in] pcm const float*: The input signal as interleaved + * samples with a normal range of + * +/-1.0. + * Samples with a range beyond +/-1.0 + * are supported but will be clipped by + * decoders using the integer API and + * should only be used if it is known + * that the far end supports extended + * dynamic range. + * This must contain + * frame_size*channels + * samples. + * @param frame_size int: Number of samples per channel in the input + * signal. + * This must be an Opus frame size for the + * encoder's sampling rate. + * For example, at 48 kHz the permitted values + * are 120, 240, 480, 960, 1920, and 2880. + * Passing in a duration of less than 10 ms + * (480 samples at 48 kHz) will prevent the + * encoder from using the LPC or hybrid modes. + * @param[out] data unsigned char*: Output payload. + * This must contain storage for at + * least \a max_data_bytes. + * @param [in] max_data_bytes opus_int32: Size of the allocated + * memory for the output + * payload. This may be + * used to impose an upper limit on + * the instant bitrate, but should + * not be used as the only bitrate + * control. Use #OPUS_SET_BITRATE to + * control the bitrate. + * @returns The length of the encoded packet (in bytes) on success or a + * negative error code (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode_float( + OpusProjectionEncoder *st, + const float *pcm, + int frame_size, + unsigned char *data, + opus_int32 max_data_bytes +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4); + + +/** Frees an OpusProjectionEncoder allocated by + * opus_projection_ambisonics_encoder_create(). + * @param st OpusProjectionEncoder*: Projection encoder state to be freed. + */ +OPUS_EXPORT void opus_projection_encoder_destroy(OpusProjectionEncoder *st); + + +/** Perform a CTL function on a projection Opus encoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusProjectionEncoder*: Projection encoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_encoderctls, @ref opus_multistream_ctls, or + * @ref opus_projection_ctls + * @see opus_genericctls + * @see opus_encoderctls + * @see opus_multistream_ctls + * @see opus_projection_ctls + */ +OPUS_EXPORT int opus_projection_encoder_ctl(OpusProjectionEncoder *st, int request, ...) OPUS_ARG_NONNULL(1); + + +/**@}*/ + +/**\name Projection decoder functions */ +/**@{*/ + +/** Gets the size of an OpusProjectionDecoder structure. + * @param channels int: The total number of output channels. + * This must be no more than 255. + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @returns The size in bytes on success, or a negative error code + * (see @ref opus_errorcodes) on error. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_decoder_get_size( + int channels, + int streams, + int coupled_streams +); + + +/** Allocates and initializes a projection decoder state. + * Call opus_projection_decoder_destroy() to release + * this object when finished. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] demixing_matrix const unsigned char[demixing_matrix_size]: Demixing matrix + * that mapping from coded channels to output channels, + * as described in @ref opus_projection and + * @ref opus_projection_ctls. + * @param demixing_matrix_size opus_int32: The size in bytes of the + * demixing matrix, as + * described in @ref + * opus_projection_ctls. + * @param[out] error int *: Returns #OPUS_OK on success, or an error + * code (see @ref opus_errorcodes) on + * failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionDecoder *opus_projection_decoder_create( + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + unsigned char *demixing_matrix, + opus_int32 demixing_matrix_size, + int *error +) OPUS_ARG_NONNULL(5); + + +/** Initialize a previously allocated projection decoder state object. + * The memory pointed to by \a st must be at least the size returned by + * opus_projection_decoder_get_size(). + * This is intended for applications which use their own allocator instead of + * malloc. + * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL. + * @see opus_projection_decoder_create + * @see opus_projection_deocder_get_size + * @param st OpusProjectionDecoder*: Projection encoder state to initialize. + * @param Fs opus_int32: Sampling rate to decode at (in Hz). + * This must be one of 8000, 12000, 16000, + * 24000, or 48000. + * @param channels int: Number of channels to output. + * This must be at most 255. + * It may be different from the number of coded + * channels (streams + + * coupled_streams). + * @param streams int: The total number of streams coded in the + * input. + * This must be no more than 255. + * @param coupled_streams int: Number of streams to decode as coupled + * (2 channel) streams. + * This must be no larger than the total + * number of streams. + * Additionally, The total number of + * coded channels (streams + + * coupled_streams) must be no + * more than 255. + * @param[in] demixing_matrix const unsigned char[demixing_matrix_size]: Demixing matrix + * that mapping from coded channels to output channels, + * as described in @ref opus_projection and + * @ref opus_projection_ctls. + * @param demixing_matrix_size opus_int32: The size in bytes of the + * demixing matrix, as + * described in @ref + * opus_projection_ctls. + * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes) + * on failure. + */ +OPUS_EXPORT int opus_projection_decoder_init( + OpusProjectionDecoder *st, + opus_int32 Fs, + int channels, + int streams, + int coupled_streams, + unsigned char *demixing_matrix, + opus_int32 demixing_matrix_size +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6); + + +/** Decode a projection Opus packet. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int16 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a projection Opus packet. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int32*: Output signal, with interleaved + * samples representing (or slightly exceeding) 24-bit values. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode24( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + opus_int32 *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + +/** Decode a projection Opus packet with floating point output. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param[in] data const unsigned char*: Input payload. + * Use a NULL + * pointer to indicate packet + * loss. + * @param len opus_int32: Number of bytes in payload. + * @param[out] pcm opus_int16*: Output signal, with interleaved + * samples. + * This must contain room for + * frame_size*channels + * samples. + * @param frame_size int: The number of samples per channel of + * available space in \a pcm. + * If this is less than the maximum packet duration + * (120 ms; 5760 for 48kHz), this function will not be capable + * of decoding some packets. In the case of PLC (data==NULL) + * or FEC (decode_fec=1), then frame_size needs to be exactly + * the duration of audio that is missing, otherwise the + * decoder will not be in the optimal state to decode the + * next incoming packet. For the PLC and FEC cases, frame_size + * must be a multiple of 2.5 ms. + * @param decode_fec int: Flag (0 or 1) to request that any in-band + * forward error correction data be decoded. + * If no such data is available, the frame is + * decoded as if it were lost. + * @returns Number of samples decoded on success or a negative error code + * (see @ref opus_errorcodes) on failure. + */ +OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode_float( + OpusProjectionDecoder *st, + const unsigned char *data, + opus_int32 len, + float *pcm, + int frame_size, + int decode_fec +) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4); + + +/** Perform a CTL function on a projection Opus decoder. + * + * Generally the request and subsequent arguments are generated by a + * convenience macro. + * @param st OpusProjectionDecoder*: Projection decoder state. + * @param request This and all remaining parameters should be replaced by one + * of the convenience macros in @ref opus_genericctls, + * @ref opus_decoderctls, @ref opus_multistream_ctls, or + * @ref opus_projection_ctls. + * @see opus_genericctls + * @see opus_decoderctls + * @see opus_multistream_ctls + * @see opus_projection_ctls + */ +OPUS_EXPORT int opus_projection_decoder_ctl(OpusProjectionDecoder *st, int request, ...) OPUS_ARG_NONNULL(1); + + +/** Frees an OpusProjectionDecoder allocated by + * opus_projection_decoder_create(). + * @param st OpusProjectionDecoder: Projection decoder state to be freed. + */ +OPUS_EXPORT void opus_projection_decoder_destroy(OpusProjectionDecoder *st); + + +/**@}*/ + +/**@}*/ + +#ifdef __cplusplus +} +#endif + +#endif /* OPUS_PROJECTION_H */ diff --git a/native/third_party/ucrt64/include/opus/opus_types.h b/native/third_party/ucrt64/include/opus/opus_types.h new file mode 100644 index 0000000..b4f09a3 --- /dev/null +++ b/native/third_party/ucrt64/include/opus/opus_types.h @@ -0,0 +1,166 @@ +/* (C) COPYRIGHT 1994-2002 Xiph.Org Foundation */ +/* Modified by Jean-Marc Valin */ +/* + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +/* opus_types.h based on ogg_types.h from libogg */ + +/** + @file opus_types.h + @brief Opus reference implementation types +*/ +#ifndef OPUS_TYPES_H +#define OPUS_TYPES_H + +#define opus_int int /* used for counters etc; at least 16 bits */ +#define opus_int64 long long +#define opus_int8 signed char + +#define opus_uint unsigned int /* used for counters etc; at least 16 bits */ +#define opus_uint64 unsigned long long +#define opus_uint8 unsigned char + +/* Use the real stdint.h if it's there (taken from Paul Hsieh's pstdint.h) */ +#if (defined(__STDC__) && __STDC__ && defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L) || (defined(__GNUC__) && (defined(_STDINT_H) || defined(_STDINT_H_)) || defined (HAVE_STDINT_H)) +#include +# undef opus_int64 +# undef opus_int8 +# undef opus_uint64 +# undef opus_uint8 + typedef int8_t opus_int8; + typedef uint8_t opus_uint8; + typedef int16_t opus_int16; + typedef uint16_t opus_uint16; + typedef int32_t opus_int32; + typedef uint32_t opus_uint32; + typedef int64_t opus_int64; + typedef uint64_t opus_uint64; +#elif defined(_WIN32) + +# if defined(__CYGWIN__) +# include <_G_config.h> + typedef _G_int32_t opus_int32; + typedef _G_uint32_t opus_uint32; + typedef _G_int16 opus_int16; + typedef _G_uint16 opus_uint16; +# elif defined(__MINGW32__) + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; +# elif defined(__MWERKS__) + typedef int opus_int32; + typedef unsigned int opus_uint32; + typedef short opus_int16; + typedef unsigned short opus_uint16; +# else + /* MSVC/Borland */ + typedef __int32 opus_int32; + typedef unsigned __int32 opus_uint32; + typedef __int16 opus_int16; + typedef unsigned __int16 opus_uint16; +# endif + +#elif defined(__MACOS__) + +# include + typedef SInt16 opus_int16; + typedef UInt16 opus_uint16; + typedef SInt32 opus_int32; + typedef UInt32 opus_uint32; + +#elif (defined(__APPLE__) && defined(__MACH__)) /* MacOS X Framework build */ + +# include + typedef int16_t opus_int16; + typedef u_int16_t opus_uint16; + typedef int32_t opus_int32; + typedef u_int32_t opus_uint32; + +#elif defined(__BEOS__) + + /* Be */ +# include + typedef int16 opus_int16; + typedef u_int16 opus_uint16; + typedef int32_t opus_int32; + typedef u_int32_t opus_uint32; + +#elif defined (__EMX__) + + /* OS/2 GCC */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined (DJGPP) + + /* DJGPP */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined(R5900) + + /* PS2 EE */ + typedef int opus_int32; + typedef unsigned opus_uint32; + typedef short opus_int16; + typedef unsigned short opus_uint16; + +#elif defined(__SYMBIAN32__) + + /* Symbian GCC */ + typedef signed short opus_int16; + typedef unsigned short opus_uint16; + typedef signed int opus_int32; + typedef unsigned int opus_uint32; + +#elif defined(CONFIG_TI_C54X) || defined (CONFIG_TI_C55X) + + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef long opus_int32; + typedef unsigned long opus_uint32; + +#elif defined(CONFIG_TI_C6X) + + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#else + + /* Give up, take a reasonable guess */ + typedef short opus_int16; + typedef unsigned short opus_uint16; + typedef int opus_int32; + typedef unsigned int opus_uint32; + +#endif + +#endif /* OPUS_TYPES_H */ diff --git a/native/third_party/ucrt64/include/vpx/vp8.h b/native/third_party/ucrt64/include/vpx/vp8.h new file mode 100644 index 0000000..a9d2aca --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8.h @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup vp8 VP8 + * \ingroup codecs + * VP8 is a video compression algorithm that uses motion + * compensated prediction, Discrete Cosine Transform (DCT) coding of the + * prediction error signal and context dependent entropy coding techniques + * based on arithmetic principles. It features: + * - YUV 4:2:0 image format + * - Macro-block based coding (16x16 luma plus two 8x8 chroma) + * - 1/4 (1/8) pixel accuracy motion compensated prediction + * - 4x4 DCT transform + * - 128 level linear quantizer + * - In loop deblocking filter + * - Context-based entropy coding + * + * @{ + */ +/*!\file + * \brief Provides controls common to both the VP8 encoder and decoder. + */ +#ifndef VPX_VPX_VP8_H_ +#define VPX_VPX_VP8_H_ + +#include "./vpx_codec.h" +#include "./vpx_image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Control functions + * + * The set of macros define the control functions of VP8 interface + */ +enum vp8_com_control_id { + /*!\brief pass in an external frame into decoder to be used as reference frame + */ + VP8_SET_REFERENCE = 1, + VP8_COPY_REFERENCE = 2, /**< get a copy of reference frame from the decoder */ + VP8_SET_POSTPROC = 3, /**< set the decoder's post processing settings */ + + /* TODO(jkoleszar): The encoder incorrectly reuses some of these values (5+) + * for its control ids. These should be migrated to something like the + * VP8_DECODER_CTRL_ID_START range next time we're ready to break the ABI. + */ + VP9_GET_REFERENCE = 128, /**< get a pointer to a reference frame */ + VP8_COMMON_CTRL_ID_MAX, + VP8_DECODER_CTRL_ID_START = 256 +}; + +/*!\brief post process flags + * + * The set of macros define VP8 decoder post processing flags + */ +enum vp8_postproc_level { + VP8_NOFILTERING = 0, + VP8_DEBLOCK = 1 << 0, + VP8_DEMACROBLOCK = 1 << 1, + VP8_ADDNOISE = 1 << 2, + VP8_MFQE = 1 << 3 +}; + +/*!\brief post process flags + * + * This define a structure that describe the post processing settings. For + * the best objective measure (using the PSNR metric) set post_proc_flag + * to VP8_DEBLOCK and deblocking_level to 1. + */ + +typedef struct vp8_postproc_cfg { + /*!\brief the types of post processing to be done, should be combination of + * "vp8_postproc_level" */ + int post_proc_flag; + int deblocking_level; /**< the strength of deblocking, valid range [0, 16] */ + int noise_level; /**< the strength of additive noise, valid range [0, 16] */ +} vp8_postproc_cfg_t; + +/*!\brief reference frame type + * + * The set of macros define the type of VP8 reference frames + */ +typedef enum vpx_ref_frame_type { + VP8_LAST_FRAME = 1, + VP8_GOLD_FRAME = 2, + VP8_ALTR_FRAME = 4 +} vpx_ref_frame_type_t; + +/*!\brief reference frame data struct + * + * Define the data struct to access vp8 reference frames. + */ +typedef struct vpx_ref_frame { + vpx_ref_frame_type_t frame_type; /**< which reference frame */ + vpx_image_t img; /**< reference frame data in image format */ +} vpx_ref_frame_t; + +/*!\brief VP9 specific reference frame data struct + * + * Define the data struct to access vp9 reference frames. + */ +typedef struct vp9_ref_frame { + int idx; /**< frame index to get (input) */ + vpx_image_t img; /**< img structure to populate (output) */ +} vp9_ref_frame_t; + +/*!\cond */ +/*!\brief vp8 decoder control function parameter type + * + * defines the data type for each of VP8 decoder control function requires + */ +VPX_CTRL_USE_TYPE(VP8_SET_REFERENCE, vpx_ref_frame_t *) +#define VPX_CTRL_VP8_SET_REFERENCE +VPX_CTRL_USE_TYPE(VP8_COPY_REFERENCE, vpx_ref_frame_t *) +#define VPX_CTRL_VP8_COPY_REFERENCE +VPX_CTRL_USE_TYPE(VP8_SET_POSTPROC, vp8_postproc_cfg_t *) +#define VPX_CTRL_VP8_SET_POSTPROC +VPX_CTRL_USE_TYPE(VP9_GET_REFERENCE, vp9_ref_frame_t *) +#define VPX_CTRL_VP9_GET_REFERENCE + +/*!\endcond */ +/*! @} - end defgroup vp8 */ + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8_H_ diff --git a/native/third_party/ucrt64/include/vpx/vp8cx.h b/native/third_party/ucrt64/include/vpx/vp8cx.h new file mode 100644 index 0000000..52f88b6 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8cx.h @@ -0,0 +1,1129 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VP8CX_H_ +#define VPX_VPX_VP8CX_H_ + +/*!\defgroup vp8_encoder WebM VP8/VP9 Encoder + * \ingroup vp8 + * + * @{ + */ +#include "./vp8.h" +#include "./vpx_encoder.h" +#include "./vpx_ext_ratectrl.h" + +/*!\file + * \brief Provides definitions for using VP8 or VP9 encoder algorithm within the + * vpx Codec Interface. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\name Algorithm interface for VP8 + * + * This interface provides the capability to encode raw VP8 streams. + * @{ + */ + +/*!\brief A single instance of the VP8 encoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp8_cx(). + */ +extern vpx_codec_iface_t vpx_codec_vp8_cx_algo; + +/*!\brief The interface to the VP8 encoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp8_cx(void); +/*!@} - end algorithm interface member group*/ + +/*!\name Algorithm interface for VP9 + * + * This interface provides the capability to encode raw VP9 streams. + * @{ + */ + +/*!\brief A single instance of the VP9 encoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp9_cx(). + */ +extern vpx_codec_iface_t vpx_codec_vp9_cx_algo; + +/*!\brief The interface to the VP9 encoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp9_cx(void); +/*!@} - end algorithm interface member group*/ + +/* + * Algorithm Flags + */ + +/*!\brief Don't reference the last frame + * + * When this flag is set, the encoder will not use the last frame as a + * predictor. When not set, the encoder will choose whether to use the + * last frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_LAST (1 << 16) + +/*!\brief Don't reference the golden frame + * + * When this flag is set, the encoder will not use the golden frame as a + * predictor. When not set, the encoder will choose whether to use the + * golden frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_GF (1 << 17) + +/*!\brief Don't reference the alternate reference frame + * + * When this flag is set, the encoder will not use the alt ref frame as a + * predictor. When not set, the encoder will choose whether to use the + * alt ref frame or not automatically. + */ +#define VP8_EFLAG_NO_REF_ARF (1 << 21) + +/*!\brief Don't update the last frame + * + * When this flag is set, the encoder will not update the last frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_LAST (1 << 18) + +/*!\brief Don't update the golden frame + * + * When this flag is set, the encoder will not update the golden frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_GF (1 << 22) + +/*!\brief Don't update the alternate reference frame + * + * When this flag is set, the encoder will not update the alt ref frame with + * the contents of the current frame. + */ +#define VP8_EFLAG_NO_UPD_ARF (1 << 23) + +/*!\brief Force golden frame update + * + * When this flag is set, the encoder copy the contents of the current frame + * to the golden frame buffer. + */ +#define VP8_EFLAG_FORCE_GF (1 << 19) + +/*!\brief Force alternate reference frame update + * + * When this flag is set, the encoder copy the contents of the current frame + * to the alternate reference frame buffer. + */ +#define VP8_EFLAG_FORCE_ARF (1 << 24) + +/*!\brief Disable entropy update + * + * When this flag is set, the encoder will not update its internal entropy + * model based on the entropy of this frame. + */ +#define VP8_EFLAG_NO_UPD_ENTROPY (1 << 20) + +/*!\brief VPx encoder control functions + * + * This set of macros define the control functions available for VPx + * encoder interface. + * + * \sa #vpx_codec_control + */ +enum vp8e_enc_control_id { + /*!\brief Codec control function to pass an ROI map to encoder. + * + * Supported in codecs: VP8 + */ + VP8E_SET_ROI_MAP = 8, + + /*!\brief Codec control function to pass an Active map to encoder. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ACTIVEMAP, + + /*!\brief Codec control function to set encoder scaling mode. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_SCALEMODE = 11, + + /*!\brief Codec control function to set encoder internal speed settings. + * + * Changes in this value influences, among others, the encoder's selection + * of motion estimation methods. Values greater than 0 will increase encoder + * speed at the expense of quality. + * + * \note Valid range for VP8: -16..16 + * \note Valid range for VP9: -9..9 + * \note A negative value (-n) is treated as its absolute value (n) in VP9. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_CPUUSED = 13, + + /*!\brief Codec control function to enable automatic use of arf frames. + * + * \note Valid range for VP8: 0..1 + * \note Valid range for VP9: 0..6 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ENABLEAUTOALTREF, + + /*!\brief control function to set noise sensitivity + * + * 0: off, 1: OnYOnly, 2: OnYUV, + * 3: OnYUVAggressive, 4: Adaptive + * + * Supported in codecs: VP8 + */ + VP8E_SET_NOISE_SENSITIVITY, + + /*!\brief Codec control function to set higher sharpness at the expense + * of a lower PSNR. + * + * \note Valid range: 0..7 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_SHARPNESS, + + /*!\brief Codec control function to set the threshold for MBs treated static. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_STATIC_THRESHOLD, + + /*!\brief Codec control function to set the number of token partitions. + * + * Supported in codecs: VP8 + */ + VP8E_SET_TOKEN_PARTITIONS, + + /*!\brief Codec control function to get last quantizer chosen by the encoder. + * + * Return value uses internal quantizer scale defined by the codec. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_GET_LAST_QUANTIZER, + + /*!\brief Codec control function to get last quantizer chosen by the encoder. + * + * Return value uses the 0..63 scale as used by the rc_*_quantizer config + * parameters. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_GET_LAST_QUANTIZER_64, + + /*!\brief Codec control function to set the max no of frames to create arf. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ARNR_MAXFRAMES, + + /*!\brief Codec control function to set the filter strength for the arf. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_ARNR_STRENGTH, + + /*!\deprecated control function to set the filter type to use for the arf. */ + VP8E_SET_ARNR_TYPE, + + /*!\brief Codec control function to set visual tuning. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_TUNING, + + /*!\brief Codec control function to set constrained / constant quality level. + * + * \attention For this value to be used vpx_codec_enc_cfg_t::rc_end_usage must + * be set to #VPX_CQ or #VPX_Q + * \note Valid range: 0..63 + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_CQ_LEVEL, + + /*!\brief Codec control function to set Max data rate for Intra frames. + * + * This value controls additional clamping on the maximum size of a + * keyframe. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * unlimited, or no additional clamping beyond the codec's built-in + * algorithm. + * + * For example, to allocate no more than 4.5 frames worth of bitrate + * to a keyframe, set this to 450. + * + * Supported in codecs: VP8, VP9 + */ + VP8E_SET_MAX_INTRA_BITRATE_PCT, + + /*!\brief Codec control function to set reference and update frame flags. + * + * Supported in codecs: VP8 + */ + VP8E_SET_FRAME_FLAGS, + + /*!\brief Codec control function to set max data rate for Inter frames. + * + * This value controls additional clamping on the maximum size of an + * inter frame. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * unlimited, or no additional clamping beyond the codec's built-in + * algorithm. + * + * For example, to allow no more than 4.5 frames worth of bitrate + * to an inter frame, set this to 450. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MAX_INTER_BITRATE_PCT, + + /*!\brief Boost percentage for Golden Frame in CBR mode. + * + * This value controls the amount of boost given to Golden Frame in + * CBR mode. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * the feature is off, i.e., no golden frame boost in CBR mode and + * average bitrate target is used. + * + * For example, to allow 100% more bits, i.e., 2X, in a golden frame + * than average frame, set this to 100. + * + * Supported in codecs: VP9 + */ + VP9E_SET_GF_CBR_BOOST_PCT, + + /*!\brief Codec control function to set the temporal layer id. + * + * For temporal scalability: this control allows the application to set the + * layer id for each frame to be encoded. Note that this control must be set + * for every frame prior to encoding. The usage of this control function + * supersedes the internal temporal pattern counter, which is now deprecated. + * + * Supported in codecs: VP8 + */ + VP8E_SET_TEMPORAL_LAYER_ID, + + /*!\brief Codec control function to set encoder screen content mode. + * + * 0: off, 1: On, 2: On with more aggressive rate control. + * + * Supported in codecs: VP8 + */ + VP8E_SET_SCREEN_CONTENT_MODE, + + /*!\brief Codec control function to set lossless encoding mode. + * + * VP9 can operate in lossless encoding mode, in which the bitstream + * produced will be able to decode and reconstruct a perfect copy of + * input source. This control function provides a mean to switch encoder + * into lossless coding mode(1) or normal coding mode(0) that may be lossy. + * 0 = lossy coding mode + * 1 = lossless coding mode + * + * By default, encoder operates in normal coding mode (maybe lossy). + * + * Supported in codecs: VP9 + */ + VP9E_SET_LOSSLESS, + + /*!\brief Codec control function to set number of tile columns. + * + * In encoding and decoding, VP9 allows an input image frame be partitioned + * into separated vertical tile columns, which can be encoded or decoded + * independently. This enables easy implementation of parallel encoding and + * decoding. This control requests the encoder to use column tiles in + * encoding an input frame, with number of tile columns (in Log2 unit) as + * the parameter: + * 0 = 1 tile column + * 1 = 2 tile columns + * 2 = 4 tile columns + * ..... + * n = 2**n tile columns + * The requested tile columns will be capped by the encoder based on image + * size limitations (The minimum width of a tile column is 256 pixels, the + * maximum is 4096). + * + * By default, the value is 6, i.e., the maximum number of tiles supported by + * the resolution. + * + * Supported in codecs: VP9 + */ + VP9E_SET_TILE_COLUMNS, + + /*!\brief Codec control function to set number of tile rows. + * + * In encoding and decoding, VP9 allows an input image frame be partitioned + * into separated horizontal tile rows. Tile rows are encoded or decoded + * sequentially. Even though encoding/decoding of later tile rows depends on + * earlier ones, this allows the encoder to output data packets for tile rows + * prior to completely processing all tile rows in a frame, thereby reducing + * the latency in processing between input and output. The parameter + * for this control describes the number of tile rows, which has a valid + * range [0, 2]: + * 0 = 1 tile row + * 1 = 2 tile rows + * 2 = 4 tile rows + * + * By default, the value is 0, i.e. one single row tile for entire image. + * + * Supported in codecs: VP9 + */ + VP9E_SET_TILE_ROWS, + + /*!\brief Codec control function to enable frame parallel decoding feature. + * + * VP9 has a bitstream feature to reduce decoding dependency between frames + * by turning off backward update of probability context used in encoding + * and decoding. This allows staged parallel processing of more than one + * video frame in the decoder. This control function provides a means to + * turn this feature on or off for bitstreams produced by encoder. + * + * By default, this feature is on. + * + * Supported in codecs: VP9 + */ + VP9E_SET_FRAME_PARALLEL_DECODING, + + /*!\brief Codec control function to set adaptive quantization mode. + * + * VP9 has a segment based feature that allows encoder to adaptively change + * quantization parameter for each segment within a frame to improve the + * subjective quality. This control makes encoder operate in one of the + * several AQ_modes supported. + * + * By default, encoder operates with AQ_Mode 0(adaptive quantization off). + * + * Supported in codecs: VP9 + */ + VP9E_SET_AQ_MODE, + + /*!\brief Codec control function to enable/disable periodic Q boost. + * + * One VP9 encoder speed feature is to enable quality boost by lowering + * frame level Q periodically. This control function provides a mean to + * turn on/off this feature. + * 0 = off + * 1 = on + * + * By default, the encoder is allowed to use this feature for appropriate + * encoding modes. + * + * Supported in codecs: VP9 + */ + VP9E_SET_FRAME_PERIODIC_BOOST, + + /*!\brief Codec control function to set noise sensitivity. + * + * 0: off, 1: On(YOnly), 2: For SVC only, on top two spatial layers(YOnly) + * + * Supported in codecs: VP9 + */ + VP9E_SET_NOISE_SENSITIVITY, + + /*!\brief Codec control function to turn on/off SVC in encoder. + * \note Return value is VPX_CODEC_INVALID_PARAM if the encoder does not + * support SVC in its current encoding mode + * 0: off, 1: on + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC, + + /*!\brief Codec control function to pass an ROI map to encoder. + * + * Supported in codecs: VP9 + */ + VP9E_SET_ROI_MAP, + + /*!\brief Codec control function to set parameters for SVC. + * \note Parameters contain min_q, max_q, scaling factor for each of the + * SVC layers. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_PARAMETERS, + + /*!\brief Codec control function to set svc layer for spatial and temporal. + * \note Valid ranges: 0..#vpx_codec_enc_cfg::ss_number_layers for spatial + * layer and 0..#vpx_codec_enc_cfg::ts_number_layers for + * temporal layer. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_LAYER_ID, + + /*!\brief Codec control function to set content type. + * \note Valid parameter range: + * VP9E_CONTENT_DEFAULT = Regular video content (Default) + * VP9E_CONTENT_SCREEN = Screen capture content + * VP9E_CONTENT_FILM = Film content: improves grain retention + * + * Supported in codecs: VP9 + */ + VP9E_SET_TUNE_CONTENT, + + /*!\brief Codec control function to get svc layer ID. + * \note The layer ID returned is for the data packet from the registered + * callback function. + * + * Supported in codecs: VP9 + */ + VP9E_GET_SVC_LAYER_ID, + + /*!\brief Codec control function to register callback to get per layer packet. + * \note Parameter for this control function is a structure with a callback + * function and a pointer to private data used by the callback. + * + * Supported in codecs: VP9 + */ + VP9E_REGISTER_CX_CALLBACK, + + /*!\brief Codec control function to set color space info. + * \note Valid ranges: 0..7, default is "UNKNOWN". + * 0 = UNKNOWN, + * 1 = BT_601 + * 2 = BT_709 + * 3 = SMPTE_170 + * 4 = SMPTE_240 + * 5 = BT_2020 + * 6 = RESERVED + * 7 = SRGB + * + * Supported in codecs: VP9 + */ + VP9E_SET_COLOR_SPACE, + + /*!\brief Codec control function to set minimum interval between GF/ARF frames + * + * By default the value is set as 4. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MIN_GF_INTERVAL = 48, + + /*!\brief Codec control function to set minimum interval between GF/ARF frames + * + * By default the value is set as 16. + * + * Supported in codecs: VP9 + */ + VP9E_SET_MAX_GF_INTERVAL, + + /*!\brief Codec control function to get an Active map back from the encoder. + * + * Supported in codecs: VP9 + */ + VP9E_GET_ACTIVEMAP, + + /*!\brief Codec control function to set color range bit. + * \note Valid ranges: 0..1, default is 0 + * 0 = Limited range (16..235 or HBD equivalent) + * 1 = Full range (0..255 or HBD equivalent) + * + * Supported in codecs: VP9 + */ + VP9E_SET_COLOR_RANGE, + + /*!\brief Codec control function to set the frame flags and buffer indices + * for spatial layers. The frame flags and buffer indices are set using the + * struct #vpx_svc_ref_frame_config defined below. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_REF_FRAME_CONFIG, + + /*!\brief Codec control function to set intended rendering image size. + * + * By default, this is identical to the image size in pixels. + * + * Supported in codecs: VP9 + */ + VP9E_SET_RENDER_SIZE, + + /*!\brief Codec control function to set target level. + * + * 255: off (default); 0: only keep level stats; 10: target for level 1.0; + * 11: target for level 1.1; ... 62: target for level 6.2 + * + * Supported in codecs: VP9 + */ + VP9E_SET_TARGET_LEVEL, + + /*!\brief Codec control function to set row level multi-threading. + * + * 0 : off, 1 : on + * + * Supported in codecs: VP9 + */ + VP9E_SET_ROW_MT, + + /*!\brief Codec control function to get bitstream level. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LEVEL, + + /*!\brief Codec control function to enable/disable special mode for altref + * adaptive quantization. You can use it with --aq-mode concurrently. + * + * Enable special adaptive quantization for altref frames based on their + * expected prediction quality for the future frames. + * + * Supported in codecs: VP9 + */ + VP9E_SET_ALT_REF_AQ, + + /*!\brief Boost percentage for Golden Frame in CBR mode. + * + * This value controls the amount of boost given to Golden Frame in + * CBR mode. It is expressed as a percentage of the average + * per-frame bitrate, with the special (and default) value 0 meaning + * the feature is off, i.e., no golden frame boost in CBR mode and + * average bitrate target is used. + * + * For example, to allow 100% more bits, i.e., 2X, in a golden frame + * than average frame, set this to 100. + * + * Supported in codecs: VP8 + */ + VP8E_SET_GF_CBR_BOOST_PCT, + + /*!\brief Codec control function to enable the extreme motion vector unit test + * in VP9. Please note that this is only used in motion vector unit test. + * + * 0 : off, 1 : MAX_EXTREME_MV, 2 : MIN_EXTREME_MV + * + * Supported in codecs: VP9 + */ + VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST, + + /*!\brief Codec control function to constrain the inter-layer prediction + * (prediction of lower spatial resolution) in VP9 SVC. + * + * 0 : inter-layer prediction on, 1 : off, 2 : off only on non-key frames + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_INTER_LAYER_PRED, + + /*!\brief Codec control function to set mode and thresholds for frame + * dropping in SVC. Drop frame thresholds are set per-layer. Mode is set as: + * 0 : layer-dependent dropping, 1 : constrained dropping, current layer drop + * forces drop on all upper layers. Default mode is 0. + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_FRAME_DROP_LAYER, + + /*!\brief Codec control function to get the refresh and reference flags and + * the buffer indices, up to the last encoded spatial layer. + * + * Supported in codecs: VP9 + */ + VP9E_GET_SVC_REF_FRAME_CONFIG, + + /*!\brief Codec control function to enable/disable use of golden reference as + * a second temporal reference for SVC. Only used when inter-layer prediction + * is disabled on INTER frames. + * + * 0: Off, 1: Enabled (default) + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_GF_TEMPORAL_REF, + + /*!\brief Codec control function to enable spatial layer sync frame, for any + * spatial layer. Enabling it for layer k means spatial layer k will disable + * all temporal prediction, but keep the inter-layer prediction. It will + * refresh any temporal reference buffer for that layer, and reset the + * temporal layer for the superframe to 0. Setting the layer sync for base + * spatial layer forces a key frame. Default is off (0) for all spatial + * layers. Spatial layer sync flag is reset to 0 after each encoded layer, + * so when control is invoked it is only used for the current superframe. + * + * 0: Off (default), 1: Enabled + * + * Supported in codecs: VP9 + */ + VP9E_SET_SVC_SPATIAL_LAYER_SYNC, + + /*!\brief Codec control function to enable temporal dependency model. + * + * Vp9 allows the encoder to run temporal dependency model and use it to + * improve the compression performance. To enable, set this parameter to be + * 1. The default value is set to be 1. + */ + VP9E_SET_TPL, + + /*!\brief Codec control function to enable post encode frame drop. + * + * This will allow encoder to drop frame after it's encoded. + * + * 0: Off (default), 1: Enabled + * + * Supported in codecs: VP9 + */ + VP9E_SET_POSTENCODE_DROP, + + /*!\brief Codec control function to set delta q for uv. + * + * Cap it at +/-15. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DELTA_Q_UV, + + /*!\brief Codec control function to disable increase Q on overshoot in CBR. + * + * 0: On (default), 1: Disable. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR, + + /*!\brief Codec control function to disable loopfilter. + * + * 0: Loopfilter on all frames, 1: Disable on non reference frames. + * 2: Disable on all frames. + * + * Supported in codecs: VP9 + */ + VP9E_SET_DISABLE_LOOPFILTER, + + /*!\brief Codec control function to enable external rate control library. + * + * args[0]: path of the rate control library + * + * args[1]: private config of the rate control library + * + * Supported in codecs: VP9 + */ + VP9E_SET_EXTERNAL_RATE_CONTROL, + + /*!\brief Codec control to disable internal features in rate control. + * + * This will do 3 things, only for 1 pass: + * - Turn off low motion computation + * - Turn off gf update constraint on key frame frequency + * - Turn off content mode for cyclic refresh + * + * With those, the rate control is expected to work exactly the same as the + * interface provided in ratectrl_rtc.cc/h + * + * Supported in codecs: VP9 + */ + VP9E_SET_RTC_EXTERNAL_RATECTRL, + + /*!\brief Codec control function to get loopfilter level in the encoder. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LOOPFILTER_LEVEL, + + /*!\brief Codec control to get last quantizers for all spatial layers. + * + * Return value uses an array of internal quantizers scale defined by the + * codec, for all spatial layers. + * The size of the array passed in should be #VPX_SS_MAX_LAYERS. + * + * Supported in codecs: VP9 + */ + VP9E_GET_LAST_QUANTIZER_SVC_LAYERS, + + /*!\brief Codec control to disable internal features in rate control. + * + * This will turn off cyclic refresh for vp8. + * + * With this, the rate control is expected to work exactly the same as the + * interface provided in vp8_ratectrl_rtc.cc/h + * + * Supported in codecs: VP8 + */ + VP8E_SET_RTC_EXTERNAL_RATECTRL, + + /*!\brief Codec control to set quantizer for the next frame. + * + * This will turn off cyclic refresh. Only applicable to 1-pass without + * spatial layers. + * + * Supported in codecs: VP9 + * + */ + VP9E_SET_QUANTIZER_ONE_PASS, + + /*!\brief Codec control function to enable key frame temporal filtering. + * + * Vp9 allows the encoder to run key frame temporal filtering and use it to + * improve the compression performance. To enable, set this parameter to be + * 1. The default value is set to be 0. + */ + VP9E_SET_KEY_FRAME_FILTERING, + + /*!\brief Codec control function to validate HBD input. + * + * VP9 allows the encoder to validate the high bitdepth (HBD) input and + * ensure that every pixel is within the valid range. To disable/enable, + * set this parameter to 0/1. The default value is set to be 1. + * + * Supported in codecs: VP9 + */ + VP9E_SET_VALIDATE_HBD_INPUT, +}; + +/*!\brief vpx 1-D scaling mode + * + * This set of constants define 1-D vpx scaling modes + */ +typedef enum vpx_scaling_mode_1d { + VP8E_NORMAL = 0, + VP8E_FOURFIVE = 1, + VP8E_THREEFIVE = 2, + VP8E_ONETWO = 3 +} VPX_SCALING_MODE; + +/*!\brief Temporal layering mode enum for VP9 SVC. + * + * This set of macros define the different temporal layering modes. + * Supported codecs: VP9 (in SVC mode) + * + */ +typedef enum vp9e_temporal_layering_mode { + /*!\brief No temporal layering. + * Used when only spatial layering is used. + */ + VP9E_TEMPORAL_LAYERING_MODE_NOLAYERING = 0, + + /*!\brief Bypass mode. + * Used when application needs to control spatial and temporal layering. + */ + VP9E_TEMPORAL_LAYERING_MODE_BYPASS = 1, + + /*!\brief 0-1-0-1... temporal layering scheme with two temporal layers. + */ + VP9E_TEMPORAL_LAYERING_MODE_0101 = 2, + + /*!\brief 0-2-1-2... temporal layering scheme with three temporal layers. + */ + VP9E_TEMPORAL_LAYERING_MODE_0212 = 3 +} VP9E_TEMPORAL_LAYERING_MODE; + +/*!\brief vpx region of interest map + * + * These defines the data structures for the region of interest map + * + */ + +typedef struct vpx_roi_map { + /*! If ROI is enabled. */ + uint8_t enabled; + /*! An id between 0-3 (0-7 for vp9) for each 16x16 (8x8 for VP9) + * region within a frame. */ + unsigned char *roi_map; + unsigned int rows; /**< Number of rows. */ + unsigned int cols; /**< Number of columns. */ + /*! VP8 only uses the first 4 segments. VP9 uses 8 segments. */ + int delta_q[8]; /**< Quantizer deltas. Valid range: [-63, 63].*/ + int delta_lf[8]; /**< Loop filter deltas. Valid range: [-63, 63].*/ + /*! skip and ref frame segment is only used in VP9. */ + int skip[8]; /**< Skip this block. */ + int ref_frame[8]; /**< Reference frame for this block. */ + /*! Static breakout threshold for each segment. Only used in VP8. */ + unsigned int static_threshold[4]; +} vpx_roi_map_t; + +/*!\brief vpx active region map + * + * These defines the data structures for active region map + * + */ + +typedef struct vpx_active_map { + /*!\brief specify an on (1) or off (0) each 16x16 region within a frame */ + unsigned char *active_map; + unsigned int rows; /**< number of rows */ + unsigned int cols; /**< number of cols */ +} vpx_active_map_t; + +/*!\brief vpx image scaling mode + * + * This defines the data structure for image scaling mode + * + */ +typedef struct vpx_scaling_mode { + VPX_SCALING_MODE h_scaling_mode; /**< horizontal scaling mode */ + VPX_SCALING_MODE v_scaling_mode; /**< vertical scaling mode */ +} vpx_scaling_mode_t; + +/*!\brief VP8 token partition mode + * + * This defines VP8 partitioning mode for compressed data, i.e., the number of + * sub-streams in the bitstream. Used for parallelized decoding. + * + */ + +typedef enum { + VP8_ONE_TOKENPARTITION = 0, + VP8_TWO_TOKENPARTITION = 1, + VP8_FOUR_TOKENPARTITION = 2, + VP8_EIGHT_TOKENPARTITION = 3 +} vp8e_token_partitions; + +/*!\brief VP9 encoder content type */ +typedef enum { + VP9E_CONTENT_DEFAULT, + VP9E_CONTENT_SCREEN, + VP9E_CONTENT_FILM, + VP9E_CONTENT_INVALID +} vp9e_tune_content; + +/*!\brief VP8 model tuning parameters + * + * Changes the encoder to tune for certain types of input material. + * + */ +typedef enum { VP8_TUNE_PSNR, VP8_TUNE_SSIM } vp8e_tuning; + +/*!\brief vp9 svc layer parameters + * + * This defines the spatial and temporal layer id numbers for svc encoding. + * This is used with the #VP9E_SET_SVC_LAYER_ID control to set the spatial and + * temporal layer id for the current frame. + * + */ +typedef struct vpx_svc_layer_id { + int spatial_layer_id; /**< First spatial layer to start encoding. */ + // TODO(jianj): Deprecated, to be removed. + int temporal_layer_id; /**< Temporal layer id number. */ + int temporal_layer_id_per_spatial[VPX_SS_MAX_LAYERS]; /**< Temp layer id. */ +} vpx_svc_layer_id_t; + +/*!\brief vp9 svc frame flag parameters. + * + * This defines the frame flags and buffer indices for each spatial layer for + * svc encoding. + * This is used with the #VP9E_SET_SVC_REF_FRAME_CONFIG control to set frame + * flags and buffer indices for each spatial layer for the current (super)frame. + * + */ +typedef struct vpx_svc_ref_frame_config { + int lst_fb_idx[VPX_SS_MAX_LAYERS]; /**< Last buffer index. */ + int gld_fb_idx[VPX_SS_MAX_LAYERS]; /**< Golden buffer index. */ + int alt_fb_idx[VPX_SS_MAX_LAYERS]; /**< Altref buffer index. */ + int update_buffer_slot[VPX_SS_MAX_LAYERS]; /**< Update reference frames. */ + // TODO(jianj): Remove update_last/golden/alt_ref, these are deprecated. + int update_last[VPX_SS_MAX_LAYERS]; /**< Update last. */ + int update_golden[VPX_SS_MAX_LAYERS]; /**< Update golden. */ + int update_alt_ref[VPX_SS_MAX_LAYERS]; /**< Update altref. */ + int reference_last[VPX_SS_MAX_LAYERS]; /**< Last as reference. */ + int reference_golden[VPX_SS_MAX_LAYERS]; /**< Golden as reference. */ + int reference_alt_ref[VPX_SS_MAX_LAYERS]; /**< Altref as reference. */ + int64_t duration[VPX_SS_MAX_LAYERS]; /**< Duration per spatial layer. */ +} vpx_svc_ref_frame_config_t; + +/*!\brief VP9 svc frame dropping mode. + * + * This defines the frame drop mode for SVC. + * + */ +typedef enum { + CONSTRAINED_LAYER_DROP, + /**< Upper layers are constrained to drop if current layer drops. */ + LAYER_DROP, /**< Any spatial layer can drop. */ + FULL_SUPERFRAME_DROP, /**< Only full superframe can drop. */ + CONSTRAINED_FROM_ABOVE_DROP, + /**< Lower layers are constrained to drop if current layer drops. */ +} SVC_LAYER_DROP_MODE; + +/*!\brief vp9 svc frame dropping parameters. + * + * This defines the frame drop thresholds for each spatial layer, and + * the frame dropping mode: 0 = layer based frame dropping (default), + * 1 = constrained dropping where current layer drop forces all upper + * spatial layers to drop. + */ +typedef struct vpx_svc_frame_drop { + int framedrop_thresh[VPX_SS_MAX_LAYERS]; /**< Frame drop thresholds */ + SVC_LAYER_DROP_MODE + framedrop_mode; /**< Layer-based or constrained dropping. */ + int max_consec_drop; /**< Maximum consecutive drops, for any layer. */ +} vpx_svc_frame_drop_t; + +/*!\brief vp9 svc spatial layer sync parameters. + * + * This defines the spatial layer sync flag, defined per spatial layer. + * + */ +typedef struct vpx_svc_spatial_layer_sync { + int spatial_layer_sync[VPX_SS_MAX_LAYERS]; /**< Sync layer flags */ + int base_layer_intra_only; /**< Flag for setting Intra-only frame on base */ +} vpx_svc_spatial_layer_sync_t; + +/*!\cond */ +/*!\brief VP8 encoder control function parameter type + * + * Defines the data types that VP8E control functions take. Note that + * additional common controls are defined in vp8.h + * + */ + +VPX_CTRL_USE_TYPE(VP8E_SET_ROI_MAP, vpx_roi_map_t *) +#define VPX_CTRL_VP8E_SET_ROI_MAP +VPX_CTRL_USE_TYPE(VP8E_SET_ACTIVEMAP, vpx_active_map_t *) +#define VPX_CTRL_VP8E_SET_ACTIVEMAP +VPX_CTRL_USE_TYPE(VP8E_SET_SCALEMODE, vpx_scaling_mode_t *) +#define VPX_CTRL_VP8E_SET_SCALEMODE +VPX_CTRL_USE_TYPE(VP8E_SET_CPUUSED, int) +#define VPX_CTRL_VP8E_SET_CPUUSED +VPX_CTRL_USE_TYPE(VP8E_SET_ENABLEAUTOALTREF, unsigned int) +#define VPX_CTRL_VP8E_SET_ENABLEAUTOALTREF +VPX_CTRL_USE_TYPE(VP8E_SET_NOISE_SENSITIVITY, unsigned int) +#define VPX_CTRL_VP8E_SET_NOISE_SENSITIVITY +VPX_CTRL_USE_TYPE(VP8E_SET_SHARPNESS, unsigned int) +#define VPX_CTRL_VP8E_SET_SHARPNESS +VPX_CTRL_USE_TYPE(VP8E_SET_STATIC_THRESHOLD, unsigned int) +#define VPX_CTRL_VP8E_SET_STATIC_THRESHOLD +VPX_CTRL_USE_TYPE(VP8E_SET_TOKEN_PARTITIONS, int) /* vp8e_token_partitions */ +#define VPX_CTRL_VP8E_SET_TOKEN_PARTITIONS +VPX_CTRL_USE_TYPE(VP8E_GET_LAST_QUANTIZER, int *) +#define VPX_CTRL_VP8E_GET_LAST_QUANTIZER +VPX_CTRL_USE_TYPE(VP8E_GET_LAST_QUANTIZER_64, int *) +#define VPX_CTRL_VP8E_GET_LAST_QUANTIZER_64 +VPX_CTRL_USE_TYPE(VP8E_SET_ARNR_MAXFRAMES, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_MAXFRAMES +VPX_CTRL_USE_TYPE(VP8E_SET_ARNR_STRENGTH, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_STRENGTH +VPX_CTRL_USE_TYPE_DEPRECATED(VP8E_SET_ARNR_TYPE, unsigned int) +#define VPX_CTRL_VP8E_SET_ARNR_TYPE +VPX_CTRL_USE_TYPE(VP8E_SET_TUNING, int) /* vp8e_tuning */ +#define VPX_CTRL_VP8E_SET_TUNING +VPX_CTRL_USE_TYPE(VP8E_SET_CQ_LEVEL, unsigned int) +#define VPX_CTRL_VP8E_SET_CQ_LEVEL +VPX_CTRL_USE_TYPE(VP8E_SET_MAX_INTRA_BITRATE_PCT, unsigned int) +#define VPX_CTRL_VP8E_SET_MAX_INTRA_BITRATE_PCT +VPX_CTRL_USE_TYPE(VP8E_SET_FRAME_FLAGS, int) +#define VPX_CTRL_VP8E_SET_FRAME_FLAGS +VPX_CTRL_USE_TYPE(VP9E_SET_MAX_INTER_BITRATE_PCT, unsigned int) +#define VPX_CTRL_VP9E_SET_MAX_INTER_BITRATE_PCT +VPX_CTRL_USE_TYPE(VP9E_SET_GF_CBR_BOOST_PCT, unsigned int) +#define VPX_CTRL_VP9E_SET_GF_CBR_BOOST_PCT +VPX_CTRL_USE_TYPE(VP8E_SET_TEMPORAL_LAYER_ID, int) +#define VPX_CTRL_VP8E_SET_TEMPORAL_LAYER_ID +VPX_CTRL_USE_TYPE(VP8E_SET_SCREEN_CONTENT_MODE, unsigned int) +#define VPX_CTRL_VP8E_SET_SCREEN_CONTENT_MODE +VPX_CTRL_USE_TYPE(VP9E_SET_LOSSLESS, unsigned int) +#define VPX_CTRL_VP9E_SET_LOSSLESS +VPX_CTRL_USE_TYPE(VP9E_SET_TILE_COLUMNS, int) +#define VPX_CTRL_VP9E_SET_TILE_COLUMNS +VPX_CTRL_USE_TYPE(VP9E_SET_TILE_ROWS, int) +#define VPX_CTRL_VP9E_SET_TILE_ROWS +VPX_CTRL_USE_TYPE(VP9E_SET_FRAME_PARALLEL_DECODING, unsigned int) +#define VPX_CTRL_VP9E_SET_FRAME_PARALLEL_DECODING +VPX_CTRL_USE_TYPE(VP9E_SET_AQ_MODE, unsigned int) +#define VPX_CTRL_VP9E_SET_AQ_MODE +VPX_CTRL_USE_TYPE(VP9E_SET_FRAME_PERIODIC_BOOST, unsigned int) +#define VPX_CTRL_VP9E_SET_FRAME_PERIODIC_BOOST +VPX_CTRL_USE_TYPE(VP9E_SET_NOISE_SENSITIVITY, unsigned int) +#define VPX_CTRL_VP9E_SET_NOISE_SENSITIVITY +VPX_CTRL_USE_TYPE(VP9E_SET_SVC, int) +#define VPX_CTRL_VP9E_SET_SVC +VPX_CTRL_USE_TYPE(VP9E_SET_ROI_MAP, vpx_roi_map_t *) +#define VPX_CTRL_VP9E_SET_ROI_MAP +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_PARAMETERS, void *) +#define VPX_CTRL_VP9E_SET_SVC_PARAMETERS +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_LAYER_ID, vpx_svc_layer_id_t *) +#define VPX_CTRL_VP9E_SET_SVC_LAYER_ID +VPX_CTRL_USE_TYPE(VP9E_SET_TUNE_CONTENT, int) /* vp9e_tune_content */ +#define VPX_CTRL_VP9E_SET_TUNE_CONTENT +VPX_CTRL_USE_TYPE(VP9E_GET_SVC_LAYER_ID, vpx_svc_layer_id_t *) +#define VPX_CTRL_VP9E_GET_SVC_LAYER_ID +VPX_CTRL_USE_TYPE(VP9E_REGISTER_CX_CALLBACK, void *) +#define VPX_CTRL_VP9E_REGISTER_CX_CALLBACK +VPX_CTRL_USE_TYPE(VP9E_SET_COLOR_SPACE, int) +#define VPX_CTRL_VP9E_SET_COLOR_SPACE +VPX_CTRL_USE_TYPE(VP9E_SET_MIN_GF_INTERVAL, unsigned int) +#define VPX_CTRL_VP9E_SET_MIN_GF_INTERVAL +VPX_CTRL_USE_TYPE(VP9E_SET_MAX_GF_INTERVAL, unsigned int) +#define VPX_CTRL_VP9E_SET_MAX_GF_INTERVAL +VPX_CTRL_USE_TYPE(VP9E_GET_ACTIVEMAP, vpx_active_map_t *) +#define VPX_CTRL_VP9E_GET_ACTIVEMAP +VPX_CTRL_USE_TYPE(VP9E_SET_COLOR_RANGE, int) +#define VPX_CTRL_VP9E_SET_COLOR_RANGE +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_REF_FRAME_CONFIG, vpx_svc_ref_frame_config_t *) +#define VPX_CTRL_VP9E_SET_SVC_REF_FRAME_CONFIG +VPX_CTRL_USE_TYPE(VP9E_SET_RENDER_SIZE, int *) +#define VPX_CTRL_VP9E_SET_RENDER_SIZE +VPX_CTRL_USE_TYPE(VP9E_SET_TARGET_LEVEL, unsigned int) +#define VPX_CTRL_VP9E_SET_TARGET_LEVEL +VPX_CTRL_USE_TYPE(VP9E_SET_ROW_MT, unsigned int) +#define VPX_CTRL_VP9E_SET_ROW_MT +VPX_CTRL_USE_TYPE(VP9E_GET_LEVEL, int *) +#define VPX_CTRL_VP9E_GET_LEVEL +VPX_CTRL_USE_TYPE(VP9E_SET_ALT_REF_AQ, int) +#define VPX_CTRL_VP9E_SET_ALT_REF_AQ +VPX_CTRL_USE_TYPE(VP8E_SET_GF_CBR_BOOST_PCT, unsigned int) +#define VPX_CTRL_VP8E_SET_GF_CBR_BOOST_PCT +VPX_CTRL_USE_TYPE(VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST, unsigned int) +#define VPX_CTRL_VP9E_ENABLE_MOTION_VECTOR_UNIT_TEST +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_INTER_LAYER_PRED, unsigned int) +#define VPX_CTRL_VP9E_SET_SVC_INTER_LAYER_PRED +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_FRAME_DROP_LAYER, vpx_svc_frame_drop_t *) +#define VPX_CTRL_VP9E_SET_SVC_FRAME_DROP_LAYER +VPX_CTRL_USE_TYPE(VP9E_GET_SVC_REF_FRAME_CONFIG, vpx_svc_ref_frame_config_t *) +#define VPX_CTRL_VP9E_GET_SVC_REF_FRAME_CONFIG +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_GF_TEMPORAL_REF, unsigned int) +#define VPX_CTRL_VP9E_SET_SVC_GF_TEMPORAL_REF +VPX_CTRL_USE_TYPE(VP9E_SET_SVC_SPATIAL_LAYER_SYNC, + vpx_svc_spatial_layer_sync_t *) +#define VPX_CTRL_VP9E_SET_SVC_SPATIAL_LAYER_SYNC +VPX_CTRL_USE_TYPE(VP9E_SET_TPL, int) +#define VPX_CTRL_VP9E_SET_TPL +VPX_CTRL_USE_TYPE(VP9E_SET_POSTENCODE_DROP, unsigned int) +#define VPX_CTRL_VP9E_SET_POSTENCODE_DROP +VPX_CTRL_USE_TYPE(VP9E_SET_DELTA_Q_UV, int) +#define VPX_CTRL_VP9E_SET_DELTA_Q_UV +VPX_CTRL_USE_TYPE(VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR, int) +#define VPX_CTRL_VP9E_SET_DISABLE_OVERSHOOT_MAXQ_CBR +VPX_CTRL_USE_TYPE(VP9E_SET_DISABLE_LOOPFILTER, int) +#define VPX_CTRL_VP9E_SET_DISABLE_LOOPFILTER +VPX_CTRL_USE_TYPE(VP9E_SET_EXTERNAL_RATE_CONTROL, vpx_rc_funcs_t *) +#define VPX_CTRL_VP9E_SET_EXTERNAL_RATE_CONTROL +VPX_CTRL_USE_TYPE(VP9E_SET_RTC_EXTERNAL_RATECTRL, int) +#define VPX_CTRL_VP9E_SET_RTC_EXTERNAL_RATECTRL +VPX_CTRL_USE_TYPE(VP9E_GET_LOOPFILTER_LEVEL, int *) +#define VPX_CTRL_VP9E_GET_LOOPFILTER_LEVEL +VPX_CTRL_USE_TYPE(VP9E_GET_LAST_QUANTIZER_SVC_LAYERS, int *) +#define VPX_CTRL_VP9E_GET_LAST_QUANTIZER_SVC_LAYERS +VPX_CTRL_USE_TYPE(VP8E_SET_RTC_EXTERNAL_RATECTRL, int) +#define VPX_CTRL_VP8E_SET_RTC_EXTERNAL_RATECTRL +VPX_CTRL_USE_TYPE(VP9E_SET_QUANTIZER_ONE_PASS, int) +#define VPX_CTRL_VP9E_SET_QUANTIZER_ONE_PASS +VPX_CTRL_USE_TYPE(VP9E_SET_KEY_FRAME_FILTERING, int) +#define VPX_CTRL_VP9E_SET_KEY_FRAME_FILTERING +VPX_CTRL_USE_TYPE(VP9E_SET_VALIDATE_HBD_INPUT, int) +#define VPX_CTRL_VP9E_SET_VALIDATE_HBD_INPUT + +/*!\endcond */ +/*! @} - end defgroup vp8_encoder */ +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8CX_H_ diff --git a/native/third_party/ucrt64/include/vpx/vp8dx.h b/native/third_party/ucrt64/include/vpx/vp8dx.h new file mode 100644 index 0000000..a7e6426 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vp8dx.h @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup vp8_decoder WebM VP8/VP9 Decoder + * \ingroup vp8 + * + * @{ + */ +/*!\file + * \brief Provides definitions for using VP8 or VP9 within the vpx Decoder + * interface. + */ +#ifndef VPX_VPX_VP8DX_H_ +#define VPX_VPX_VP8DX_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +/* Include controls common to both the encoder and decoder */ +#include "./vp8.h" + +/*!\name Algorithm interface for VP8 + * + * This interface provides the capability to decode VP8 streams. + * @{ + */ + +/*!\brief A single instance of the VP8 decoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp8_dx(). + */ +extern vpx_codec_iface_t vpx_codec_vp8_dx_algo; + +/*!\brief The interface to the VP8 decoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp8_dx(void); +/*!@} - end algorithm interface member group*/ + +/*!\name Algorithm interface for VP9 + * + * This interface provides the capability to decode VP9 streams. + * @{ + */ + +/*!\brief A single instance of the VP9 decoder. + *\deprecated This access mechanism is provided for backwards compatibility; + * prefer vpx_codec_vp9_dx(). + */ +extern vpx_codec_iface_t vpx_codec_vp9_dx_algo; + +/*!\brief The interface to the VP9 decoder. + */ +extern vpx_codec_iface_t *vpx_codec_vp9_dx(void); +/*!@} - end algorithm interface member group*/ + +/*!\enum vp8_dec_control_id + * \brief VP8 decoder control functions + * + * This set of macros define the control functions available for the VP8 + * decoder interface. + * + * \sa #vpx_codec_control + */ +enum vp8_dec_control_id { + /** control function to get info on which reference frames were updated + * by the last decode + */ + VP8D_GET_LAST_REF_UPDATES = VP8_DECODER_CTRL_ID_START, + + /** check if the indicated frame is corrupted */ + VP8D_GET_FRAME_CORRUPTED, + + /** control function to get info on which reference frames were used + * by the last decode + */ + VP8D_GET_LAST_REF_USED, + + /** decryption function to decrypt encoded buffer data immediately + * before decoding. Takes a vpx_decrypt_init, which contains + * a callback function and opaque context pointer. + */ + VPXD_SET_DECRYPTOR, + VP8D_SET_DECRYPTOR = VPXD_SET_DECRYPTOR, + + /** control function to get the dimensions that the current frame is decoded + * at. This may be different to the intended display size for the frame as + * specified in the wrapper or frame header (see VP9D_GET_DISPLAY_SIZE). */ + VP9D_GET_FRAME_SIZE, + + /** control function to get the current frame's intended display dimensions + * (as specified in the wrapper or frame header). This may be different to + * the decoded dimensions of this frame (see VP9D_GET_FRAME_SIZE). */ + VP9D_GET_DISPLAY_SIZE, + + /** control function to get the bit depth of the stream. */ + VP9D_GET_BIT_DEPTH, + + /** control function to set the byte alignment of the planes in the reference + * buffers. Valid values are power of 2, from 32 to 1024. A value of 0 sets + * legacy alignment. I.e. Y plane is aligned to 32 bytes, U plane directly + * follows Y plane, and V plane directly follows U plane. Default value is 0. + */ + VP9_SET_BYTE_ALIGNMENT, + + /** control function to invert the decoding order to from right to left. The + * function is used in a test to confirm the decoding independence of tile + * columns. The function may be used in application where this order + * of decoding is desired. + * + * TODO(yaowu): Rework the unit test that uses this control, and in a future + * release, this test-only control shall be removed. + */ + VP9_INVERT_TILE_DECODE_ORDER, + + /** control function to set the skip loop filter flag. Valid values are + * integers. The decoder will skip the loop filter when its value is set to + * nonzero. If the loop filter is skipped the decoder may accumulate decode + * artifacts. The default value is 0. + */ + VP9_SET_SKIP_LOOP_FILTER, + + /** control function to decode SVC stream up to the x spatial layers, + * where x is passed in through the control, and is 0 for base layer. + */ + VP9_DECODE_SVC_SPATIAL_LAYER, + + /*!\brief Codec control function to get last decoded frame quantizer. + * + * Return value uses internal quantizer scale defined by the codec. + * + * Supported in codecs: VP8, VP9 + */ + VPXD_GET_LAST_QUANTIZER, + + /*!\brief Codec control function to set row level multi-threading. + * + * 0 : off, 1 : on + * + * Supported in codecs: VP9 + */ + VP9D_SET_ROW_MT, + + /*!\brief Codec control function to set loopfilter optimization. + * + * 0 : off, Loop filter is done after all tiles have been decoded + * 1 : on, Loop filter is done immediately after decode without + * waiting for all threads to sync. + * + * Supported in codecs: VP9 + */ + VP9D_SET_LOOP_FILTER_OPT, + + VP8_DECODER_CTRL_ID_MAX +}; + +/** Decrypt n bytes of data from input -> output, using the decrypt_state + * passed in VPXD_SET_DECRYPTOR. + */ +typedef void (*vpx_decrypt_cb)(void *decrypt_state, const unsigned char *input, + unsigned char *output, int count); + +/*!\brief Structure to hold decryption state + * + * Defines a structure to hold the decryption state and access function. + */ +typedef struct vpx_decrypt_init { + /*! Decrypt callback. */ + vpx_decrypt_cb decrypt_cb; + + /*! Decryption state. */ + void *decrypt_state; +} vpx_decrypt_init; + +/*!\cond */ +/*!\brief VP8 decoder control function parameter type + * + * Defines the data types that VP8D control functions take. Note that + * additional common controls are defined in vp8.h + * + */ + +VPX_CTRL_USE_TYPE(VP8D_GET_LAST_REF_UPDATES, int *) +#define VPX_CTRL_VP8D_GET_LAST_REF_UPDATES +VPX_CTRL_USE_TYPE(VP8D_GET_FRAME_CORRUPTED, int *) +#define VPX_CTRL_VP8D_GET_FRAME_CORRUPTED +VPX_CTRL_USE_TYPE(VP8D_GET_LAST_REF_USED, int *) +#define VPX_CTRL_VP8D_GET_LAST_REF_USED +VPX_CTRL_USE_TYPE(VPXD_SET_DECRYPTOR, vpx_decrypt_init *) +#define VPX_CTRL_VPXD_SET_DECRYPTOR +VPX_CTRL_USE_TYPE(VP8D_SET_DECRYPTOR, vpx_decrypt_init *) +#define VPX_CTRL_VP8D_SET_DECRYPTOR +VPX_CTRL_USE_TYPE(VP9D_GET_FRAME_SIZE, int *) +#define VPX_CTRL_VP9D_GET_FRAME_SIZE +VPX_CTRL_USE_TYPE(VP9D_GET_DISPLAY_SIZE, int *) +#define VPX_CTRL_VP9D_GET_DISPLAY_SIZE +VPX_CTRL_USE_TYPE(VP9D_GET_BIT_DEPTH, unsigned int *) +#define VPX_CTRL_VP9D_GET_BIT_DEPTH +VPX_CTRL_USE_TYPE(VP9_SET_BYTE_ALIGNMENT, int) +#define VPX_CTRL_VP9_SET_BYTE_ALIGNMENT +VPX_CTRL_USE_TYPE(VP9_INVERT_TILE_DECODE_ORDER, int) +#define VPX_CTRL_VP9_INVERT_TILE_DECODE_ORDER +VPX_CTRL_USE_TYPE(VP9_SET_SKIP_LOOP_FILTER, int) +#define VPX_CTRL_VP9_SET_SKIP_LOOP_FILTER +VPX_CTRL_USE_TYPE(VP9_DECODE_SVC_SPATIAL_LAYER, int) +#define VPX_CTRL_VP9_DECODE_SVC_SPATIAL_LAYER +VPX_CTRL_USE_TYPE(VPXD_GET_LAST_QUANTIZER, int *) +#define VPX_CTRL_VPXD_GET_LAST_QUANTIZER +VPX_CTRL_USE_TYPE(VP9D_SET_ROW_MT, int) +#define VPX_CTRL_VP9_DECODE_SET_ROW_MT +VPX_CTRL_USE_TYPE(VP9D_SET_LOOP_FILTER_OPT, int) +#define VPX_CTRL_VP9_SET_LOOP_FILTER_OPT + +/*!\endcond */ +/*! @} - end defgroup vp8_decoder */ + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VP8DX_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_codec.h b/native/third_party/ucrt64/include/vpx/vpx_codec.h new file mode 100644 index 0000000..d9c9256 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_codec.h @@ -0,0 +1,475 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\defgroup codec Common Algorithm Interface + * This abstraction allows applications to easily support multiple video + * formats with minimal code duplication. This section describes the interface + * common to all codecs (both encoders and decoders). + * @{ + */ + +/*!\file + * \brief Describes the codec algorithm interface to applications. + * + * This file describes the interface between an application and a + * video codec algorithm. + * + * An application instantiates a specific codec instance by using + * vpx_codec_dec_init() or vpx_codec_enc_init() and a pointer to the + * algorithm's interface structure: + *
+ *     my_app.c:
+ *       extern vpx_codec_iface_t my_codec;
+ *       {
+ *           vpx_codec_ctx_t algo;
+ *           int threads = 4;
+ *           vpx_codec_dec_cfg_t cfg = { threads, 0, 0 };
+ *           res = vpx_codec_dec_init(&algo, &my_codec, &cfg, 0);
+ *       }
+ *     
+ * + * Once initialized, the instance is manged using other functions from + * the vpx_codec_* family. + */ +#ifndef VPX_VPX_VPX_CODEC_H_ +#define VPX_VPX_VPX_CODEC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_image.h" +#include "./vpx_integer.h" + +/*!\brief Decorator indicating a function is deprecated */ +#ifndef VPX_DEPRECATED +#if defined(__GNUC__) +#define VPX_DEPRECATED __attribute__((deprecated)) +#elif defined(_MSC_VER) +#define VPX_DEPRECATED +#else +#define VPX_DEPRECATED +#endif +#endif /* VPX_DEPRECATED */ + +#ifndef VPX_DECLSPEC_DEPRECATED +#if defined(__GNUC__) +#define VPX_DECLSPEC_DEPRECATED /**< \copydoc #VPX_DEPRECATED */ +#elif defined(_MSC_VER) +/*!\brief \copydoc #VPX_DEPRECATED */ +#define VPX_DECLSPEC_DEPRECATED __declspec(deprecated) +#else +#define VPX_DECLSPEC_DEPRECATED /**< \copydoc #VPX_DEPRECATED */ +#endif +#endif /* VPX_DECLSPEC_DEPRECATED */ + +/*!\brief Decorator indicating a function is potentially unused */ +#ifndef VPX_UNUSED +#if defined(__GNUC__) || defined(__clang__) +#define VPX_UNUSED __attribute__((unused)) +#else +#define VPX_UNUSED +#endif +#endif /* VPX_UNUSED */ + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_CODEC_ABI_VERSION (4 + VPX_IMAGE_ABI_VERSION) /**<\hideinitializer*/ + +/*!\brief Algorithm return codes */ +typedef enum { + /*!\brief Operation completed without error */ + VPX_CODEC_OK, + + /*!\brief Unspecified error */ + VPX_CODEC_ERROR, + + /*!\brief Memory operation failed */ + VPX_CODEC_MEM_ERROR, + + /*!\brief ABI version mismatch */ + VPX_CODEC_ABI_MISMATCH, + + /*!\brief Algorithm does not have required capability */ + VPX_CODEC_INCAPABLE, + + /*!\brief The given bitstream is not supported. + * + * The bitstream was unable to be parsed at the highest level. The decoder + * is unable to proceed. This error \ref SHOULD be treated as fatal to the + * stream. */ + VPX_CODEC_UNSUP_BITSTREAM, + + /*!\brief Encoded bitstream uses an unsupported feature + * + * The decoder does not implement a feature required by the encoder. This + * return code should only be used for features that prevent future + * pictures from being properly decoded. This error \ref MAY be treated as + * fatal to the stream or \ref MAY be treated as fatal to the current GOP. + */ + VPX_CODEC_UNSUP_FEATURE, + + /*!\brief The coded data for this stream is corrupt or incomplete + * + * There was a problem decoding the current frame. This return code + * should only be used for failures that prevent future pictures from + * being properly decoded. This error \ref MAY be treated as fatal to the + * stream or \ref MAY be treated as fatal to the current GOP. If decoding + * is continued for the current GOP, artifacts may be present. + */ + VPX_CODEC_CORRUPT_FRAME, + + /*!\brief An application-supplied parameter is not valid. + * + */ + VPX_CODEC_INVALID_PARAM, + + /*!\brief An iterator reached the end of list. + * + */ + VPX_CODEC_LIST_END + +} vpx_codec_err_t; + +/*! \brief Codec capabilities bitfield + * + * Each codec advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra interfaces + * or functionality, and are not required to be supported. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +typedef long vpx_codec_caps_t; +#define VPX_CODEC_CAP_DECODER 0x1 /**< Is a decoder */ +#define VPX_CODEC_CAP_ENCODER 0x2 /**< Is an encoder */ + +/*! Can support images at greater than 8 bitdepth. + */ +#define VPX_CODEC_CAP_HIGHBITDEPTH 0x4 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow for + * proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +typedef long vpx_codec_flags_t; + +/*!\brief Codec interface structure. + * + * Contains function pointers and other data private to the codec + * implementation. This structure is opaque to the application. + */ +typedef const struct vpx_codec_iface vpx_codec_iface_t; + +/*!\brief Codec private data structure. + * + * Contains data private to the codec implementation. This structure is opaque + * to the application. + */ +typedef struct vpx_codec_priv vpx_codec_priv_t; + +/*!\brief Iterator + * + * Opaque storage used for iterating over lists. + */ +typedef const void *vpx_codec_iter_t; + +/*!\brief Codec context structure + * + * All codecs \ref MUST support this context structure fully. In general, + * this data should be considered private to the codec algorithm, and + * not be manipulated or examined by the calling application. Applications + * may reference the 'name' member to get a printable description of the + * algorithm. + */ +typedef struct vpx_codec_ctx { + const char *name; /**< Printable interface name */ + vpx_codec_iface_t *iface; /**< Interface pointers */ + vpx_codec_err_t err; /**< Last returned error */ + const char *err_detail; /**< Detailed info, if available */ + vpx_codec_flags_t init_flags; /**< Flags passed at init time */ + union { + /**< Decoder Configuration Pointer */ + const struct vpx_codec_dec_cfg *dec; + /**< Encoder Configuration Pointer */ + const struct vpx_codec_enc_cfg *enc; + const void *raw; + } config; /**< Configuration pointer aliasing union */ + vpx_codec_priv_t *priv; /**< Algorithm private storage */ +} vpx_codec_ctx_t; + +/*!\brief Bit depth for codec + * * + * This enumeration determines the bit depth of the codec. + */ +typedef enum vpx_bit_depth { + VPX_BITS_8 = 8, /**< 8 bits */ + VPX_BITS_10 = 10, /**< 10 bits */ + VPX_BITS_12 = 12, /**< 12 bits */ +} vpx_bit_depth_t; + +/* + * Library Version Number Interface + * + * For example, see the following sample return values: + * vpx_codec_version() (1<<16 | 2<<8 | 3) + * vpx_codec_version_str() "v1.2.3-rc1-16-gec6a1ba" + * vpx_codec_version_extra_str() "rc1-16-gec6a1ba" + */ + +/*!\brief Return the version information (as an integer) + * + * Returns a packed encoding of the library version number. This will only + * include + * the major.minor.patch component of the version number. Note that this encoded + * value should be accessed through the macros provided, as the encoding may + * change + * in the future. + * + */ +int vpx_codec_version(void); +#define VPX_VERSION_MAJOR(v) \ + (((v) >> 16) & 0xff) /**< extract major from packed version */ +#define VPX_VERSION_MINOR(v) \ + (((v) >> 8) & 0xff) /**< extract minor from packed version */ +#define VPX_VERSION_PATCH(v) \ + (((v) >> 0) & 0xff) /**< extract patch from packed version */ + +/*!\brief Return the version major number */ +#define vpx_codec_version_major() ((vpx_codec_version() >> 16) & 0xff) + +/*!\brief Return the version minor number */ +#define vpx_codec_version_minor() ((vpx_codec_version() >> 8) & 0xff) + +/*!\brief Return the version patch number */ +#define vpx_codec_version_patch() ((vpx_codec_version() >> 0) & 0xff) + +/*!\brief Return the version information (as a string) + * + * Returns a printable string containing the full library version number. This + * may + * contain additional text following the three digit version number, as to + * indicate + * release candidates, prerelease versions, etc. + * + */ +const char *vpx_codec_version_str(void); + +/*!\brief Return the version information (as a string) + * + * Returns a printable "extra string". This is the component of the string + * returned + * by vpx_codec_version_str() following the three digit version number. + * + */ +const char *vpx_codec_version_extra_str(void); + +/*!\brief Return the build configuration + * + * Returns a printable string containing an encoded version of the build + * configuration. This may be useful to vpx support. + * + */ +const char *vpx_codec_build_config(void); + +/*!\brief Return the name for a given interface + * + * Returns a human readable string for name of the given codec interface. + * + * \param[in] iface Interface pointer + * + */ +const char *vpx_codec_iface_name(vpx_codec_iface_t *iface); + +/*!\brief Convert error number to printable string + * + * Returns a human readable string for the last error returned by the + * algorithm. The returned error will be one line and will not contain + * any newline characters. + * + * + * \param[in] err Error number. + * + */ +const char *vpx_codec_err_to_string(vpx_codec_err_t err); + +/*!\brief Retrieve error synopsis for codec context + * + * Returns a human readable string for the last error returned by the + * algorithm. The returned error will be one line and will not contain + * any newline characters. + * + * + * \param[in] ctx Pointer to this instance's context. + * + */ +const char *vpx_codec_error(const vpx_codec_ctx_t *ctx); + +/*!\brief Retrieve detailed error information for codec context + * + * Returns a human readable string providing detailed information about + * the last error. The returned string is only valid until the next + * vpx_codec_* function call (except vpx_codec_error and + * vpx_codec_error_detail) on the codec context. + * + * \param[in] ctx Pointer to this instance's context. + * + * \retval NULL + * No detailed information is available. + */ +const char *vpx_codec_error_detail(const vpx_codec_ctx_t *ctx); + +/* REQUIRED FUNCTIONS + * + * The following functions are required to be implemented for all codecs. + * They represent the base case functionality expected of all codecs. + */ + +/*!\brief Destroy a codec instance + * + * Destroys a codec context, freeing any associated memory buffers. + * + * \param[in] ctx Pointer to this instance's context + * + * \retval #VPX_CODEC_OK + * The codec instance has been destroyed. + * \retval #VPX_CODEC_INVALID_PARAM + * ctx is a null pointer. + * \retval #VPX_CODEC_ERROR + * Codec context not initialized. + */ +vpx_codec_err_t vpx_codec_destroy(vpx_codec_ctx_t *ctx); + +/*!\brief Get the capabilities of an algorithm. + * + * Retrieves the capabilities bitfield from the algorithm's interface. + * + * \param[in] iface Pointer to the algorithm interface + * + */ +vpx_codec_caps_t vpx_codec_get_caps(vpx_codec_iface_t *iface); + +/*!\brief Control algorithm + * + * This function is used to exchange algorithm specific data with the codec + * instance. This can be used to implement features specific to a particular + * algorithm. + * + * This wrapper function dispatches the request to the helper function + * associated with the given ctrl_id. It tries to call this function + * transparently, but will return #VPX_CODEC_ERROR if the request could not + * be dispatched. + * + * Note that this function should not be used directly. Call the + * #vpx_codec_control wrapper macro instead. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] ctrl_id Algorithm specific control identifier + * + * \retval #VPX_CODEC_OK + * The control request was processed. + * \retval #VPX_CODEC_ERROR + * The control request was not processed. + * \retval #VPX_CODEC_INVALID_PARAM + * The data was not valid. + */ +vpx_codec_err_t vpx_codec_control_(vpx_codec_ctx_t *ctx, int ctrl_id, ...); +#if defined(VPX_DISABLE_CTRL_TYPECHECKS) && VPX_DISABLE_CTRL_TYPECHECKS +#define vpx_codec_control(ctx, id, data) vpx_codec_control_(ctx, id, data) +#define VPX_CTRL_USE_TYPE(id, typ) +#define VPX_CTRL_USE_TYPE_DEPRECATED(id, typ) +#define VPX_CTRL_VOID(id, typ) + +#else +/*!\brief vpx_codec_control wrapper macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). + * + * \internal + * It works by dispatching the call to the control function through a wrapper + * function named with the id parameter. + */ +#define vpx_codec_control(ctx, id, data) \ + vpx_codec_control_##id(ctx, id, data) /**<\hideinitializer*/ + +/*!\brief vpx_codec_control type definition macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). It defines the type of the argument for a given + * control identifier. + * + * \internal + * It defines a static function with + * the correctly typed arguments as a wrapper to the type-unsafe internal + * function. + */ +#define VPX_CTRL_USE_TYPE(id, typ) \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *, int, typ) \ + VPX_UNUSED; \ + \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *ctx, \ + int ctrl_id, typ data) { \ + return vpx_codec_control_(ctx, ctrl_id, data); \ + } /**<\hideinitializer*/ + +/*!\brief vpx_codec_control deprecated type definition macro + * + * Like #VPX_CTRL_USE_TYPE, but indicates that the specified control is + * deprecated and should not be used. Consult the documentation for your + * codec for more information. + * + * \internal + * It defines a static function with the correctly typed arguments as a + * wrapper to the type-unsafe internal function. + */ +#define VPX_CTRL_USE_TYPE_DEPRECATED(id, typ) \ + VPX_DECLSPEC_DEPRECATED static vpx_codec_err_t vpx_codec_control_##id( \ + vpx_codec_ctx_t *, int, typ) VPX_DEPRECATED VPX_UNUSED; \ + \ + VPX_DECLSPEC_DEPRECATED static vpx_codec_err_t vpx_codec_control_##id( \ + vpx_codec_ctx_t *ctx, int ctrl_id, typ data) { \ + return vpx_codec_control_(ctx, ctrl_id, data); \ + } /**<\hideinitializer*/ + +/*!\brief vpx_codec_control void type definition macro + * + * This macro allows for type safe conversions across the variadic parameter + * to vpx_codec_control_(). It indicates that a given control identifier takes + * no argument. + * + * \internal + * It defines a static function without a data argument as a wrapper to the + * type-unsafe internal function. + */ +#define VPX_CTRL_VOID(id) \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *, int) \ + VPX_UNUSED; \ + \ + static vpx_codec_err_t vpx_codec_control_##id(vpx_codec_ctx_t *ctx, \ + int ctrl_id) { \ + return vpx_codec_control_(ctx, ctrl_id); \ + } /**<\hideinitializer*/ + +#endif + +/*!@} - end defgroup codec*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_CODEC_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_decoder.h b/native/third_party/ucrt64/include/vpx/vpx_decoder.h new file mode 100644 index 0000000..796180d --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_decoder.h @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VPX_DECODER_H_ +#define VPX_VPX_VPX_DECODER_H_ + +/*!\defgroup decoder Decoder Algorithm Interface + * \ingroup codec + * This abstraction allows applications using this decoder to easily support + * multiple video formats with minimal code duplication. This section describes + * the interface common to all decoders. + * @{ + */ + +/*!\file + * \brief Describes the decoder algorithm interface to applications. + * + * This file describes the interface between an application and a + * video decoder algorithm. + * + */ +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_codec.h" // IWYU pragma: export +#include "./vpx_frame_buffer.h" + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_DECODER_ABI_VERSION \ + (3 + VPX_CODEC_ABI_VERSION) /**<\hideinitializer*/ + +/*! \brief Decoder capabilities bitfield + * + * Each decoder advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra interfaces + * or functionality, and are not required to be supported by a decoder. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +#define VPX_CODEC_CAP_PUT_SLICE 0x10000 /**< Will issue put_slice callbacks */ +#define VPX_CODEC_CAP_PUT_FRAME 0x20000 /**< Will issue put_frame callbacks */ +#define VPX_CODEC_CAP_POSTPROC 0x40000 /**< Can postprocess decoded frame */ +/*!\brief Can conceal errors due to packet loss */ +#define VPX_CODEC_CAP_ERROR_CONCEALMENT 0x80000 +/*!\brief Can receive encoded frames one fragment at a time */ +#define VPX_CODEC_CAP_INPUT_FRAGMENTS 0x100000 +/*!\brief Can support frame-based multi-threading */ +#define VPX_CODEC_CAP_FRAME_THREADING 0x200000 +/*!\brief Can support external frame buffers */ +#define VPX_CODEC_CAP_EXTERNAL_FRAME_BUFFER 0x400000 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow for + * proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +#define VPX_CODEC_USE_POSTPROC 0x10000 /**< Postprocess decoded frame */ +/*!\brief Conceal errors in decoded frames */ +#define VPX_CODEC_USE_ERROR_CONCEALMENT 0x20000 +/*!\brief The input frame should be passed to the decoder one fragment at a + * time */ +#define VPX_CODEC_USE_INPUT_FRAGMENTS 0x40000 +/*!\brief Enable frame-based multi-threading */ +#define VPX_CODEC_USE_FRAME_THREADING 0x80000 + +/*!\brief Stream properties + * + * This structure is used to query or set properties of the decoded + * stream. Algorithms may extend this structure with data specific + * to their bitstream by setting the sz member appropriately. + */ +typedef struct vpx_codec_stream_info { + unsigned int sz; /**< Size of this structure */ + unsigned int w; /**< Width (or 0 for unknown/default) */ + unsigned int h; /**< Height (or 0 for unknown/default) */ + unsigned int is_kf; /**< Current frame is a keyframe */ +} vpx_codec_stream_info_t; + +/* REQUIRED FUNCTIONS + * + * The following functions are required to be implemented for all decoders. + * They represent the base case functionality expected of all decoders. + */ + +/*!\brief Initialization Configurations + * + * This structure is used to pass init time configuration options to the + * decoder. + */ +typedef struct vpx_codec_dec_cfg { + unsigned int threads; /**< Maximum number of threads to use, default 1 */ + unsigned int w; /**< Width */ + unsigned int h; /**< Height */ +} vpx_codec_dec_cfg_t; /**< alias for struct vpx_codec_dec_cfg */ + +/*!\brief Initialize a decoder instance + * + * Initializes a decoder context using the given interface. Applications + * should call the vpx_codec_dec_init convenience macro instead of this + * function directly, to ensure that the ABI version number parameter + * is properly initialized. + * + * If the library was configured with --disable-multithread, this call + * is not thread safe and should be guarded with a lock if being used + * in a multithreaded context. + * + * On success, vpx_codec_destroy() must be used to free resources allocated for + * the decoder context. If vpx_codec_dec_init_ver() fails, it is not necessary + * to call vpx_codec_destroy() on the decoder context. + * + * \param[in] ctx Pointer to this instance's context. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg Configuration to use, if known. May be NULL. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] ver ABI version number. Must be set to + * VPX_DECODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The decoder algorithm has been initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + */ +vpx_codec_err_t vpx_codec_dec_init_ver(vpx_codec_ctx_t *ctx, + vpx_codec_iface_t *iface, + const vpx_codec_dec_cfg_t *cfg, + vpx_codec_flags_t flags, int ver); + +/*!\brief Convenience macro for vpx_codec_dec_init_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_dec_init(ctx, iface, cfg, flags) \ + vpx_codec_dec_init_ver(ctx, iface, cfg, flags, VPX_DECODER_ABI_VERSION) + +/*!\brief Parse stream info from a buffer + * + * Performs high level parsing of the bitstream. Construction of a decoder + * context is not necessary. Can be used to determine if the bitstream is + * of the proper format, and to extract information from the stream. + * + * \param[in] iface Pointer to the algorithm interface + * \param[in] data Pointer to a block of data to parse + * \param[in] data_sz Size of the data buffer + * \param[in,out] si Pointer to stream info to update. The sz member + * \ref MUST be properly initialized, but \ref MAY be + * clobbered by the algorithm. This parameter \ref MAY + * be NULL. + * + * \retval #VPX_CODEC_OK + * Bitstream is parsable and stream information updated + */ +vpx_codec_err_t vpx_codec_peek_stream_info(vpx_codec_iface_t *iface, + const uint8_t *data, + unsigned int data_sz, + vpx_codec_stream_info_t *si); + +/*!\brief Return information about the current stream. + * + * Returns information about the stream that has been parsed during decoding. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] si Pointer to stream info to update. The sz member + * \ref MUST be properly initialized, but \ref MAY be + * clobbered by the algorithm. This parameter \ref MAY + * be NULL. + * + * \retval #VPX_CODEC_OK + * Bitstream is parsable and stream information updated + */ +vpx_codec_err_t vpx_codec_get_stream_info(vpx_codec_ctx_t *ctx, + vpx_codec_stream_info_t *si); + +/*!\brief Decode data + * + * Processes a buffer of coded data. If the processing results in a new + * decoded frame becoming available, put_slice and put_frame callbacks may be + * invoked, as appropriate. Encoded data \ref MUST be passed in DTS (decode + * time stamp) order. Frames produced will always be in PTS (presentation + * time stamp) order. + * If the decoder is configured with VPX_CODEC_USE_INPUT_FRAGMENTS enabled, + * data and data_sz can contain a fragment of the encoded frame. Fragment + * \#n must contain at least partition \#n, but can also contain subsequent + * partitions (\#n+1 - \#n+i), and if so, fragments \#n+1, .., \#n+i must + * be empty. When no more data is available, this function should be called + * with NULL as data and 0 as data_sz. The memory passed to this function + * must be available until the frame has been decoded. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] data Pointer to this block of new coded data. If + * NULL, the put_frame callback is invoked for + * the previously decoded frame. + * \param[in] data_sz Size of the coded data, in bytes. + * \param[in] user_priv Application specific data to associate with + * this frame. + * \param[in] deadline Soft deadline the decoder should attempt to meet, + * in us. Set to zero for unlimited. + * NOTE: The deadline parameter is ignored. Always + * pass 0. + * + * \return Returns #VPX_CODEC_OK if the coded data was processed completely + * and future pictures can be decoded without error. Otherwise, + * see the descriptions of the other error codes in ::vpx_codec_err_t + * for recoverability capabilities. + */ +vpx_codec_err_t vpx_codec_decode(vpx_codec_ctx_t *ctx, const uint8_t *data, + unsigned int data_sz, void *user_priv, + long deadline); + +/*!\brief Decoded frames iterator + * + * Iterates over a list of the frames available for display. The iterator + * storage should be initialized to NULL to start the iteration. Iteration is + * complete when this function returns NULL. + * + * The list of available frames becomes valid upon completion of the + * vpx_codec_decode call, and remains valid until the next call to + * vpx_codec_decode. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] iter Iterator storage, initialized to NULL + * + * \return Returns a pointer to an image, if one is ready for display. Frames + * produced will always be in PTS (presentation time stamp) order. + */ +vpx_image_t *vpx_codec_get_frame(vpx_codec_ctx_t *ctx, vpx_codec_iter_t *iter); + +/*!\defgroup cap_put_frame Frame-Based Decoding Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_PUT_FRAME capability. Calling this + * function for codecs that don't advertise this capability will result in + * an error code being returned, usually VPX_CODEC_INCAPABLE. + * @{ + */ + +/*!\brief put frame callback prototype + * + * This callback is invoked by the decoder to notify the application of + * the availability of decoded image data. + */ +typedef void (*vpx_codec_put_frame_cb_fn_t)(void *user_priv, + const vpx_image_t *img); + +/*!\brief Register for notification of frame completion. + * + * Registers a given function to be called when a decoded frame is + * available. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb Pointer to the callback function + * \param[in] user_priv User's private data + * + * \retval #VPX_CODEC_OK + * Callback successfully registered. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of posting frame completion. + */ +vpx_codec_err_t vpx_codec_register_put_frame_cb(vpx_codec_ctx_t *ctx, + vpx_codec_put_frame_cb_fn_t cb, + void *user_priv); + +/*!@} - end defgroup cap_put_frame */ + +/*!\defgroup cap_put_slice Slice-Based Decoding Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_PUT_SLICE capability. Calling this + * function for codecs that don't advertise this capability will result in + * an error code being returned, usually VPX_CODEC_INCAPABLE. + * @{ + */ + +/*!\brief put slice callback prototype + * + * This callback is invoked by the decoder to notify the application of + * the availability of partially decoded image data. + */ +typedef void (*vpx_codec_put_slice_cb_fn_t)(void *user_priv, + const vpx_image_t *img, + const vpx_image_rect_t *valid, + const vpx_image_rect_t *update); + +/*!\brief Register for notification of slice completion. + * + * Registers a given function to be called when a decoded slice is + * available. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb Pointer to the callback function + * \param[in] user_priv User's private data + * + * \retval #VPX_CODEC_OK + * Callback successfully registered. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of posting slice completion. + */ +vpx_codec_err_t vpx_codec_register_put_slice_cb(vpx_codec_ctx_t *ctx, + vpx_codec_put_slice_cb_fn_t cb, + void *user_priv); + +/*!@} - end defgroup cap_put_slice*/ + +/*!\defgroup cap_external_frame_buffer External Frame Buffer Functions + * + * The following function is required to be implemented for all decoders + * that advertise the VPX_CODEC_CAP_EXTERNAL_FRAME_BUFFER capability. + * Calling this function for codecs that don't advertise this capability + * will result in an error code being returned, usually VPX_CODEC_INCAPABLE. + * + * \note + * Currently this only works with VP9. + * @{ + */ + +/*!\brief Pass in external frame buffers for the decoder to use. + * + * Registers functions to be called when libvpx needs a frame buffer + * to decode the current frame and a function to be called when libvpx does + * not internally reference the frame buffer. This set function must + * be called before the first call to decode or libvpx will assume the + * default behavior of allocating frame buffers internally. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cb_get Pointer to the get callback function + * \param[in] cb_release Pointer to the release callback function + * \param[in] cb_priv Callback's private data + * + * \retval #VPX_CODEC_OK + * External frame buffers will be used by libvpx. + * \retval #VPX_CODEC_INVALID_PARAM + * One or more of the callbacks were NULL. + * \retval #VPX_CODEC_ERROR + * Decoder context not initialized. + * \retval #VPX_CODEC_INCAPABLE + * Algorithm not capable of using external frame buffers. + * + * \note + * When decoding VP9, the application may be required to pass in at least + * #VP9_MAXIMUM_REF_BUFFERS + #VPX_MAXIMUM_WORK_BUFFERS external frame + * buffers. + */ +vpx_codec_err_t vpx_codec_set_frame_buffer_functions( + vpx_codec_ctx_t *ctx, vpx_get_frame_buffer_cb_fn_t cb_get, + vpx_release_frame_buffer_cb_fn_t cb_release, void *cb_priv); + +/*!@} - end defgroup cap_external_frame_buffer */ + +/*!@} - end defgroup decoder*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_DECODER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_encoder.h b/native/third_party/ucrt64/include/vpx/vpx_encoder.h new file mode 100644 index 0000000..6a87f91 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_encoder.h @@ -0,0 +1,1163 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ +#ifndef VPX_VPX_VPX_ENCODER_H_ +#define VPX_VPX_VPX_ENCODER_H_ + +/*!\defgroup encoder Encoder Algorithm Interface + * \ingroup codec + * This abstraction allows applications using this encoder to easily support + * multiple video formats with minimal code duplication. This section describes + * the interface common to all encoders. + * @{ + */ + +/*!\file + * \brief Describes the encoder algorithm interface to applications. + * + * This file describes the interface between an application and a + * video encoder algorithm. + * + */ +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_codec.h" // IWYU pragma: export +#include "./vpx_ext_ratectrl.h" + +/*! Temporal Scalability: Maximum length of the sequence defining frame + * layer membership + */ +#define VPX_TS_MAX_PERIODICITY 16 + +/*! Temporal Scalability: Maximum number of coding layers */ +#define VPX_TS_MAX_LAYERS 5 + +/*! Temporal+Spatial Scalability: Maximum number of coding layers */ +#define VPX_MAX_LAYERS 12 // 3 temporal + 4 spatial layers are allowed. + +/*! Spatial Scalability: Maximum number of coding layers */ +#define VPX_SS_MAX_LAYERS 5 + +/*! Spatial Scalability: Default number of coding layers */ +#define VPX_SS_DEFAULT_LAYERS 1 + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + * + * \note + * VPX_ENCODER_ABI_VERSION has a VPX_EXT_RATECTRL_ABI_VERSION component + * because the VP9E_SET_EXTERNAL_RATE_CONTROL codec control uses + * vpx_rc_funcs_t. + */ +#define VPX_ENCODER_ABI_VERSION \ + (18 + VPX_CODEC_ABI_VERSION + \ + VPX_EXT_RATECTRL_ABI_VERSION) /**<\hideinitializer*/ + +/*! \brief Encoder capabilities bitfield + * + * Each encoder advertises the capabilities it supports as part of its + * ::vpx_codec_iface_t interface structure. Capabilities are extra + * interfaces or functionality, and are not required to be supported + * by an encoder. + * + * The available flags are specified by VPX_CODEC_CAP_* defines. + */ +#define VPX_CODEC_CAP_PSNR 0x10000 /**< Can issue PSNR packets */ + +/*! Can output one partition at a time. Each partition is returned in its + * own VPX_CODEC_CX_FRAME_PKT, with the FRAME_IS_FRAGMENT flag set for + * every partition but the last. In this mode all frames are always + * returned partition by partition. + */ +#define VPX_CODEC_CAP_OUTPUT_PARTITION 0x20000 + +/*! \brief Initialization-time Feature Enabling + * + * Certain codec features must be known at initialization time, to allow + * for proper memory allocation. + * + * The available flags are specified by VPX_CODEC_USE_* defines. + */ +#define VPX_CODEC_USE_PSNR 0x10000 /**< Calculate PSNR on each frame */ +/*!\brief Make the encoder output one partition at a time. */ +#define VPX_CODEC_USE_OUTPUT_PARTITION 0x20000 +#define VPX_CODEC_USE_HIGHBITDEPTH 0x40000 /**< Use high bitdepth */ + +/*!\brief Generic fixed size buffer structure + * + * This structure is able to hold a reference to any fixed size buffer. + */ +typedef struct vpx_fixed_buf { + void *buf; /**< Pointer to the data */ + size_t sz; /**< Length of the buffer, in chars */ +} vpx_fixed_buf_t; /**< alias for struct vpx_fixed_buf */ + +/*!\brief Time Stamp Type + * + * An integer, which when multiplied by the stream's time base, provides + * the absolute time of a sample. + */ +typedef int64_t vpx_codec_pts_t; + +/*!\brief Compressed Frame Flags + * + * This type represents a bitfield containing information about a compressed + * frame that may be useful to an application. The most significant 16 bits + * can be used by an algorithm to provide additional detail, for example to + * support frame types that are codec specific (MPEG-1 D-frames for example) + */ +typedef uint32_t vpx_codec_frame_flags_t; +#define VPX_FRAME_IS_KEY 0x1u /**< frame is the start of a GOP */ +/*!\brief frame can be dropped without affecting the stream (no future frame + * depends on this one) */ +#define VPX_FRAME_IS_DROPPABLE 0x2u +/*!\brief frame should be decoded but will not be shown */ +#define VPX_FRAME_IS_INVISIBLE 0x4u +/*!\brief this is a fragment of the encoded frame */ +#define VPX_FRAME_IS_FRAGMENT 0x8u + +/*!\brief Error Resilient flags + * + * These flags define which error resilient features to enable in the + * encoder. The flags are specified through the + * vpx_codec_enc_cfg::g_error_resilient variable. + */ +typedef uint32_t vpx_codec_er_flags_t; +/*!\brief Improve resiliency against losses of whole frames */ +#define VPX_ERROR_RESILIENT_DEFAULT 0x1u +/*!\brief The frame partitions are independently decodable by the bool decoder, + * meaning that partitions can be decoded even though earlier partitions have + * been lost. Note that intra prediction is still done over the partition + * boundary. + * \note This is only supported by VP8.*/ +#define VPX_ERROR_RESILIENT_PARTITIONS 0x2u + +/*!\brief Encoder output packet variants + * + * This enumeration lists the different kinds of data packets that can be + * returned by calls to vpx_codec_get_cx_data(). Algorithms \ref MAY + * extend this list to provide additional functionality. + */ +enum vpx_codec_cx_pkt_kind { + VPX_CODEC_CX_FRAME_PKT, /**< Compressed video frame */ + VPX_CODEC_STATS_PKT, /**< Two-pass statistics for this frame */ + VPX_CODEC_FPMB_STATS_PKT, /**< first pass mb statistics for this frame */ + VPX_CODEC_PSNR_PKT, /**< PSNR statistics for this frame */ + VPX_CODEC_CUSTOM_PKT = 256 /**< Algorithm extensions */ +}; + +/*!\brief Encoder output packet + * + * This structure contains the different kinds of output data the encoder + * may produce while compressing a frame. + */ +typedef struct vpx_codec_cx_pkt { + enum vpx_codec_cx_pkt_kind kind; /**< packet variant */ + union { + struct { + void *buf; /**< compressed data buffer */ + size_t sz; /**< length of compressed data */ + /*!\brief time stamp to show frame (in timebase units) */ + vpx_codec_pts_t pts; + /*!\brief duration to show frame (in timebase units) */ + unsigned long duration; + vpx_codec_frame_flags_t flags; /**< flags for this frame */ + /*!\brief the partition id defines the decoding order of the partitions. + * Only applicable when "output partition" mode is enabled. First + * partition has id 0.*/ + int partition_id; + /*!\brief Width and height of frames in this packet. VP8 will only use the + * first one.*/ + unsigned int width[VPX_SS_MAX_LAYERS]; /**< frame width */ + unsigned int height[VPX_SS_MAX_LAYERS]; /**< frame height */ + /*!\brief Flag to indicate if spatial layer frame in this packet is + * encoded or dropped. VP8 will always be set to 1.*/ + uint8_t spatial_layer_encoded[VPX_SS_MAX_LAYERS]; + } frame; /**< data for compressed frame packet */ + vpx_fixed_buf_t twopass_stats; /**< data for two-pass packet */ + vpx_fixed_buf_t firstpass_mb_stats; /**< first pass mb packet */ + struct vpx_psnr_pkt { + unsigned int samples[4]; /**< Number of samples, total/y/u/v */ + uint64_t sse[4]; /**< sum squared error, total/y/u/v */ + double psnr[4]; /**< PSNR, total/y/u/v */ + int spatial_layer_id; /**< Spatial layer id */ + } psnr; /**< data for PSNR packet */ + vpx_fixed_buf_t raw; /**< data for arbitrary packets */ + + /* This packet size is fixed to allow codecs to extend this + * interface without having to manage storage for raw packets, + * i.e., if it's smaller than 128 bytes, you can store in the + * packet list directly. + */ + char pad[128 - sizeof(enum vpx_codec_cx_pkt_kind)]; /**< fixed sz */ + } data; /**< packet data */ +} vpx_codec_cx_pkt_t; /**< alias for struct vpx_codec_cx_pkt */ + +/*!\brief Encoder return output buffer callback + * + * This callback function, when registered, returns with packets when each + * spatial layer is encoded. + */ +typedef void (*vpx_codec_enc_output_cx_pkt_cb_fn_t)(vpx_codec_cx_pkt_t *pkt, + void *user_data); + +/*!\brief Callback function pointer / user data pair storage */ +typedef struct vpx_codec_enc_output_cx_cb_pair { + vpx_codec_enc_output_cx_pkt_cb_fn_t output_cx_pkt; /**< Callback function */ + void *user_priv; /**< Pointer to private data */ +} vpx_codec_priv_output_cx_pkt_cb_pair_t; + +/*!\brief Rational Number + * + * This structure holds a fractional value. + */ +typedef struct vpx_rational { + int num; /**< fraction numerator */ + int den; /**< fraction denominator */ +} vpx_rational_t; /**< alias for struct vpx_rational */ + +/*!\brief Multi-pass Encoding Pass */ +typedef enum vpx_enc_pass { + VPX_RC_ONE_PASS, /**< Single pass mode */ + VPX_RC_FIRST_PASS, /**< First pass of multi-pass mode */ + VPX_RC_LAST_PASS /**< Final pass of multi-pass mode */ +} vpx_enc_pass; + +/*!\brief Rate control mode */ +enum vpx_rc_mode { + VPX_VBR, /**< Variable Bit Rate (VBR) mode */ + VPX_CBR, /**< Constant Bit Rate (CBR) mode */ + VPX_CQ, /**< Constrained Quality (CQ) mode */ + VPX_Q, /**< Constant Quality (Q) mode */ +}; + +/*!\brief Keyframe placement mode. + * + * This enumeration determines whether keyframes are placed automatically by + * the encoder or whether this behavior is disabled. Older releases of this + * SDK were implemented such that VPX_KF_FIXED meant keyframes were disabled. + * This name is confusing for this behavior, so the new symbols to be used + * are VPX_KF_AUTO and VPX_KF_DISABLED. + */ +enum vpx_kf_mode { + VPX_KF_FIXED, /**< deprecated, implies VPX_KF_DISABLED */ + VPX_KF_AUTO, /**< Encoder determines optimal placement automatically */ + VPX_KF_DISABLED = 0 /**< Encoder does not place keyframes. */ +}; + +/*!\brief Encoded Frame Flags + * + * This type indicates a bitfield to be passed to vpx_codec_encode(), defining + * per-frame boolean values. By convention, bits common to all codecs will be + * named VPX_EFLAG_*, and bits specific to an algorithm will be named + * /algo/_eflag_*. The lower order 16 bits are reserved for common use. + */ +typedef long vpx_enc_frame_flags_t; +#define VPX_EFLAG_FORCE_KF (1 << 0) /**< Force this frame to be a keyframe */ +/** Calculate PSNR on this frame, requires g_lag_in_frames to be 0 */ +#define VPX_EFLAG_CALCULATE_PSNR (1 << 1) + +/*!\brief Encoder configuration structure + * + * This structure contains the encoder settings that have common representations + * across all codecs. This doesn't imply that all codecs support all features, + * however. + */ +typedef struct vpx_codec_enc_cfg { + /* + * generic settings (g) + */ + + /*!\brief Deprecated: Algorithm specific "usage" value + * + * This value must be zero. + */ + unsigned int g_usage; + + /*!\brief Maximum number of threads to use + * + * For multi-threaded implementations, use no more than this number of + * threads. The codec may use fewer threads than allowed. The value + * 0 is equivalent to the value 1. + */ + unsigned int g_threads; + + /*!\brief Bitstream profile to use + * + * Some codecs support a notion of multiple bitstream profiles. Typically + * this maps to a set of features that are turned on or off. Often the + * profile to use is determined by the features of the intended decoder. + * Consult the documentation for the codec to determine the valid values + * for this parameter, or set to zero for a sane default. + */ + unsigned int g_profile; /**< profile of bitstream to use */ + + /*!\brief Width of the frame + * + * This value identifies the presentation resolution of the frame, + * in pixels. Note that the frames passed as input to the encoder must + * have this resolution. Frames will be presented by the decoder in this + * resolution, independent of any spatial resampling the encoder may do. + */ + unsigned int g_w; + + /*!\brief Height of the frame + * + * This value identifies the presentation resolution of the frame, + * in pixels. Note that the frames passed as input to the encoder must + * have this resolution. Frames will be presented by the decoder in this + * resolution, independent of any spatial resampling the encoder may do. + */ + unsigned int g_h; + + /*!\brief Bit-depth of the codec + * + * This value identifies the bit_depth of the codec, + * Only certain bit-depths are supported as identified in the + * vpx_bit_depth_t enum. + */ + vpx_bit_depth_t g_bit_depth; + + /*!\brief Bit-depth of the input source + * + * This value identifies the actual bit-depth of the input source in bits. It + * must not exceed codec bit-depth. Note that the frames passed as input to + * the encoder must match codec bit-depth. So, if there is a mismatch between + * source bit-depth and codec bit-depth, the application is required to + * upshift the frame to the codec bit-depth before passing it for encoding. + * This is only used for computing quality metrics relative to the actual + * input source and has no effect on the encoder's output. + */ + unsigned int g_input_bit_depth; + + /*!\brief Stream timebase units + * + * Indicates the smallest interval of time, in seconds, used by the stream. + * For fixed frame rate material, or variable frame rate material where + * frames are timed at a multiple of a given clock (ex: video capture), + * the \ref RECOMMENDED method is to set the timebase to the reciprocal + * of the frame rate (ex: 1001/30000 for 29.970 Hz NTSC). This allows the + * pts to correspond to the frame number, which can be handy. For + * re-encoding video from containers with absolute time timestamps, the + * \ref RECOMMENDED method is to set the timebase to that of the parent + * container or multimedia framework (ex: 1/1000 for ms, as in FLV). + */ + struct vpx_rational g_timebase; + + /*!\brief Enable error resilient modes. + * + * The error resilient bitfield indicates to the encoder which features + * it should enable to take measures for streaming over lossy or noisy + * links. + */ + vpx_codec_er_flags_t g_error_resilient; + + /*!\brief Multi-pass Encoding Mode + * + * This value should be set to the current phase for multi-pass encoding. + * For single pass, set to #VPX_RC_ONE_PASS. + */ + enum vpx_enc_pass g_pass; + + /*!\brief Allow lagged encoding + * + * If set, this value allows the encoder to consume a number of input + * frames before producing output frames. This allows the encoder to + * base decisions for the current frame on future frames. This does + * increase the latency of the encoding pipeline, so it is not appropriate + * in all situations (ex: realtime encoding). + * + * Note that this is a maximum value -- the encoder may produce frames + * sooner than the given limit. Set this value to 0 to disable this + * feature. + */ + unsigned int g_lag_in_frames; + + /* + * rate control settings (rc) + */ + + /*!\brief Temporal resampling configuration, if supported by the codec. + * + * Temporal resampling allows the codec to "drop" frames as a strategy to + * meet its target data rate. This can cause temporal discontinuities in + * the encoded video, which may appear as stuttering during playback. This + * trade-off is often acceptable, but for many applications is not. It can + * be disabled in these cases. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer falls below this percentage of fullness, a + * dropped frame is indicated. Set the threshold to zero (0) to disable + * this feature. + */ + unsigned int rc_dropframe_thresh; + + /*!\brief Enable/disable spatial resampling, if supported by the codec. + * + * Spatial resampling allows the codec to compress a lower resolution + * version of the frame, which is then upscaled by the encoder to the + * correct presentation resolution. This increases visual quality at + * low data rates, at the expense of CPU time on the encoder/decoder. + */ + unsigned int rc_resize_allowed; + + /*!\brief Internal coded frame width. + * + * If spatial resampling is enabled this specifies the width of the + * encoded frame. + */ + unsigned int rc_scaled_width; + + /*!\brief Internal coded frame height. + * + * If spatial resampling is enabled this specifies the height of the + * encoded frame. + */ + unsigned int rc_scaled_height; + + /*!\brief Spatial resampling up watermark. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer rises above this percentage of fullness, the + * encoder will step up to a higher resolution version of the frame. + */ + unsigned int rc_resize_up_thresh; + + /*!\brief Spatial resampling down watermark. + * + * This threshold is described as a percentage of the target data buffer. + * When the data buffer falls below this percentage of fullness, the + * encoder will step down to a lower resolution version of the frame. + */ + unsigned int rc_resize_down_thresh; + + /*!\brief Rate control algorithm to use. + * + * Indicates whether the end usage of this stream is to be streamed over + * a bandwidth constrained link, indicating that Constant Bit Rate (CBR) + * mode should be used, or whether it will be played back on a high + * bandwidth link, as from a local disk, where higher variations in + * bitrate are acceptable. + */ + enum vpx_rc_mode rc_end_usage; + + /*!\brief Two-pass stats buffer. + * + * A buffer containing all of the stats packets produced in the first + * pass, concatenated. + */ + vpx_fixed_buf_t rc_twopass_stats_in; + + /*!\brief first pass mb stats buffer. + * + * A buffer containing all of the first pass mb stats packets produced + * in the first pass, concatenated. + */ + vpx_fixed_buf_t rc_firstpass_mb_stats_in; + + /*!\brief Target data rate + * + * Target bitrate to use for this stream, in kilobits per second. + * Internally capped to the smaller of the uncompressed bitrate and + * 1000000 kilobits per second. + */ + unsigned int rc_target_bitrate; + + /* + * quantizer settings + */ + + /*!\brief Minimum (Best Quality) Quantizer + * + * The quantizer is the most direct control over the quality of the + * encoded image. The range of valid values for the quantizer is codec + * specific. Consult the documentation for the codec to determine the + * values to use. + */ + unsigned int rc_min_quantizer; + + /*!\brief Maximum (Worst Quality) Quantizer + * + * The quantizer is the most direct control over the quality of the + * encoded image. The range of valid values for the quantizer is codec + * specific. Consult the documentation for the codec to determine the + * values to use. + */ + unsigned int rc_max_quantizer; + + /* + * bitrate tolerance + */ + + /*!\brief Rate control adaptation undershoot control + * + * VP8: Expressed as a percentage of the target bitrate, + * controls the maximum allowed adaptation speed of the codec. + * This factor controls the maximum amount of bits that can + * be subtracted from the target bitrate in order to compensate + * for prior overshoot. + * VP9: Expressed as a percentage of the target bitrate, a threshold + * undershoot level (current rate vs target) beyond which more aggressive + * corrective measures are taken. + * * + * Valid values in the range VP8:0-100 VP9: 0-100. + */ + unsigned int rc_undershoot_pct; + + /*!\brief Rate control adaptation overshoot control + * + * VP8: Expressed as a percentage of the target bitrate, + * controls the maximum allowed adaptation speed of the codec. + * This factor controls the maximum amount of bits that can + * be added to the target bitrate in order to compensate for + * prior undershoot. + * VP9: Expressed as a percentage of the target bitrate, a threshold + * overshoot level (current rate vs target) beyond which more aggressive + * corrective measures are taken. + * + * Valid values in the range VP8:0-100 VP9: 0-100. + */ + unsigned int rc_overshoot_pct; + + /* + * decoder buffer model parameters + */ + + /*!\brief Decoder Buffer Size + * + * This value indicates the amount of data that may be buffered by the + * decoding application. Note that this value is expressed in units of + * time (milliseconds). For example, a value of 5000 indicates that the + * client will buffer (at least) 5000ms worth of encoded data. Use the + * target bitrate (#rc_target_bitrate) to convert to bits/bytes, if + * necessary. + */ + unsigned int rc_buf_sz; + + /*!\brief Decoder Buffer Initial Size + * + * This value indicates the amount of data that will be buffered by the + * decoding application prior to beginning playback. This value is + * expressed in units of time (milliseconds). Use the target bitrate + * (#rc_target_bitrate) to convert to bits/bytes, if necessary. + */ + unsigned int rc_buf_initial_sz; + + /*!\brief Decoder Buffer Optimal Size + * + * This value indicates the amount of data that the encoder should try + * to maintain in the decoder's buffer. This value is expressed in units + * of time (milliseconds). Use the target bitrate (#rc_target_bitrate) + * to convert to bits/bytes, if necessary. + */ + unsigned int rc_buf_optimal_sz; + + /* + * 2 pass rate control parameters + */ + + /*!\brief Two-pass mode CBR/VBR bias + * + * Bias, expressed on a scale of 0 to 100, for determining target size + * for the current frame. The value 0 indicates the optimal CBR mode + * value should be used. The value 100 indicates the optimal VBR mode + * value should be used. Values in between indicate which way the + * encoder should "lean." + */ + unsigned int rc_2pass_vbr_bias_pct; + + /*!\brief Two-pass mode per-GOP minimum bitrate + * + * This value, expressed as a percentage of the target bitrate, indicates + * the minimum bitrate to be used for a single GOP (aka "section") + */ + unsigned int rc_2pass_vbr_minsection_pct; + + /*!\brief Two-pass mode per-GOP maximum bitrate + * + * This value, expressed as a percentage of the target bitrate, indicates + * the maximum bitrate to be used for a single GOP (aka "section") + */ + unsigned int rc_2pass_vbr_maxsection_pct; + + /*!\brief Two-pass corpus vbr mode complexity control + * Used only in VP9: A value representing the corpus midpoint complexity + * for corpus vbr mode. This value defaults to 0 which disables corpus vbr + * mode in favour of normal vbr mode. + */ + unsigned int rc_2pass_vbr_corpus_complexity; + + /* + * keyframing settings (kf) + */ + + /*!\brief Keyframe placement mode + * + * This value indicates whether the encoder should place keyframes at a + * fixed interval, or determine the optimal placement automatically + * (as governed by the #kf_min_dist and #kf_max_dist parameters) + */ + enum vpx_kf_mode kf_mode; + + /*!\brief Keyframe minimum interval + * + * This value, expressed as a number of frames, prevents the encoder from + * placing a keyframe nearer than kf_min_dist to the previous keyframe. At + * least kf_min_dist frames non-keyframes will be coded before the next + * keyframe. Set kf_min_dist equal to kf_max_dist for a fixed interval. + */ + unsigned int kf_min_dist; + + /*!\brief Keyframe maximum interval + * + * This value, expressed as a number of frames, forces the encoder to code + * a keyframe if one has not been coded in the last kf_max_dist frames. + * A value of 0 implies all frames will be keyframes. Set kf_min_dist + * equal to kf_max_dist for a fixed interval. + */ + unsigned int kf_max_dist; + + /* + * Spatial scalability settings (ss) + */ + + /*!\brief Number of spatial coding layers. + * + * This value specifies the number of spatial coding layers to be used. + */ + unsigned int ss_number_layers; + + /*!\brief Enable auto alt reference flags for each spatial layer. + * + * These values specify if auto alt reference frame is enabled for each + * spatial layer. + */ + int ss_enable_auto_alt_ref[VPX_SS_MAX_LAYERS]; + + /*!\brief Target bitrate for each spatial layer. + * + * These values specify the target coding bitrate to be used for each + * spatial layer. (in kbps) + */ + unsigned int ss_target_bitrate[VPX_SS_MAX_LAYERS]; + + /*!\brief Number of temporal coding layers. + * + * This value specifies the number of temporal layers to be used. + */ + unsigned int ts_number_layers; + + /*!\brief Target bitrate for each temporal layer. + * + * These values specify the target coding bitrate to be used for each + * temporal layer. (in kbps) + */ + unsigned int ts_target_bitrate[VPX_TS_MAX_LAYERS]; + + /*!\brief Frame rate decimation factor for each temporal layer. + * + * These values specify the frame rate decimation factors to apply + * to each temporal layer. + */ + unsigned int ts_rate_decimator[VPX_TS_MAX_LAYERS]; + + /*!\brief Length of the sequence defining frame temporal layer membership. + * + * This value specifies the length of the sequence that defines the + * membership of frames to temporal layers. For example, if the + * ts_periodicity = 8, then the frames are assigned to coding layers with a + * repeated sequence of length 8. + */ + unsigned int ts_periodicity; + + /*!\brief Template defining the membership of frames to temporal layers. + * + * This array defines the membership of frames to temporal coding layers. + * For a 2-layer encoding that assigns even numbered frames to one temporal + * layer (0) and odd numbered frames to a second temporal layer (1) with + * ts_periodicity=8, then ts_layer_id = (0,1,0,1,0,1,0,1). + */ + unsigned int ts_layer_id[VPX_TS_MAX_PERIODICITY]; + + /*!\brief Target bitrate for each spatial/temporal layer. + * + * These values specify the target coding bitrate to be used for each + * spatial/temporal layer. (in kbps) + * + */ + unsigned int layer_target_bitrate[VPX_MAX_LAYERS]; + + /*!\brief Temporal layering mode indicating which temporal layering scheme to + * use. + * + * The value (refer to VP9E_TEMPORAL_LAYERING_MODE) specifies the + * temporal layering mode to use. + * + */ + int temporal_layering_mode; + + /*!\brief A flag indicating whether to use external rate control parameters. + * By default is 0. If set to 1, the following parameters will be used in the + * rate control system. + */ + int use_vizier_rc_params; + + /*!\brief Active worst quality factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t active_wq_factor; + + /*!\brief Error per macroblock adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t err_per_mb_factor; + + /*!\brief Second reference default decay limit. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t sr_default_decay_limit; + + /*!\brief Second reference difference factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t sr_diff_factor; + + /*!\brief Keyframe error per macroblock adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_err_per_mb_factor; + + /*!\brief Keyframe minimum boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_min_boost_factor; + + /*!\brief Keyframe maximum boost adjustment factor, for the first keyframe + * in a chunk. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_max_boost_first_factor; + + /*!\brief Keyframe maximum boost adjustment factor, for subsequent keyframes. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_frame_max_boost_subs_factor; + + /*!\brief Keyframe maximum total boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t kf_max_total_boost_factor; + + /*!\brief Golden frame maximum total boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t gf_max_total_boost_factor; + + /*!\brief Golden frame maximum boost adjustment factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t gf_frame_max_boost_factor; + + /*!\brief Zero motion power factor. + * + * Rate control parameters, set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t zm_factor; + + /*!\brief Rate-distortion multiplier for inter frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_inter_qp_fac; + + /*!\brief Rate-distortion multiplier for alt-ref frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_arf_qp_fac; + + /*!\brief Rate-distortion multiplier for key frames. + * The multiplier is a crucial parameter in the calculation of rate distortion + * cost. It is often related to the qp (qindex) value. + * Rate control parameters, could be set from external experiment results. + * Only when |use_vizier_rc_params| is set to 1, the pass in value will be + * used. Otherwise, the default value is used. + * + */ + vpx_rational_t rd_mult_key_qp_fac; +} vpx_codec_enc_cfg_t; /**< alias for struct vpx_codec_enc_cfg */ + +/*!\brief vp9 svc extra configure parameters + * + * This defines max/min quantizers and scale factors for each layer + * + */ +typedef struct vpx_svc_parameters { + int max_quantizers[VPX_MAX_LAYERS]; /**< Max Q for each layer */ + int min_quantizers[VPX_MAX_LAYERS]; /**< Min Q for each layer */ + int scaling_factor_num[VPX_MAX_LAYERS]; /**< Scaling factor-numerator */ + int scaling_factor_den[VPX_MAX_LAYERS]; /**< Scaling factor-denominator */ + int speed_per_layer[VPX_MAX_LAYERS]; /**< Speed setting for each sl */ + int temporal_layering_mode; /**< Temporal layering mode */ + int loopfilter_ctrl[VPX_MAX_LAYERS]; /**< Loopfilter ctrl for each sl */ +} vpx_svc_extra_cfg_t; + +/*!\brief Initialize an encoder instance + * + * Initializes an encoder context using the given interface. Applications + * should call the vpx_codec_enc_init convenience macro instead of this + * function directly, to ensure that the ABI version number parameter + * is properly initialized. + * + * If the library was configured with --disable-multithread, this call + * is not thread safe and should be guarded with a lock if being used + * in a multithreaded context. + * + * On success, vpx_codec_destroy() must be used to free resources allocated for + * the encoder context. If vpx_codec_enc_init_ver() fails, it is not necessary + * to call vpx_codec_destroy() on the encoder context. + * + * \param[in] ctx Pointer to this instance's context. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg Configuration to use. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] ver ABI version number. Must be set to + * VPX_ENCODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The decoder algorithm initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + */ +vpx_codec_err_t vpx_codec_enc_init_ver(vpx_codec_ctx_t *ctx, + vpx_codec_iface_t *iface, + const vpx_codec_enc_cfg_t *cfg, + vpx_codec_flags_t flags, int ver); + +/*!\brief Convenience macro for vpx_codec_enc_init_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_enc_init(ctx, iface, cfg, flags) \ + vpx_codec_enc_init_ver(ctx, iface, cfg, flags, VPX_ENCODER_ABI_VERSION) + +/*!\brief Initialize multi-encoder instance + * + * Initializes multiple encoder contexts using the given interface. + * Applications should call the vpx_codec_enc_init_multi convenience macro + * instead of this function directly, to ensure that the ABI version number + * parameter is properly initialized. + * + * \param[in] ctx Pointer to an array of num_enc instances' contexts. + * \param[in] iface Pointer to the algorithm interface to use. + * \param[in] cfg An array of num_enc configurations to use. + * \param[in] num_enc Total number of encoders. + * \param[in] flags Bitfield of VPX_CODEC_USE_* flags + * \param[in] dsf Pointer to an array of num_enc down-sampling factors. + * \param[in] ver ABI version number. Must be set to + * VPX_ENCODER_ABI_VERSION + * \retval #VPX_CODEC_OK + * The encoder algorithm has been initialized. + * \retval #VPX_CODEC_MEM_ERROR + * Memory allocation failed. + * + * \note + * This is only supported by VP8. iface must point to the interface to the VP8 + * encoder. + */ +vpx_codec_err_t vpx_codec_enc_init_multi_ver( + vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, + const vpx_codec_enc_cfg_t *cfg, int num_enc, vpx_codec_flags_t flags, + const vpx_rational_t *dsf, int ver); + +/*!\brief Convenience macro for vpx_codec_enc_init_multi_ver() + * + * Ensures the ABI version parameter is properly set. + */ +#define vpx_codec_enc_init_multi(ctx, iface, cfg, num_enc, flags, dsf) \ + vpx_codec_enc_init_multi_ver(ctx, iface, cfg, num_enc, flags, dsf, \ + VPX_ENCODER_ABI_VERSION) + +/*!\brief Get a default configuration + * + * Initializes a encoder configuration structure with default values. Supports + * the notion of "usages" so that an algorithm may offer different default + * settings depending on the user's intended goal. This function \ref SHOULD + * be called by all applications to initialize the configuration structure + * before specializing the configuration with application specific values. + * + * \param[in] iface Pointer to the algorithm interface to use. + * \param[out] cfg Configuration buffer to populate. + * \param[in] usage Must be set to 0. + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, or the usage value was not recognized. + */ +vpx_codec_err_t vpx_codec_enc_config_default(vpx_codec_iface_t *iface, + vpx_codec_enc_cfg_t *cfg, + unsigned int usage); + +/*!\brief Set or change configuration + * + * Reconfigures an encoder instance according to the given configuration. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] cfg Configuration buffer to use + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, or the usage value was not recognized. + */ +vpx_codec_err_t vpx_codec_enc_config_set(vpx_codec_ctx_t *ctx, + const vpx_codec_enc_cfg_t *cfg); + +/*!\brief Get global stream headers + * + * Retrieves a stream level global header packet, if supported by the codec. + * + * \li VP8: Unsupported + * \li VP9: Returns a buffer of ID (1 byte)|Length (1 byte)|Length + * bytes values. The function should be called after encoding to retrieve + * the most accurate information. + * + * \param[in] ctx Pointer to this instance's context + * + * \retval NULL + * Encoder does not support global header + * \retval Non-NULL + * Pointer to buffer containing global header packet. The buffer pointer + * and its contents are only valid for the lifetime of \a ctx. The contents + * may change in subsequent calls to the function. + * \sa + * https://www.webmproject.org/docs/container/#vp9-codec-feature-metadata-codecprivate + */ +vpx_fixed_buf_t *vpx_codec_get_global_headers(vpx_codec_ctx_t *ctx); + +/*!\brief Encode Deadline + * + * This type indicates a deadline, in microseconds, to be passed to + * vpx_codec_encode(). + */ +typedef unsigned long vpx_enc_deadline_t; +/*!\brief deadline parameter analogous to VPx REALTIME mode. */ +#define VPX_DL_REALTIME 1ul +/*!\brief deadline parameter analogous to VPx GOOD QUALITY mode. */ +#define VPX_DL_GOOD_QUALITY 1000000ul +/*!\brief deadline parameter analogous to VPx BEST QUALITY mode. */ +#define VPX_DL_BEST_QUALITY 0ul +/*!\brief Encode a frame + * + * Encodes a video frame at the given "presentation time." The presentation + * time stamp (PTS) \ref MUST be strictly increasing. + * + * The encoder supports the notion of a soft real-time deadline. Given a + * non-zero value to the deadline parameter, the encoder will make a "best + * effort" guarantee to return before the given time slice expires. It is + * implicit that limiting the available time to encode will degrade the + * output quality. The encoder can be given an unlimited time to produce the + * best possible frame by specifying a deadline of '0'. This deadline + * supersedes the VPx notion of "best quality, good quality, realtime". + * Applications that wish to map these former settings to the new deadline + * based system can use the symbols #VPX_DL_REALTIME, #VPX_DL_GOOD_QUALITY, + * and #VPX_DL_BEST_QUALITY. + * + * When the last frame has been passed to the encoder, this function should + * continue to be called, with the img parameter set to NULL. This will + * signal the end-of-stream condition to the encoder and allow it to encode + * any held buffers. Encoding is complete when vpx_codec_encode() is called + * and vpx_codec_get_cx_data() returns no data. + * + * \param[in] ctx Pointer to this instance's context + * \param[in] img Image data to encode, NULL to flush. + * Encoding sample values outside the range + * [0..(1<bit_depth)-1] is undefined behavior. + * \param[in] pts Presentation time stamp, in timebase units. + * \param[in] duration Duration to show frame, in timebase units. + * \param[in] flags Flags to use for encoding this frame. + * \param[in] deadline Time to spend encoding, in microseconds. (0=infinite) + * + * \retval #VPX_CODEC_OK + * The configuration was populated. + * \retval #VPX_CODEC_INCAPABLE + * Interface is not an encoder interface. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, the image format is unsupported, etc. + */ +vpx_codec_err_t vpx_codec_encode(vpx_codec_ctx_t *ctx, const vpx_image_t *img, + vpx_codec_pts_t pts, unsigned long duration, + vpx_enc_frame_flags_t flags, + vpx_enc_deadline_t deadline); + +/*!\brief Set compressed data output buffer + * + * Sets the buffer that the codec should output the compressed data + * into. This call effectively sets the buffer pointer returned in the + * next VPX_CODEC_CX_FRAME_PKT packet. Subsequent packets will be + * appended into this buffer. The buffer is preserved across frames, + * so applications must periodically call this function after flushing + * the accumulated compressed data to disk or to the network to reset + * the pointer to the buffer's head. + * + * `pad_before` bytes will be skipped before writing the compressed + * data, and `pad_after` bytes will be appended to the packet. The size + * of the packet will be the sum of the size of the actual compressed + * data, pad_before, and pad_after. The padding bytes will be preserved + * (not overwritten). + * + * Note that calling this function does not guarantee that the returned + * compressed data will be placed into the specified buffer. In the + * event that the encoded data will not fit into the buffer provided, + * the returned packet \ref MAY point to an internal buffer, as it would + * if this call were never used. In this event, the output packet will + * NOT have any padding, and the application must free space and copy it + * to the proper place. This is of particular note in configurations + * that may output multiple packets for a single encoded frame (e.g., lagged + * encoding) or if the application does not reset the buffer periodically. + * + * Applications may restore the default behavior of the codec providing + * the compressed data buffer by calling this function with a NULL + * buffer. + * + * Applications \ref MUSTNOT call this function during iteration of + * vpx_codec_get_cx_data(). + * + * \param[in] ctx Pointer to this instance's context + * \param[in] buf Buffer to store compressed data into + * \param[in] pad_before Bytes to skip before writing compressed data + * \param[in] pad_after Bytes to skip after writing compressed data + * + * \retval #VPX_CODEC_OK + * The buffer was set successfully. + * \retval #VPX_CODEC_INVALID_PARAM + * A parameter was NULL, the image format is unsupported, etc. + * + * \note + * `duration` and `deadline` are of the unsigned long type, which can be 32 + * or 64 bits. `duration` and `deadline` must be less than or equal to + * UINT32_MAX so that their ranges are independent of the size of unsigned + * long. + */ +vpx_codec_err_t vpx_codec_set_cx_data_buf(vpx_codec_ctx_t *ctx, + const vpx_fixed_buf_t *buf, + unsigned int pad_before, + unsigned int pad_after); + +/*!\brief Encoded data iterator + * + * Iterates over a list of data packets to be passed from the encoder to the + * application. The different kinds of packets available are enumerated in + * #vpx_codec_cx_pkt_kind. + * + * #VPX_CODEC_CX_FRAME_PKT packets should be passed to the application's + * muxer. Multiple compressed frames may be in the list. + * #VPX_CODEC_STATS_PKT packets should be appended to a global buffer. + * + * The application \ref MUST silently ignore any packet kinds that it does + * not recognize or support. + * + * The data buffers returned from this function are only guaranteed to be + * valid until the application makes another call to any vpx_codec_* function. + * + * \param[in] ctx Pointer to this instance's context + * \param[in,out] iter Iterator storage, initialized to NULL + * + * \return Returns a pointer to an output data packet (compressed frame data, + * two-pass statistics, etc.) or NULL to signal end-of-list. + * + */ +const vpx_codec_cx_pkt_t *vpx_codec_get_cx_data(vpx_codec_ctx_t *ctx, + vpx_codec_iter_t *iter); + +/*!\brief Get Preview Frame + * + * Returns an image that can be used as a preview. Shows the image as it would + * exist at the decompressor. The application \ref MUST NOT write into this + * image buffer. + * + * \param[in] ctx Pointer to this instance's context + * + * \return Returns a pointer to a preview image, or NULL if no image is + * available. + * + */ +const vpx_image_t *vpx_codec_get_preview_frame(vpx_codec_ctx_t *ctx); + +/*!@} - end defgroup encoder*/ +#ifdef __cplusplus +} +#endif +#endif // VPX_VPX_VPX_ENCODER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h b/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h new file mode 100644 index 0000000..9631d40 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_ext_ratectrl.h @@ -0,0 +1,605 @@ +/* + * Copyright (c) 2020 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Defines structs and callbacks needed for external rate control. + * + */ +#ifndef VPX_VPX_VPX_EXT_RATECTRL_H_ +#define VPX_VPX_VPX_EXT_RATECTRL_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_integer.h" +#include "./vpx_tpl.h" + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures. + */ +#define VPX_EXT_RATECTRL_ABI_VERSION (7 + VPX_TPL_ABI_VERSION) + +/*!\brief Corresponds to MAX_STATIC_GF_GROUP_LENGTH defined in vp9_ratectrl.h + */ +#define VPX_RC_MAX_STATIC_GF_GROUP_LENGTH 250 + +/*!\brief Max number of ref frames returned by the external RC. + * + * Corresponds to MAX_REF_FRAMES defined in vp9_blockd.h. + */ +#define VPX_RC_MAX_REF_FRAMES 4 + +/*!\brief The type of the external rate control. + * + * This controls what encoder parameters are determined by the external rate + * control. + */ +typedef enum vpx_rc_type { + /*! + * The external rate control doesn't determine anything. + * This mode is used as baseline. + */ + VPX_RC_NONE = 0, + /*! + * The external rate control model determines the quantization parameter (QP) + * for each frame. + */ + VPX_RC_QP = 1 << 0, + /*! + * The external rate control model determines the group of picture (GOP) of + * the video sequence. + */ + VPX_RC_GOP = 1 << 1, + /*! + * The external rate control model determines the rate-distortion multiplier + * (rdmult) for the current frame. + */ + VPX_RC_RDMULT = 1 << 2, + /*! + * The external rate control model determines both QP and GOP. + */ + VPX_RC_GOP_QP = VPX_RC_QP | VPX_RC_GOP, + /*! + * The external rate control model determines the QP, GOP and the rdmult. + */ + VPX_RC_GOP_QP_RDMULT = VPX_RC_QP | VPX_RC_GOP | VPX_RC_RDMULT +} vpx_rc_type_t; + +/*!\brief The rate control mode for the external rate control model. + */ +typedef enum vpx_ext_rc_mode { + VPX_RC_QMODE = 0, + VPX_RC_VBR = 1, + VPX_RC_CQ = 2, +} vpx_ext_rc_mode_t; + +/*!\brief Corresponds to FRAME_UPDATE_TYPE defined in vp9_firstpass.h. + */ +typedef enum vpx_rc_frame_update_type { + VPX_RC_INVALID_UPDATE_TYPE = -1, + VPX_RC_KF_UPDATE = 0, + VPX_RC_LF_UPDATE = 1, + VPX_RC_GF_UPDATE = 2, + VPX_RC_ARF_UPDATE = 3, + VPX_RC_OVERLAY_UPDATE = 4, + VPX_RC_MID_OVERLAY_UPDATE = 5, + VPX_RC_USE_BUF_FRAME = 6, +} vpx_rc_frame_update_type_t; + +/*!\brief Name for the ref frames returned by the external RC. + * + * Corresponds to the ref frames defined in vp9_blockd.h. + */ +typedef enum vpx_rc_ref_name { + VPX_RC_INVALID_REF_FRAME = -1, + VPX_RC_INTRA_FRAME = 0, + VPX_RC_LAST_FRAME = 1, + VPX_RC_GOLDEN_FRAME = 2, + VPX_RC_ALTREF_FRAME = 3, +} vpx_rc_ref_name_t; + +/*!\brief Abstract rate control model handler + * + * The encoder will receive the model handler from + * vpx_rc_funcs_t::create_model(). + */ +typedef void *vpx_rc_model_t; + +/*!\brief A reserved value for the q index. + * If the external rate control model returns this value, + * the encoder will use the default q selected by libvpx's rate control + * system. + */ +#define VPX_DEFAULT_Q -1 + +/*!\brief A reserved value for the rdmult. + * If the external rate control model returns this value, + * the encoder will use the default rdmult selected by libvpx's rate control + * system. + */ +#define VPX_DEFAULT_RDMULT -1 + +/*!\brief Superblock quantization parameters + * Store the superblock quantization parameters + */ +typedef struct sb_parameters { + int q_index; /**< Quantizer step index [0..255]*/ + int rdmult; /**< Superblock level Lagrangian multiplier*/ +} sb_params; + +/*!\brief Encode frame decision made by the external rate control model + * + * The encoder will receive the decision from the external rate control model + * through vpx_rc_funcs_t::get_encodeframe_decision(). + */ +typedef struct vpx_rc_encodeframe_decision { + int q_index; /**< Required: Quantizer step index [0..255]*/ + int rdmult; /**< Required: Frame level Lagrangian multiplier*/ + int delta_q_uv; /**< Required: Delta QP for UV */ + /*! + * Optional: Superblock quantization parameters + * It is zero initialized by default. It will be set for key and ARF frames + * but not leaf frames. + */ + sb_params *sb_params_list; +} vpx_rc_encodeframe_decision_t; + +/*!\brief Information for the frame to be encoded. + * + * The encoder will send the information to external rate control model through + * vpx_rc_funcs_t::get_encodeframe_decision(). + * + */ +typedef struct vpx_rc_encodeframe_info { + /*! + * 0: Key frame + * 1: Inter frame + * 2: Alternate reference frame + * 3: Overlay frame + * 4: Golden frame + */ + int frame_type; + int show_index; /**< display index, starts from zero*/ + int coding_index; /**< coding index, starts from zero*/ + /*! + * index of the current frame in this group of picture, starts from zero. + */ + int gop_index; + int ref_frame_coding_indexes[3]; /**< three reference frames' coding indices*/ + /*! + * The validity of the three reference frames. + * 0: Invalid + * 1: Valid + */ + int ref_frame_valid_list[3]; + /*! + * The length of the current GOP. + */ + int gop_size; + /*! + * Whether the current GOP uses an alt ref. + */ + int use_alt_ref; +} vpx_rc_encodeframe_info_t; + +/*!\brief Frame coding result + * + * The encoder will send the result to the external rate control model through + * vpx_rc_funcs_t::update_encodeframe_result(). + */ +typedef struct vpx_rc_encodeframe_result { + int64_t bit_count; /**< number of bits spent on coding the frame*/ + int actual_encoding_qindex; /**< the actual qindex used to encode the frame*/ +} vpx_rc_encodeframe_result_t; + +/*!\brief Status returned by rate control callback functions. + */ +typedef enum vpx_rc_status { + VPX_RC_OK = 0, + VPX_RC_ERROR = 1, +} vpx_rc_status_t; + +/*!\brief First pass frame stats + * This is a mirror of vp9's FIRSTPASS_STATS except that spatial_layer_id is + * omitted + */ +typedef struct vpx_rc_frame_stats { + /*! + * Frame number in display order, if stats are for a single frame. + * No real meaning for a collection of frames. + */ + double frame; + /*! + * Weight assigned to this frame (or total weight for the collection of + * frames) currently based on intra factor and brightness factor. This is used + * to distribute bits between easier and harder frames. + */ + double weight; + /*! + * Intra prediction error. + */ + double intra_error; + /*! + * Best of intra pred error and inter pred error using last frame as ref. + */ + double coded_error; + /*! + * Best of intra pred error and inter pred error using golden frame as ref. + */ + double sr_coded_error; + /*! + * Estimate the noise energy of the current frame. + */ + double frame_noise_energy; + /*! + * Percentage of blocks with inter pred error < intra pred error. + */ + double pcnt_inter; + /*! + * Percentage of blocks using (inter prediction and) non-zero motion vectors. + */ + double pcnt_motion; + /*! + * Percentage of blocks where golden frame was better than last or intra: + * inter pred error using golden frame < inter pred error using last frame and + * inter pred error using golden frame < intra pred error + */ + double pcnt_second_ref; + /*! + * Percentage of blocks where intra and inter prediction errors were very + * close. + */ + double pcnt_neutral; + /*! + * Percentage of blocks that have intra error < inter error and inter error < + * LOW_I_THRESH + * - bit_depth 8: LOW_I_THRESH = 24000 + * - bit_depth 10: LOW_I_THRESH = 24000 << 4 + * - bit_depth 12: LOW_I_THRESH = 24000 << 8 + */ + double pcnt_intra_low; + /*! + * Percentage of blocks that have intra error < inter error and intra error < + * LOW_I_THRESH but inter error >= LOW_I_THRESH LOW_I_THRESH + * - bit_depth 8: LOW_I_THRESH = 24000 + * - bit_depth 10: LOW_I_THRESH = 24000 << 4 + * - bit_depth 12: LOW_I_THRESH = 24000 << 8 + */ + double pcnt_intra_high; + /*! + * Percentage of blocks that have almost no intra error residual + * (i.e. are in effect completely flat and untextured in the intra + * domain). In natural videos this is uncommon, but it is much more + * common in animations, graphics and screen content, so may be used + * as a signal to detect these types of content. + */ + double intra_skip_pct; + /*! + * Percentage of blocks that have intra error < SMOOTH_INTRA_THRESH + * - bit_depth 8: SMOOTH_INTRA_THRESH = 4000 + * - bit_depth 10: SMOOTH_INTRA_THRESH = 4000 << 4 + * - bit_depth 12: SMOOTH_INTRA_THRESH = 4000 << 8 + */ + double intra_smooth_pct; + /*! + * Image mask rows top and bottom. + */ + double inactive_zone_rows; + /*! + * Image mask columns at left and right edges. + */ + double inactive_zone_cols; + /*! + * Mean of row motion vectors. + */ + double MVr; + /*! + * Mean of absolute value of row motion vectors. + */ + double mvr_abs; + /*! + * Mean of column motion vectors. + */ + double MVc; + /*! + * Mean of absolute value of column motion vectors. + */ + double mvc_abs; + /*! + * Variance of row motion vectors. + */ + double MVrv; + /*! + * Variance of column motion vectors. + */ + double MVcv; + /*! + * Value in range [-1,1] indicating fraction of row and column motion vectors + * that point inwards (negative MV value) or outwards (positive MV value). + * For example, value of 1 indicates, all row/column MVs are inwards. + */ + double mv_in_out_count; + /*! + * Duration of the frame / collection of frames. + */ + double duration; + /*! + * 1.0 if stats are for a single frame, or + * number of frames whose stats are accumulated. + */ + double count; + /*! + * Number of new mv in a frame. + */ + double new_mv_count; +} vpx_rc_frame_stats_t; + +/*!\brief Collection of first pass frame stats + */ +typedef struct vpx_rc_firstpass_stats { + /*! + * Pointer to first pass frame stats. + * The pointed array of vpx_rc_frame_stats_t should have length equal to + * number of show frames in the video. + */ + vpx_rc_frame_stats_t *frame_stats; + /*! + * Number of show frames in the video. + */ + int num_frames; +} vpx_rc_firstpass_stats_t; + +/*!\brief Encode config sent to external rate control model + */ +typedef struct vpx_rc_config { + int frame_width; /**< frame width */ + int frame_height; /**< frame height */ + int show_frame_count; /**< number of visible frames in the video */ + int max_gf_interval; /**< max GOP size in number of show frames */ + int min_gf_interval; /**< min GOP size in number of show frames */ + /*! + * Target bitrate in kilobytes per second + */ + int target_bitrate_kbps; + int frame_rate_num; /**< numerator of frame rate */ + int frame_rate_den; /**< denominator of frame rate */ + /*! + * The following fields are only for external rate control models that support + * different rate control modes. + */ + vpx_ext_rc_mode_t rc_mode; /**< Q mode or VBR mode */ + int overshoot_percent; /**< for VBR mode only */ + int undershoot_percent; /**< for VBR mode only */ + int min_base_q_index; /**< for VBR mode only */ + int max_base_q_index; /**< for VBR mode only */ + int base_qp; /**< base QP for leaf frames, 0-255 */ +} vpx_rc_config_t; + +/*!\brief Control what ref frame to use and its index. + */ +typedef struct vpx_rc_ref_frame { + /*! + * Ref frame index. Corresponding to |lst_fb_idx|, |gld_fb_idx| or + * |alt_fb_idx| in VP9_COMP depending on the ref frame #name. + */ + int index[VPX_RC_MAX_REF_FRAMES]; + /*! + * Ref frame name. This decides whether the #index is used as + * |lst_fb_idx|, |gld_fb_idx| or |alt_fb_idx| in VP9_COMP. + * + */ + vpx_rc_ref_name_t name[VPX_RC_MAX_REF_FRAMES]; +} vpx_rc_ref_frame_t; + +/*!\brief The decision made by the external rate control model to set the + * group of picture. + */ +typedef struct vpx_rc_gop_decision { + int gop_coding_frames; /**< The number of frames of this GOP */ + int use_alt_ref; /**< Whether to use alt ref for this GOP */ + int use_key_frame; /**< Whether to set key frame for this GOP */ + /*! + * Frame type for each frame in this GOP. + * This will be populated to |update_type| in GF_GROUP defined in + * vp9_firstpass.h + */ + vpx_rc_frame_update_type_t update_type[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; + /*! Ref frame buffer index to be updated for each frame in this GOP. */ + int update_ref_index[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; + /*! Ref frame list to be used for each frame in this GOP. */ + vpx_rc_ref_frame_t ref_frame_list[VPX_RC_MAX_STATIC_GF_GROUP_LENGTH + 2]; +} vpx_rc_gop_decision_t; + +/*!\brief The decision made by the external rate control model to set the + * key frame location and the show frame count in the key frame group + */ +typedef struct vpx_rc_key_frame_decision { + int key_frame_show_index; /**< This key frame's show index in the video */ + int key_frame_group_size; /**< Show frame count of this key frame group */ +} vpx_rc_key_frame_decision_t; + +/*!\brief Create an external rate control model callback prototype + * + * This callback is invoked by the encoder to create an external rate control + * model. + * + * \param[in] priv Callback's private data + * \param[in] ratectrl_config Pointer to vpx_rc_config_t + * \param[out] rate_ctrl_model_ptr Pointer to vpx_rc_model_t + */ +typedef vpx_rc_status_t (*vpx_rc_create_model_cb_fn_t)( + void *priv, const vpx_rc_config_t *ratectrl_config, + vpx_rc_model_t *rate_ctrl_model_ptr); + +/*!\brief Send first pass stats to the external rate control model callback + * prototype + * + * This callback is invoked by the encoder to send first pass stats to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] first_pass_stats first pass stats + */ +typedef vpx_rc_status_t (*vpx_rc_send_firstpass_stats_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + const vpx_rc_firstpass_stats_t *first_pass_stats); + +/*!\brief Send TPL stats for the current GOP to the external rate control model + * callback prototype + * + * This callback is invoked by the encoder to send TPL stats for the GOP to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] tpl_gop_stats TPL stats for current GOP + */ +typedef vpx_rc_status_t (*vpx_rc_send_tpl_gop_stats_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const VpxTplGopStats *tpl_gop_stats); + +/*!\brief Receive encode frame decision callback prototype + * + * This callback is invoked by the encoder to receive encode frame decision from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] frame_gop_index index of the frame in current gop + * \param[out] frame_decision encode decision of the coding frame + */ +typedef vpx_rc_status_t (*vpx_rc_get_encodeframe_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const int frame_gop_index, + vpx_rc_encodeframe_decision_t *frame_decision); + +/*!\brief Update encode frame result callback prototype + * + * This callback is invoked by the encoder to update encode frame result to the + * external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] encode_frame_result encode result of the coding frame + */ +typedef vpx_rc_status_t (*vpx_rc_update_encodeframe_result_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + const vpx_rc_encodeframe_result_t *encode_frame_result); + +/*!\brief Get the key frame decision from the external rate control model. + * + * This callback is invoked by the encoder to get key frame decision from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] key_frame_decision key frame decision from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_key_frame_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, + vpx_rc_key_frame_decision_t *key_frame_decision); + +/*!\brief Get the GOP structure from the external rate control model. + * + * This callback is invoked by the encoder to get GOP decisions from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[out] gop_decision GOP decision from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_gop_decision_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, vpx_rc_gop_decision_t *gop_decision); + +/*!\brief Get the frame rdmult from the external rate control model. + * + * This callback is invoked by the encoder to get rdmult from + * the external rate control model. + * + * \param[in] rate_ctrl_model rate control model + * \param[in] frame_info information collected from the encoder + * \param[out] rdmult frame rate-distortion multiplier from the model + */ +typedef vpx_rc_status_t (*vpx_rc_get_frame_rdmult_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model, const vpx_rc_encodeframe_info_t *frame_info, + int *rdmult); + +/*!\brief Delete the external rate control model callback prototype + * + * This callback is invoked by the encoder to delete the external rate control + * model. + * + * \param[in] rate_ctrl_model rate control model + */ +typedef vpx_rc_status_t (*vpx_rc_delete_model_cb_fn_t)( + vpx_rc_model_t rate_ctrl_model); + +/*!\brief Callback function set for external rate control. + * + * The user can enable external rate control by registering + * a set of callback functions with the codec control flag + * #VP9E_SET_EXTERNAL_RATE_CONTROL. + */ +typedef struct vpx_rc_funcs { + /*! + * The rate control type of this API. + */ + vpx_rc_type_t rc_type; + /*! + * Create an external rate control model. + */ + vpx_rc_create_model_cb_fn_t create_model; + /*! + * Send first pass stats to the external rate control model. + */ + vpx_rc_send_firstpass_stats_cb_fn_t send_firstpass_stats; + /*! + * Send TPL stats for current GOP to the external rate control model. + */ + vpx_rc_send_tpl_gop_stats_cb_fn_t send_tpl_gop_stats; + /*! + * Get encodeframe decision from the external rate control model. + */ + vpx_rc_get_encodeframe_decision_cb_fn_t get_encodeframe_decision; + /*! + * Update encodeframe result to the external rate control model. + */ + vpx_rc_update_encodeframe_result_cb_fn_t update_encodeframe_result; + /*! + * Get key frame decision from the external rate control model. + */ + vpx_rc_get_key_frame_decision_cb_fn_t get_key_frame_decision; + /*! + * Get GOP decisions from the external rate control model. + */ + vpx_rc_get_gop_decision_cb_fn_t get_gop_decision; + /*! + * Get rdmult for the frame from the external rate control model. + */ + vpx_rc_get_frame_rdmult_cb_fn_t get_frame_rdmult; + /*! + * Delete the external rate control model. + */ + vpx_rc_delete_model_cb_fn_t delete_model; + + /*! + * Rate control log path. + */ + const char *rate_ctrl_log_path; + /*! + * Private data for the external rate control model. + */ + void *priv; +} vpx_rc_funcs_t; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_EXT_RATECTRL_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h b/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h new file mode 100644 index 0000000..b8a0ec2 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_frame_buffer.h @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2014 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#ifndef VPX_VPX_VPX_FRAME_BUFFER_H_ +#define VPX_VPX_VPX_FRAME_BUFFER_H_ + +/*!\file + * \brief Describes the decoder external frame buffer interface. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./vpx_integer.h" + +/*!\brief The maximum number of work buffers used by libvpx. + * Support maximum 4 threads to decode video in parallel. + * Each thread will use one work buffer. + * TODO(hkuang): Add support to set number of worker threads dynamically. + */ +#define VPX_MAXIMUM_WORK_BUFFERS 8 + +/*!\brief The maximum number of reference buffers that a VP9 encoder may use. + */ +#define VP9_MAXIMUM_REF_BUFFERS 8 + +/*!\brief External frame buffer + * + * This structure holds allocated frame buffers used by the decoder. + */ +typedef struct vpx_codec_frame_buffer { + uint8_t *data; /**< Pointer to the data buffer */ + size_t size; /**< Size of data in bytes */ + void *priv; /**< Frame's private data */ +} vpx_codec_frame_buffer_t; + +/*!\brief get frame buffer callback prototype + * + * This callback is invoked by the decoder to retrieve data for the frame + * buffer in order for the decode call to complete. The callback must + * allocate at least min_size in bytes and assign it to fb->data. The callback + * must zero out all the data allocated. Then the callback must set fb->size + * to the allocated size. The application does not need to align the allocated + * data. The callback is triggered when the decoder needs a frame buffer to + * decode a compressed image into. This function may be called more than once + * for every call to vpx_codec_decode. The application may set fb->priv to + * some data which will be passed back in the vpx_image_t and the release + * function call. |fb| is guaranteed to not be NULL. On success the callback + * must return 0. Any failure the callback must return a value less than 0. + * + * \param[in] priv Callback's private data + * \param[in] min_size Size in bytes needed by the buffer + * \param[in,out] fb Pointer to vpx_codec_frame_buffer_t + */ +typedef int (*vpx_get_frame_buffer_cb_fn_t)(void *priv, size_t min_size, + vpx_codec_frame_buffer_t *fb); + +/*!\brief release frame buffer callback prototype + * + * This callback is invoked by the decoder when the frame buffer is not + * referenced by any other buffers. |fb| is guaranteed to not be NULL. On + * success the callback must return 0. Any failure the callback must return + * a value less than 0. + * + * \param[in] priv Callback's private data + * \param[in] fb Pointer to vpx_codec_frame_buffer_t + */ +typedef int (*vpx_release_frame_buffer_cb_fn_t)(void *priv, + vpx_codec_frame_buffer_t *fb); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_FRAME_BUFFER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_image.h b/native/third_party/ucrt64/include/vpx/vpx_image.h new file mode 100644 index 0000000..a998393 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_image.h @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Describes the vpx image descriptor and associated operations + * + */ +#ifndef VPX_VPX_VPX_IMAGE_H_ +#define VPX_VPX_VPX_IMAGE_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_IMAGE_ABI_VERSION (5) /**<\hideinitializer*/ + +#define VPX_IMG_FMT_PLANAR 0x100 /**< Image is a planar format. */ +#define VPX_IMG_FMT_UV_FLIP 0x200 /**< V plane precedes U in memory. */ +#define VPX_IMG_FMT_HAS_ALPHA 0x400 /**< Image has an alpha channel. */ +#define VPX_IMG_FMT_HIGHBITDEPTH 0x800 /**< Image uses 16bit framebuffer. */ + +/*!\brief List of supported image formats */ +typedef enum vpx_img_fmt { + VPX_IMG_FMT_NONE, + VPX_IMG_FMT_YV12 = + VPX_IMG_FMT_PLANAR | VPX_IMG_FMT_UV_FLIP | 1, /**< planar YVU */ + VPX_IMG_FMT_I420 = VPX_IMG_FMT_PLANAR | 2, + VPX_IMG_FMT_I422 = VPX_IMG_FMT_PLANAR | 5, + VPX_IMG_FMT_I444 = VPX_IMG_FMT_PLANAR | 6, + VPX_IMG_FMT_I440 = VPX_IMG_FMT_PLANAR | 7, + VPX_IMG_FMT_NV12 = VPX_IMG_FMT_PLANAR | 9, + VPX_IMG_FMT_I42016 = VPX_IMG_FMT_I420 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I42216 = VPX_IMG_FMT_I422 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I44416 = VPX_IMG_FMT_I444 | VPX_IMG_FMT_HIGHBITDEPTH, + VPX_IMG_FMT_I44016 = VPX_IMG_FMT_I440 | VPX_IMG_FMT_HIGHBITDEPTH +} vpx_img_fmt_t; /**< alias for enum vpx_img_fmt */ + +/*!\brief List of supported color spaces */ +typedef enum vpx_color_space { + VPX_CS_UNKNOWN = 0, /**< Unknown */ + VPX_CS_BT_601 = 1, /**< BT.601 */ + VPX_CS_BT_709 = 2, /**< BT.709 */ + VPX_CS_SMPTE_170 = 3, /**< SMPTE.170 */ + VPX_CS_SMPTE_240 = 4, /**< SMPTE.240 */ + VPX_CS_BT_2020 = 5, /**< BT.2020 */ + VPX_CS_RESERVED = 6, /**< Reserved */ + VPX_CS_SRGB = 7 /**< sRGB */ +} vpx_color_space_t; /**< alias for enum vpx_color_space */ + +/*!\brief List of supported color range */ +typedef enum vpx_color_range { + VPX_CR_STUDIO_RANGE = 0, /**<- Y [16..235], UV [16..240] (bit depth 8) */ + /**<- Y [64..940], UV [64..960] (bit depth 10) */ + /**<- Y [256..3760], UV [256..3840] (bit depth 12) */ + VPX_CR_FULL_RANGE = 1 /**<- YUV/RGB [0..255] (bit depth 8) */ + /**<- YUV/RGB [0..1023] (bit depth 10) */ + /**<- YUV/RGB [0..4095] (bit depth 12) */ +} vpx_color_range_t; /**< alias for enum vpx_color_range */ + +/**\brief Image Descriptor */ +typedef struct vpx_image { + vpx_img_fmt_t fmt; /**< Image Format */ + vpx_color_space_t cs; /**< Color Space */ + vpx_color_range_t range; /**< Color Range */ + + /* Image storage dimensions */ + unsigned int w; /**< Stored image width */ + unsigned int h; /**< Stored image height */ + unsigned int bit_depth; /**< Stored image bit-depth */ + + /* Image display dimensions */ + unsigned int d_w; /**< Displayed image width */ + unsigned int d_h; /**< Displayed image height */ + + /* Image intended rendering dimensions */ + unsigned int r_w; /**< Intended rendering image width */ + unsigned int r_h; /**< Intended rendering image height */ + + /* Chroma subsampling info */ + unsigned int x_chroma_shift; /**< subsampling order, X */ + unsigned int y_chroma_shift; /**< subsampling order, Y */ + +/* Image data pointers. */ +#define VPX_PLANE_PACKED 0 /**< To be used for all packed formats */ +#define VPX_PLANE_Y 0 /**< Y (Luminance) plane */ +#define VPX_PLANE_U 1 /**< U (Chroma) plane */ +#define VPX_PLANE_V 2 /**< V (Chroma) plane */ +#define VPX_PLANE_ALPHA 3 /**< A (Transparency) plane */ + unsigned char *planes[4]; /**< pointer to the top left pixel for each plane */ + /*!Stride between rows for each plane + * + * \note With planar formats, \c stride[VPX_PLANE_U] must be the same as \c + * stride[VPX_PLANE_V]. + */ + int stride[4]; + + int bps; /**< bits per sample (for packed formats) */ + + /*!\brief The following member may be set by the application to associate + * data with this image. + */ + void *user_priv; + + /* The following members should be treated as private. */ + unsigned char *img_data; /**< private */ + int img_data_owner; /**< private */ + int self_allocd; /**< private */ + + void *fb_priv; /**< Frame buffer data associated with the image. */ +} vpx_image_t; /**< alias for struct vpx_image */ + +/**\brief Representation of a rectangle on a surface */ +typedef struct vpx_image_rect { + unsigned int x; /**< leftmost column */ + unsigned int y; /**< topmost row */ + unsigned int w; /**< width */ + unsigned int h; /**< height */ +} vpx_image_rect_t; /**< alias for struct vpx_image_rect */ + +/*!\brief Open a descriptor, allocating storage for the underlying image + * + * Returns a descriptor for storing an image of the given format. The + * storage for the image is allocated on the heap. + * + * \param[in] img Pointer to storage for descriptor. If this parameter + * is NULL, the storage for the descriptor will be + * allocated on the heap. + * \param[in] fmt Format for the image + * \param[in] d_w Width of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] d_h Height of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] align Alignment, in bytes, of the image buffer and + * each row in the image (stride). Must not exceed + * 65536. + * + * \return Returns a pointer to the initialized image descriptor. If the img + * parameter is non-null, the value of the img parameter will be + * returned. + */ +vpx_image_t *vpx_img_alloc(vpx_image_t *img, vpx_img_fmt_t fmt, + unsigned int d_w, unsigned int d_h, + unsigned int align); + +/*!\brief Open a descriptor, using existing storage for the underlying image + * + * Returns a descriptor for storing an image of the given format. The + * storage for the image has been allocated elsewhere, and a descriptor is + * desired to "wrap" that storage. + * + * \param[in] img Pointer to storage for descriptor. If this + * parameter is NULL, the storage for the descriptor + * will be allocated on the heap. + * \param[in] fmt Format for the image + * \param[in] d_w Width of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] d_h Height of the image. Must not exceed 0x08000000 + * (2^27). + * \param[in] stride_align Alignment, in bytes, of each row in the image + * (stride). Must not exceed 65536. + * \param[in] img_data Storage to use for the image. The storage must + * outlive the returned image descriptor; it can be + * disposed of after calling vpx_img_free(). + * + * \return Returns a pointer to the initialized image descriptor. If the img + * parameter is non-null, the value of the img parameter will be + * returned. + * + * \note \a img_data is required to have a minimum allocation size that + * satisfies the requirements of the \a fmt, \a d_w, \a d_h and \a + * stride_align parameters. This size can be calculated as follows (see + * \c img_alloc_helper in the vpx_image.c file in the libvpx source tree + * for more detail): + * \code + * align = (1 << x_chroma_shift) - 1; + * w = (d_w + align) & ~align; + * align = (1 << y_chroma_shift) - 1; + * h = (d_h + align) & ~align; + * + * s = (fmt & VPX_IMG_FMT_PLANAR) ? w : (uint64_t)bps * w / 8; + * s = (fmt & VPX_IMG_FMT_HIGHBITDEPTH) ? s * 2 : s; + * s = (s + stride_align - 1) & ~((uint64_t)stride_align - 1); + * s = (fmt & VPX_IMG_FMT_HIGHBITDEPTH) ? s / 2 : s; + * alloc_size = (fmt & VPX_IMG_FMT_PLANAR) ? (uint64_t)h * s * bps / 8 + * : (uint64_t)h * s; + * \endcode + * \a x_chroma_shift, \a y_chroma_shift and \a bps can be obtained by calling + * \ref vpx_img_wrap with a non-\c NULL \a img_data parameter. The \c + * vpx_image_t pointer should \em not be used in other API calls until \em after + * a successful call to \ref vpx_img_wrap with a valid image buffer. For + * example: + * \code + * vpx_img_wrap(img, fmt, d_w, d_h, stride_align, (unsigned char *)1); + * ... calculate buffer size and allocate buffer as described earlier + * vpx_img_wrap(img, fmt, d_w, d_h, stride_align, img_data); + * \endcode + */ +vpx_image_t *vpx_img_wrap(vpx_image_t *img, vpx_img_fmt_t fmt, unsigned int d_w, + unsigned int d_h, unsigned int stride_align, + unsigned char *img_data); + +/*!\brief Set the rectangle identifying the displayed portion of the image + * + * Updates the displayed rectangle (aka viewport) on the image surface to + * match the specified coordinates and size. Specifically, sets img->d_w, + * img->d_h, and elements of the img->planes[] array. + * + * \param[in] img Image descriptor + * \param[in] x leftmost column + * \param[in] y topmost row + * \param[in] w width + * \param[in] h height + * + * \return 0 if the requested rectangle is valid, nonzero (-1) otherwise. + */ +int vpx_img_set_rect(vpx_image_t *img, unsigned int x, unsigned int y, + unsigned int w, unsigned int h); + +/*!\brief Flip the image vertically (top for bottom) + * + * Adjusts the image descriptor's pointers and strides to make the image + * be referenced upside-down. + * + * \param[in] img Image descriptor + */ +void vpx_img_flip(vpx_image_t *img); + +/*!\brief Close an image descriptor + * + * Frees all allocated storage associated with an image descriptor. + * + * \param[in] img Image descriptor + */ +void vpx_img_free(vpx_image_t *img); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_IMAGE_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_integer.h b/native/third_party/ucrt64/include/vpx/vpx_integer.h new file mode 100644 index 0000000..460bcc8 --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_integer.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2010 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#ifndef VPX_VPX_VPX_INTEGER_H_ +#define VPX_VPX_VPX_INTEGER_H_ + +/* get ptrdiff_t, size_t, wchar_t, NULL */ +#include // IWYU pragma: export + +#if defined(_MSC_VER) +#define VPX_FORCE_INLINE __forceinline +#define VPX_INLINE __inline +#else +#define VPX_FORCE_INLINE __inline__ __attribute__((always_inline)) +// TODO(jbb): Allow a way to force inline off for older compilers. +#define VPX_INLINE inline +#endif + +/* Assume platforms have the C99 standard integer types. */ + +#if defined(__cplusplus) +#if !defined(__STDC_FORMAT_MACROS) +#define __STDC_FORMAT_MACROS +#endif +#if !defined(__STDC_LIMIT_MACROS) +#define __STDC_LIMIT_MACROS +#endif +#endif // __cplusplus + +#include // IWYU pragma: export +#include // IWYU pragma: export + +#endif // VPX_VPX_VPX_INTEGER_H_ diff --git a/native/third_party/ucrt64/include/vpx/vpx_tpl.h b/native/third_party/ucrt64/include/vpx/vpx_tpl.h new file mode 100644 index 0000000..9bb6d0f --- /dev/null +++ b/native/third_party/ucrt64/include/vpx/vpx_tpl.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 The WebM project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +/*!\file + * \brief Describes the TPL stats descriptor and associated operations + * + */ +#ifndef VPX_VPX_VPX_TPL_H_ +#define VPX_VPX_VPX_TPL_H_ + +#include "./vpx_integer.h" +#include "./vpx_codec.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/*!\brief Current ABI version number + * + * \internal + * If this file is altered in any way that changes the ABI, this value + * must be bumped. Examples include, but are not limited to, changing + * types, removing or reassigning enums, adding/removing/rearranging + * fields to structures + */ +#define VPX_TPL_ABI_VERSION 5 /**<\hideinitializer*/ + +/*!\brief Temporal dependency model stats for each block before propagation */ +typedef struct VpxTplBlockStats { + int16_t row; /**< Pixel row of the top left corner */ + int16_t col; /**< Pixel col of the top left corner */ + int64_t intra_cost; /**< Intra cost */ + int64_t inter_cost; /**< Inter cost */ + int16_t mv_r; /**< Motion vector row in pixel */ + int16_t mv_c; /**< Motion vector col in pixel */ + int64_t srcrf_rate; /**< Rate from source ref frame */ + int64_t srcrf_dist; /**< Distortion from source ref frame */ + int64_t pred_error; /**< Prediction error */ + int64_t inter_pred_err; /**< Inter prediction error */ + int64_t intra_pred_err; /**< Intra prediction error */ + int ref_frame_index; /**< Ref frame index in the ref frame buffer */ +} VpxTplBlockStats; + +/*!\brief Temporal dependency model stats for each frame before propagation */ +typedef struct VpxTplFrameStats { + int frame_width; /**< Frame width */ + int frame_height; /**< Frame height */ + int num_blocks; /**< Number of blocks. Size of block_stats_list */ + VpxTplBlockStats *block_stats_list; /**< List of tpl stats for each block */ +} VpxTplFrameStats; + +/*!\brief Temporal dependency model stats for each GOP before propagation */ +typedef struct VpxTplGopStats { + int size; /**< GOP size, also the size of frame_stats_list. */ + VpxTplFrameStats *frame_stats_list; /**< List of tpl stats for each frame */ +} VpxTplGopStats; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // VPX_VPX_VPX_TPL_H_ diff --git a/native/third_party/ucrt64/lib/libopus.a b/native/third_party/ucrt64/lib/libopus.a new file mode 100644 index 0000000000000000000000000000000000000000..241b47a4977fcf7ea7d7007eceacc1e31dde4323 GIT binary patch literal 732568 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ῲw1RD$}qQKG`EKr>=b)4QOLLS zoKe(j?hTl)+!^=^dy1Dt=#TTM7!QqsPU?@dI~n!Px**`+!iK?$iF(=foVnMnE0akP zv$j~38wFn!WPL#R($&)x?F$c2{DPFf}7?MWzh4z11q zP_@=vL4NjT)u(ckfuPE~X*zs*R=BOaMj@=*{$2lg)-m80kNbV7r#KJyf?*Hcr0Iw1 zuJ0pS*i*?6o)t-6QUe(y7b|sMxnB90-S&8iXD^JZ*rx+GQ_Eg^3{aq1mb866Qs(x^ zKu`3uP(E}?{R7Rl{O|jslkc4f*&ywU*aeqV5l-y&{bI3~aX(woFG8#koG%vlizw@> z!IFLvO??qR>KD<`m(>mZBIYx#IAvVdFCxG1i>!Wl+2xke)Hk0p{m-Y5g}0%Atq8*Q zM2qfrqt}51lo8%HX53B;!h8y)ueusVRT0}VU)i53%G|TJN#qL%kqek2S~L_NLm&MI z$|p4^glqkk*6|S(mlGqkGq|BK#~)5ILKRz&^^CvqxJWWPxW>>e5RR^ z$=aU_ax2anWh4?J!^XChT}j~$s-6r2)CN8z5F>syu>ou91d|<%Joz|43>>Gt|d3N~y^8J93L%GG0ue#-7UQuRhhfJ~^sBvNS}VPshW#I)0Xl|7~A<#Dd#{$73YE@n_Bk zygUkxKZks+Vp@^zuzF3{3VHl`IGm>G~rg z-FPNdo>!<2pOVx*e}&*UbtCRd~-9iPOn}s$l8j}7gJBljaE&{s1IV7 z8NEW;Wa4V;LUn=%E-ym=Sk+wP!C=8yk@BmOc@4>&+snD0u5`yrF2_-8zXm~pw&dK? zs_i}ZY;y6bir(|GW*6JBFVJ=ctNEnQu0XAEf>w1^(7GC{$l)y9yOnW4#C#zv%a2x3 zMXvdZk3xx*I{bk_`npsgo{ApE0RSWMpF|-#1#v;fh(_vtP3q)mod@s62T((^$~9ke z`JhOp%zlm}AJ@5XZWfqJEJx2;9EJNfNgG%^o&SWi2^NAXW{W^ZPatTP=a|)HXzjYX<})J5>J%3P7UAsdVE@ZLF)9!`Pvh@T8k>Iz zG_UW+_`T^(Q)l}A(?7&_AFd-9a#)pB;XZt$Pd@`3c=+Dg58pE4t?#GdTh3g9@0KAp zUD$AO@x5PZe#1$`Vi)rj+-{YQcQPId?}}}rVgpa1ppJvmYSa zah=0JZkj%UxXfeqX`ONHX3o*-q9?TcTzooN6=g~199JLlI)|YLpP&q{f7%&7x_>I6 z(CQkme_H6DcKb`ZPxsILx_|Cb-4JNrI&>#BrT32>4#hIPxfQ~^CSFiT2*UI$4W&L~ z^?y@S=bS{OVB+IKnyLs{V^5r%qL>w4l4M`znYolvbA+>)Cj|tzaWCLbS}r`CL7wGNq-b&*NgtGGBCx7$r-Z$B-<1MX$kf z%#Ihkxv?;M4%Yvka$=pTZuqQY>x)=-NMj&#&2N~HcL{%fj7pM&f7^>ahqam=ds3>b zCZ%t}2N|PWdUKia=rCbRGhj<08OYI@@TDpK0K6<`0JYS#z#WVN(r9XdqR`=hQc}>l-E3mn5eiHd6zfCaCYUWv0>9+Fh z)Z2gCWtL}xx!9KcD>tXno6=sfTfO};$w;LSo0~;Xa%P)kqJ#RfkmDA^+Yt36e=bej zQh4PxB;ObPtZlgHKK9uC>3QIMqvRwJwMHw$@y#VEj3UzNBgdnLxA{eyQc-FWNsCwC zr?vNg_kIZ1Dc>U4KRnAfyf|xCW?e?ruoNne6An+Dka4vCRG(vWo*L1S)tRx^&w|e{ zHs{y!%+B&6?z3eOtm%bMyh_8&dcceMX3_e;;JU47Tecam-i=|FbBpPTQ| zBN9hv9PS_GOUrwxu#s85k^YN(8O6h9`Rmf^CHYDj<0)f!hHvJnzoa_VXKg&OZiTMR*<0Ut-i90p7^7m){&<--$lo zKGb;&1RviL;-b9r9DZyE3`o(uWi3|zc6xs_ixa0ky<_#FW5EOLQ_wl29)zvS<&%hH7 z@BoiKOKQ(iR>B;N$NqN-zVrsW?I|XQA%Y9lhqd)fql<5lgZ1+L%-ZO}LgkHxfU)@{ z#lx6pxpwxVIYn}pnErmjqUiU=7Zn#3kxXT%1H6<)MJn-S zRn^mGUUtdx#09S7NI?;yH%@uH>C z{L46)Q?7_An8bKGd^?C4ynhSdEc_rS-{^-cR8 zGM&4GyAG$Ry93-jyk`8M6X&r_$j^q;17k#I=7r{%Dy++kE8#KnT!t^ZfwS2zff zOol>(S5SIFk7$OfQF=m`kp5}E)Xo0s!o{=aOyk4}H#{7wGJ{i<3{~gB$;N-XWD~w! zvhx(J9-3a|t{Z(B;?V6@@i?DV(^z0?dAKq#jmhkg{Wf5366|90lwPIYfQccS9z=3;8R>?*PB~{1)=7;}_+( zjNi@t8u-Qet>$+dzb1a`_-)`P-*4o155HO8_4!`n_Ym*_p3OW9knL*tZ2@lQ_Z+_t ze(MO^#q&*mo&4VC*TwH8!n^quLrH~Gx=rn%i)*HFOT17eq;D8BW;kU`*#Gu zDCdVb>YNU1xNzJtXPz%C>djj+ckYm%%wD)~aqW;X>UbF(V8}?fNywD{ zFn-QlhQ#8Z{=+nKtxNPD7qei|g27nrL;I4MhpepAM-tE7FwWW7fz@$F$o$z$XD+;U z$&!K5y0M4qvVp0c%)V8LP9imq2X$%xjNJL;9~39D*NqG2MCT7o=Dc3oPp2;XDfHt=r(0Tui*eV_3EG6ji;iOUrvK0&J#Gz|UCR0Wj+O;gZY zKst6>miz75!`-ky0O_2Yfu^f3c6iWXXq0rAm|s_`*tK5RMi2T+w$Jwi69GIfL63RRS6;g8!`*ZXfpkrmdC+p887e=yCsnssj)m#E zv;gTZ+CAubAUU;|>01PYyi!4%fpndZKGH3%0!Y{NPPQ6!P5%U>^LxaDHUa7Uj?Hy* zISuG4mGc)sS1U+ZvHo_12ju|iZ;$uF@;&G@FLt~aR_a0j>c!sRg+)E+r(W!>URctD zZueqOJIbxqSPv=z(zUwC3#;^?sb1_%FKnI%UGK#%^TJkoklZ+{bN+=Fw!wq$_G16& zh5gxs9`#~h1G-G%^m7k7iV5x-6*dlNrh+B`Y2058q`!CyXqt-M2Bg0jna7;1!cGF3 zt)TOObf4D%U8};b1=6`}0Ma>s1yrkIFGsU8M?t>=(&>&n&gYw}!qx!IQ_wD;>l73l z?WTL=Kbh{V(TQBVQ9(2DK+w6rs zYj1@q-&Iu3tIgrNqS|FYCqd@wLlTLN>TMeYYeF8|w9*Y)He{m|1jy=Un zR|4rTehQ>Vz;>V?srvPLvFD%W=C=$;r~4G>THTJ*-LW+uNQe3J-BI>q!se)S{8QJj zYfl>&@}@m)gcJn3cVc114#c2U_q=B)uO0dey4;D)_hPB26ISR!W4+j7FKmJbP4r?X zd0}N96!c=lURcC~q#t#ig;sQ}WCqgERbK2YFHG96V}(EHZx?!DbsiM;VwZVgH+xWn z7aRA&R(sHGUTl*Ww$6h#c(HeRVH-W@9xwJjFYEyiddQ1?*b8g+peMZ8loz(egWA2= z=e)2E4|>Uq-Q|V7=|P=d?E79=mj`|9#qRaOzVx7OFZO^JCOT~03!=f+y&(E)9hT!k zxn69Z7dG01#(1&$URZ$#iH(@fSxzqLoMnSVLleB%iC)+w4=VFwgI-wJgTzu&=UnB5 zP4}Q0FZL=gY?cSj@nXd`Na~X5tMj0!2QBlUn?0z(gW?{v+JkQMpe7Gm=Rq4h=q?Z1 z=t1{*(0v~CfCoL~K@WRSvj;ulK`9T~;z8{m^qdFDNq60HImE4@T^{tN2X%VT`ySNg zK_7e2UJv@xgStKFfCq{DeO+3n2W5LujtAiw%1M{!L8Corj0fd=P=N;(deB%8D)yiW z9z@6cuIC3i@K>z<%Uiy$zYm;YP7FqwKr&tjr(sFq{9WoiDTaJ#xRbvGyE!ceE+fHk zo*5G1`zb%!iIbfB-xVbJ=)bi*WQT5W8kQ5z-~Zt$J9mTAh+akiJ;FnF^aiIf+$6qs zAlc~~oCYD>`FmZS$P$CoP)X-+k392-N<#(QKfm5F9GnkAr}HPr$Y$jal@9`^^LHL; z>V`~%Fz5UUw|HpCG&IEdyPh<%BRRM}C%H*{HB`H zX*4*}`x2h1K0UTyh`zj9Mexr}b4tH75_^8X5WcFd6Lzfr+^?qfOCwRW{X+2Zr$6ZT z)s6kqNYpKZLe>om**GZV&x1nR28D>eO#iuc{$x-Hf=*hR8_|!{Wu!`7`Iup5r)K(>hb_ zLD9|W(4Uu2yJC8I`0R7XYe#FY!qrtv_YW17X#WtcLQM~wxoCED!A}O%MK%}G6DgaH z{sE4PR|lqPkUib-pcuI_-PyhzDo*RBhl+8O={iccI2|58q2EWYay>nX*4y{#<(;^p z6#GL&rJ*tXi+;FFkLZV*v`w3IEK2$TohCt=+_g21yen$&dsTk+(rW1J(e!U>T8W2@ zaB=3+Pue$H2k%m%+&q0~YMeP!?u%aLIP zz)bN2I8!p9(QZc9E{M)txcIudp%Ut_dANmKsBi3;I(O;Zs4j4cn}JxT^t;zy-DIe$ z_Ig9uGjKeuEjbTQimFCV1vJ47zPu4{ zi{Jad@wWKAbI+Stk}rRK-WFR|YR#*xP1EHC#hvGCZ;SW+So7*09{IkyS?Mx^jv4&F z%vY524WXVvxPV5w6bba=>~XPOu^mT zS%7U>uG5WI>1GY-ZEvHa02OXJ?~md?y%=F^<)?+qN53dGuH;X`~FX6zV=2M-gs{Tz1b zl7knUq>P{uZf)tz;Hxa1ykTJSGENJ1Rk~Ta>C&_O!8GOR(YmT29K-@y9wke^SotVc%rt9VxONB4ihz)+65Lbj)=7CZX_)TAwCw*WDCHjx z0xJA3(oDJdV;}-%WFDsKg;$@J%y8-U`seH%2*5ctroDA?c_;@Rf?#%5*zDl?YfR6L zH5;L;kojD=c6vb-7l>BW?&XZZ2ZADe_B>Q!g5umG?xAxkYu}IfcXO2O7o5@RZ=FpN zxidObcMx?OHRS-_-s}GgG1PE`W%a*Fmr_EF$|3Jp1H2#b{V?&%2~ugF+%8fx<$$zj z%7IF<>69wmM-_M;^8imgCOx$0xISxj3KVxnG`ux>Dv5k@H|IyjIa!j}xb+K4Cy88v zElKL%(#M1QP8(tNUvsejPVu$B;tsjM<6getSclDV)o|?>0d@31Dj|4~!xj-L5UzbW zoV@v0nV>?P+z3v}(vM)I^(vn__>q3&C(V=AiAVO`av4cD)`m;8*H%6vrC&|YQLLS% zdN9aYC#j#22+5G9jvT-Svd`n6a=p@&yx1Qo<>ZPSnTfRy3veFb<2{cUXQvG715RnQ z(HhR`zAh)w)OZS1d;VMJSJZY(_v0?0RuRUF9QR)HxU}E=yPN>8iPXN0Bc_il{cqq6 zD&ps?PhLPAsNqQ>nHj@+e_j*EagUOde}Z7?!+>!nWHYJ^BH$);yp1heUg@_lP6jLG zdWpEIP8xdtDQG;EtFvz*`(0cRBjY|GC=5_UOZ_|Ud2i`b@I{F~{WvXGkan>C4KC1G z@e1zYc0%AEMryZH{m1oHx0U{F6}9*(y(%h1}bcGbTp-WykcM3wNnhxK*w{4Vo>L{*Nj7L@7C9zSUw6yEvKo`^%CASK`4p zQ?9kIXn7w8V{iEMb@u7yO)(aY&7a5roL$`KOXn?OIOWdXJI}cGc?ll70k6EgJU!1? z*kW9n;%XtIW(xy8u(zUgTm4gkeW{G-ByU4sn*hS_oP#{Agwen4ll#F&eLfu*=qm!oo^h25dV&5FBe+xGeD|Q9Ug9__2 z!MfQI;>=3z3ogEKK@e!!g9nyp)ukMfWKIQqk*2?w=^-XLR?S!95V6w#qH?!}tAaVV zW(qbrpOVAa%>k#PlJsc5k~%`Z2|wQ41wOt+TIHPrH)z|!r+#2_<{8xc@EBGWd+{T)>6#UfH0?T$)xbBthXpz&)tt(t6-Eh2f3^$f$E2)-AbD*U!pj#>s! zZvE=@#@(YiE$8nLlH1&*TAJ3Yay~PhJW&b}xg$BLf#Z9ZB*!)ct<1;6{#UID_lNy2 zTX~Ojtx{3D@nGitxZo?=Tl9D1!I%9Vw7=V)2Nu<7*FAp`&nCV-_cv2F-=2G#v*dAi zztd0U$xIW13?|1sjmF*AEIRXGQ7Tx3>(vK0`w`BPnPrjM4i3vzYL^%lEj?#)YmgG- zC@z?65LtOaOD9dAlFWHt%945n{k!m7X;g6Q3NbjY=7JL3x$eY=<)y8qJCsCeRJMwc znb!UR-nCcrse20681=|QD`+1SbOgw;JywEnenEi_fd~apM`Vo?J&RK`SY3F}v^l3YXnDOXa$! zPI{*;lp|#Xti%RB3|Q+Q;t{f*yoW&XGx8;Aln>qL5&IHN_UmoL+T_hp;Y{y;U8xLZc6TVAx7X?*z?vjR&7$e&l#`QD?-w zdZK(G-cB+V_lw{XV?#x9U80M2>Wmwol%{KECd(m0D&n)sW(BNd1K~yO9GG2@dKEly z#y{8J5-?SG%YN+_BV>i56bj}9%@8dYz-?p787BDf(IrNFJ_=Yp5#*jGIEN#TlQjgq zD>u2CGPIa*O()Xw$DFC;uA|eA2K{fWxSG054A&w`=bk876}cSVo?VjaH;B^cSMtmp zjz>+HBX`NkHE}bZmkB3NoMtl_)NnIiO}ogJFG;W}-%2c_OAxUW>vN^|?^d+X__fAK z37jaTo5yIKs=Cs6up9q)k=n0XdKs7d?*HQ-&#Y+qBtKAeu)=z>nI^%JY?^4qtx1~3(}>Z6JZuZ7h>a1TqE`0ts@JLD*IJXy}<8}TzBwaKyy#Agp+ z;3?&}Nx7aC|Ho?{Ah4pgouSap!S_c&wdw$w8;kiWl+5`h_Yj8tFR*F~n+L;1+xML5 z%63TJ!laBKDa5{mPG=B=%)x9};0d!HXvS67RR{UW5a87hUuw^8m9-IM}K zmMPM}n`zJ(yNwb8Mr6l0;q!rHPOkVzIG*mn*Oxd6TCnZ9dGqFlTlSw^)DjdTtt_w& zIu!!Ky(^ea8S#yvm`0Qp(C8Gtb`=fc@lgp7b;4xUr5%Ey+c*go=Jq3vo$&wY!>HO&|`MUT%IG-HFrhj z;~3u|wY!h=NbT1Ugh$1l@P)?ZW0jNE#8cmq@!L(Up?|R^^yY${|@>C{LcH zJB&+ra0SKN&U1lV@oQ3JdysjA|!*NJdvl3g(Ub1v6BJD{MTD$u$n0 z8L{PKxQ`(ltnLdHqT4t~|69w);672hSNxZRf>6l0!kvu5gF)+V>2N%28}au^;B{gA z72YapcXDYK-7kE%EmXsQyyUrVtH|a=$p+Hj8RE{qvqH;oaQ&w8X4Qc=*{Xm)_0=xc z30n~`)It@8a!K;GEMd&zz@aQ+O$`Te&&m}CIGI5}EwrZK9rzib{H_U~$i0=JPLwyv zqC}1}5~@<}*(3f> z!scfhJ(?pcfg)5lE+d;t$pY-T87O-HdW%)}z%aLAuZPb^cAGjHQJKR;;hMOMJ)q>?q&ZJ^@vD^QejD6}G3 z)_3a!a+T1%@>Pzofuw=7F5^EMftB7xm2ssXK*Kg!8ZUxd^n2mjXUuMOBj`49?uLK@ z!#<f1BwObi=6DyLpJt@OQMw|*~(#c&PC&W1C^BaF^ob%O<;6&lF(n>ZH zE>gmilAv+Fp3(i(Akge9Bjm~Zgs_&2x)*HgSGCMpn}b*xGDEYt_tl6`;KjKiBXK>Q zTmoZK(~B*)`zgM~JXIw=7-CL`7=`Z$4+vXTVHtxH1Bid(+6B1SD$5iez?ho|JLoF< zh*euyG5RuuChht)Qv1vXFb((FSGi!D+`kIgt8PYyF0;1KQ43OXdy53g%$&RaHfgLQ zXjQcAKRr;ig9&1Pa6x;KEC4rz#i2>^7c>|*&ZuO&bH50o-)ZR?j#)>8S}sYNR(?B~ zvS*}J664Ih&3v*K#+Mr`YDd)TsjO`;+3APry*rF_XSR!whYWyjc}6$ty6lpad@gqa zMEdlGQ3wA?6siMxk3D)BS12cN#g- z?5hwZ4X(|?VJNp#*@vi)6wRNf`&8Zr)+ zri`_JgD&u<%ryzzt8pr1>n@D?#33!3BwjpHGG*0L$z49ZB{+G1NsT9OHKbM*iXoiB zM74L*>F^y(4CK<$_+vc_xlcD_VNbfXy@j7Epdz3}wvsE2p%@6e1d)Wz-f7bo-B-}vm(WKOe6nyQZd?hL5C7$?0*42}g6nQr)AQJ9! z5_*HP8?NH@eK(Czz9nN%imCvK)#3q{EPjfoZ)=%f@(|LHKe^OIFDaA3iGC$I$tElJ z(8$N&vY0kXLJ`X*6Qm>Te*v7=Lv)dr7qwXN8XO6Fi^ihtKr6&=g^D4rlhXl)=Z{uA1B#--fO?2-{z z`t*%MoWsc!*`>$h>8K63Xu6silL4T`phRj}aXPW+I|x+E`f&htC(3-iE^Nlbp4|9A z)+Y+m!q)oxuYDv))%zr9fj5UU^n_0WnW&F=AL3;D%vg2tr%VyZ?RxOx`4@3$i>NQrZ)dKT zT_2rW-ZaFvl?M#8P?gq@!k;RUpx-E>tw7+*#?oV`Pxd*%K#|ZeNHc zTjDq%oO&5+js%b`t&C8y+!Pm=nx*S6W1phu!ND|XRdegF_A@B zun2v=+T=(v^=HY1=iUIb7g)7Sue#-1cTFIox7-3Ft8f;YcQXC6w^yG%XhFVtq`M$b zT&H+PNZm_eU(1X1gUBV1hyWpi-ygum7sgTSb{+My2vx{FY09%8a-S&CjJ&O3e|tqT z?-j(X6|c&_XS@v#+JLaMq65dzvQL{${wpq`+B5!c6e5GqXf>|t;jzOwdKsZBprjhw zy<(@pTE5Etfh2zk;R>}~%`;NLA5bGNAQJ)zof zLv?BLl2x6fZk71~N}L6oJD7%Agsd7`Iu8#fS5lx7W-NNU9ry* z`PTlLibbret19XD#O*Q$LS{nbLi-L<_qE3n(KPEGB&FHaYB&eemomnhPmu;$dC&5d z>5-i{9ke=iIRS8BT{a-0N?r7#NNN<1gYA-?GBAw7x1FsDC87x5$^}_!H=Mp#_a?DN zd(|%b?=ps7c_vF2S$C)&T{D4xhWC>#}5?m|RF+ zU488*-T6l0cE~ed$T{K{ZDTL5TRu0I%ZMeXt5_P$_?jP-Un4Mitay$=B$UlvP95#W z(c$h$X-EB^OTRYO+|FtUIVVumqwkA<4D6HmJ*iu`_SKNNL)Hj^9X4w2Vwzf!%siSp zW>zGpADvm@m(1T!8*eN4ctw>AHmn|6bE)u>Zlkc>$ZIw7o-wwjE+ram_KIz?lDSG6 zK6S6b*rjUxcFKB3#-3=qd0f@bC^QOp7<*Y{ zAh!Ep(En;O^Fpn`A>W);{F}ZiQwquhPg2;7ENKE+%hj!@&eL&{v&z1eoY1&I&zhM7 z=+Wy*Hp?8bE0`?vt0}=ks53;|~t#|?SLT%5aC$9n7GqO_fVV2x_BPd{;lX#PH zV*Cp2Y>p$=*%>$J`Qq&T&^OSh@2;s~z1(Z@I-!Zj|Cf0n0T|M<4?l7L(iOa~u&g7H~qoS3|W{X;! zC{c9=?Q3C-+EHXb5uIhwUF5Lo;k0ECVfRRwsES+(+ii9a{!0G=3iS|6LuIS|0K_lz zj&aUreIwxxdB$o_X~E3#CCP@>I}8n zYfpjadE-wszV=~gP+UG;y==0~;YNJF+{~pHxbRIS`nqde`@5gH^`}npWr&0<$L9-e zq);nDSN2FpALA(8m8MPY6UZ!oCw-VRXsVYZLwcJh@eA%w5E>Naw|yrsUVXLd8@bt4 z%_n|FbQgiB{vQ{ts#^^dlPIo5??&I?<6AxD+Z2f^;f&DK^YE@_n;H%@<;epyXF}alm-5UA0?X zdn9UA`!~pJ&iY0MV4v#Oewhvz{L21;T?+B0tL`or(Lts^{zR}%G$ZCwANvL(gw2_3 znjBDgv~wT?RC9Da4bnY-t(4l>?JI+b5;OS3_9NKHg}3pW;aCVVU6I;Qp{!eKU*O_G zuFq6?4Yk_01LV&YNUoYCP;`K+JydcyU`en2a|STCJjv#u{h$!dQ)+9ZnU#poe&;e1K@*jpjqK=Y&h(loef()o{g z-^I(n=<=BY^%YQT>6_2W{v`gixqHccUFFQ}6;rW{DKK9faNiVGwvTmP#NJG6^L)|Z zg<3L5=I_uJ8sS!NrO8%|c95w0w-4;pjRYn)kIQi41Dfjz(^Dj*4UdR$<^&X=4UM43j})srI%L; zgVvmaf{_2?pudw#mW=pa5H9-?>P{@3z`Q;)Xr5{=jhg=@yCdfPBB84&GbR(IE+YEf zaP1|T*0d;BdSqjLfkB;0BNNH})HSTQsWjZWLoz_Av%Rf6PXfYb`RI8@T}x-UIggn8 zW~-q5u$qMt^L^$t_P@Bx+_`d#r>tDK`MgRV^Nd%@vm1N0VT$Nsj0gWN7xYDFB+ebQb#Xx%bk^6Au02!HIK3aMuGsKCaJtOAsZMu} z+jlPB?xP6lL8sH49;l6I8rYuAE6rfL}~ zXp7_}x%x(?h}iz@8-Ma*Cs9d>TKij~N;_88F6fYDJ(eiZ`3pMQ2^8?^>pSMiv-I? zD;K<>hz8@Cik7cW3l%*ZDBZlWgN|sRA~d7RqOR!6TQD(JbfvFtl9A7*k39*@FfPmv zWsDAzb^lEZH-6l~_r@p;4UADZ)yJ?;?$$6yVPnq!=>>KCzpJ2uL;&|FGiZPZ3O4GH zhYN%U5%NSqN&qtzFga23tSo6_-6h7Fdd@J`?1T>ol2Z@F zdJZ>kJx!K(<__7Dd_QQuh;es|orzs<$S6F(7TZe!lRZ6ZTFCY-7O>c}8@WF!Gjgx3 z3dcTbFmf;Ex1=*@6h4izYbudH+ZZ&qZdpwV&PwE7{1A3f#J(Lcw!Xd^;8QXtV(Xi$ zjl%ar##VbZ|Gtoao8KbM2dnw_)>|CsAnhB#TP0wZM1A-ciFQdIDe~Au=1V$c&y%SA zZ%I*mfu14J4B`rR@bU@4g?|V53}EZ4t98iMeUw(nI{yWV`HVbYdCS=Pcd5a4GN19b zvGsY1o6!<7YP!g58+lNf8C#4Q`v~4m5JsIfn~A2$QV%JwX1g)tHSCH>Ps9$1G-`H{ zPPB0~9Tdpc>3c>YpKa}`maxAXg*J_@CXcV>{bTv}GH>rn;-GQN*Zkhaf}EuW`AYCH z)Msm|*Vy`QHDMp9570OCqED>pmVfU{P=`QY%fELk7;=dS94wiqOc9|6dJ@G*>5E2O zgdGeA1F?@X&`fe*D0d`3>|NxLOsqBEDBMq}M&T~joBN5mxC@|@9|5^jKBoG>@`FHT zKwdSU@_SbjvaMXB5`9S?M9R-i^o>p=Hi5Z6MC-DLxIHLeBu~*uLe0xw zi#j=0WURfMfkpZ(t&MLrR>_U8ih@C%qFV5f>nF)%u?K>TUtl!6PtItiWPkb{9&eLo z#te+^W}w)q!S=Th{S(OJ%Rn~qQel$Uv>SyeWm>mnNfr={F`W$5uJ$OS5cOB?o>kn1voT$dT-YGy#$Eq-Hqw^7|<{D2%`FI>MN{Kl1ELCd+K zVX{Bb`zyvdN2og8x==Vd3kBnBHaPB)3L2NEAjlMfbjjtAKZj;k?IQ*}w_}QM`8J6% z&faRQI+iYAn3IfAt463Og0>okU(tD2Z4MgKv+5Klx*GJ!pcr*IxX(Riz6!)l0D46M zrVSV6#hwYPa+M@mIQT>EBXT zyDeA44nR^`0|$Qux3T5++Vu4v%8m{$g+-viWI)a((7$i6dEI0bJ}Xl>xj=b4(B?u= zGtt{#y^G!yOO6kWD^Bcy0T}1JX#A>GsiHJ(;IJ?G8Bw z8Wc*)RbMNm`nEkOcm5t=uOA*R68r-N4G@6rt1zEpy|huLRSaJq;_1lh5$nk^(nhR% z7=vKQ|tH1&zy^zsLtxWsTDjFh04l7sG&lLSUMFrTgb| zr+@b9{&^*gZojX8zPJ(me_=dYS>^Upiq91HZcB7Zz4%l8Mff${v75#YH`ZQ2O)N_q zDJr7`%{R_DXxy@eU>L}XtknQ*p)ljFw=MP(3n}>vnO_Lc3d9bwT{%vA$%+YQ4qA8I z#{==Pj1vQQ%B`ncOfiAmf)Q&(Sv*%R*ru({?fd|%hsGXRgc$Lc$OOTvw|Ulk$I|D) zj!-GLlcg+6R+Md_pp+jnE_;SNgQj&4fdP|#+nm@|AHll40##k$s{_fZ!y#ZfZrq*0 zt)rKAb9LDAAmzU_v{7%O*NjPCX1B6XK#i|9YuD0dF_~v2Cb4pMnI6Aw8NswB)>C4v zy%a=|Z|mf+5oYeyBGcz<1-DSy6!S5ZdW_7lx zCxb$0)tHT3S=SaSSV?KI)*rIP%RTaW<;m&ed~EXu{U0Us*2{99+r+Iq8UPx59%I2B zE$18Fc-vo9cZfuRsp)IkrN5U#}pxeX&? z>DmBi3$U6yIZ*U!(BEy{@dSyvkd0a;_Rz~#b(uMBQseT7K6LVp1l=J9Cqe7+hgDM4 zu#q4e%#9x@bJgA77>O;QTQBXkWlL{#WZx?`tE`RJ&OnI}w0*y3K z7AGX2hW}*Uqvt}Brb=SJN~gR07fw|uWrb@qw8?NXG+%KoPVa<@yCkX~$69+Sa*oT) z;WGAmE%)egpKNMqK^fg6+r-AI|0JsMCg)JmMqVPx^_^0dWr@>#_P*ywP@Z6W%W-W~ z?Nybm!?{asUc^|`;^=Y2l00lBX3_6NVGb^)en7=mqDmlc&6TN_<54noc=fdupR?bB z?zQ|en1{&->Ceq**0?6dHod-2m@*>Q{j$}Dkt(KKqUQZr6#&cICWch9$T$&s zoG#i%P4++##UkM+zk)(Z5PYz3hPkew(lb$ptb zTw<^|cV@WgiF#)ebCDJzM;h_VXhkRvN4iMpG}e=2#Qz8FaDZ7f6h~k23NJd_$4NlR zk~a)evxwKb9)A+WhE+d0cv#2k;r5?Jkd^sAaD8eLi~C*h?TeCmqwUjFo63C2%t!sH zQZ;E{2Y^ymUrKzN`<+7u13K30O?c=$W~Wfth>2&;wpbH&>ffv2C@zYr#>X3o(}Q6o zX|465FI0%5l`BqfmQ9FQ&yg#}s3w7_6-Sk%Vm(J0zfCD^rz^)KGWpIqf@7+wkaPdk zT=;qHBLMrqL{QUu4dLxR8l(DT%>vyWQj(FliEKh8J0FuVt~=0vSjmMCZjm{I*(QCy zAcOHnd@Fh)`0t}b@ZbIuO5#CM+Mm$kzS&wZaWVBv!$e{qC0a3IyO3`3OVYF7d4k$M z2`AT!93D<44#>j@$=*GSxm+dhA@a+bBx@aQ(FQ{`SsQ>=PknBTO_{1M?DbMWb@t(E zXxin=+YQs_+0K%2c8 z=xb*NdXdwl_Fc4H_rtrOUg6y%+?`#+9@m7sdbo9m2pfudGFipN9g24m<9ZP?xrY($ zKNE=9F7a;UIwESW7EzP;wiDXVI!=|l)(0}|>zE0wJ47Sy3O#>dpUJ#KMs#D3=J5tiD~aW(jEw$+w23nY`ObtVbHbYIshKy!T*BPpOeD^1Xe2)7 zBlhmeg&9(v9<%?d@)^im{!9wtDpNHtfOmw*TMvIdavVW9@d)glHo`3)lTI>z!x4Kp zhVkh8CHvCYg@^CgyKa9>@Lj1G1=mj=!1N#ndiv}q*l8SHwz|&mbO(b-B$~vJNhIHi z(zaFgd?KZ^%SN!xo=&6;xeQ&M<3SLl~ zY>ozNafVAS&-4L03ErajYt{aOWaV^3JvwLmXQFssJ~4CU7;JEpc`5IVm_FRseF}RR zA$NL@-Gohi{3GUNsCgDSg_Btgl3`^5O8QgyWrHr+*DxO3#`GAOXY@Qb&se-;p0RXC zK6^baZ1AL*-xhN;|H}?SUnKO)X9?ZO3I4im{JWOKi&>~Io|?;W&ZoKaj3w_LInOxw z9QJGSPFy&*S$0Lp9y_`($yH^qGD>+F5xk^RK5S(nR?LUi#iQ9+uRktmO&Jp~pA8jl z=G;dW4A3Is5nH8t$Jsg1>d!(mU9M`yCSbFqC(~{jC_C)qvTH3L~Ht&#E z9a(?6mW1|9AZECZamrGI+{xlFFAJ{<4ggk_BP1DY^1+ePnU%W0I|!M%-f8wZelB z3qgO0TcD1hRa=Y=H#4;O&sdkrZZ{HQ(Hdtk{3p+5j#I+-RL$7`nHYE+=?fYE=}7X3 zPMLa)OP@~OcGNs*v%+N4ep{euGhTVZn9Is)ESa~O*z1TTsuC9%$;{P=q!@bbxe)tm ze;ZOpfU$*yx>-rMV#0!So6AyY?QwK)AofkxiZkeAe{zanHEP8ijpTrpxie%<+QFd| zfBhp^&+pm9jl1O&U7;{BQRqmGBJMyu&aE9yw^DlAVguy+(2|X*iPB-T1gLb!Cr}_>3CC z59Fd|ca_jBtB6zeH}NnlH*rAHDy-hw`(Q1__D8s-_tYY^Hv5ZqlzzsVxXZqmyf8he zf)5sN+QRIi_@HhtItmAwa#IW|ag9tSGMifUVZu>L#9yM^z!M&BqK7*tP5xaCKSkpn zdWJ$u02dp{J@DPM#>PVwEeJ68vK;@0C3ZMsk_V2xav=b(WXt1=JX5oOM; zK}>%d!GrPsigF>6(TT+Er0D z{-9(pmL*`F>UmD_${MiC_XIMCHl$uE{mpR-Oxi?K)9dH*cvz?3S<(toRgnY_H6j0j z0G`#t{3dk!bQed*NU$3g6d)-3MCOHCnw|X zL|05m5-2}{pd$59h~C7qpf_mV#5qa2ibELP_C1&$D|v!WXP%Q~n5ZL8t<1JZP?32} zB5n)fl-~3WyOlsWUiCDCGkuCP;wMXpI|l|WHEx{=$yj?Ai-E?+g_Bmn1387@aRPaF zrYmD@J<0kwtTTBTYp?NwWy*EtFJtZZy#|Y*hb2{k#VqC3}R;M|40-f$;eS7#cZ7;J7#sn{y^;5+y(SJ%%zM6LwS+f zT^J!ayUVh(%@^#n`# z<}P*=AALi-q}jvC-%{0=bJB?&@;WUn0_avicdCv*#*3$?d4QKTrBf1N)&4!Nn`GXi z$0Dt^UxY)ou`@m$U6#z2a8A^Tu88y) zOcDc z7OJjtL8c7HV~6YpI^-lPfh&1+)!84=<65v;^SVb1(q8*np#`a}eI)COW|6N;c7{&2 z15eOapIDA6nLD8PKKgpmH@wA?$yr&bJa))AfF>M*oF@|r76{{qo^H1qYwx8H7$}x9 zum*3ZnD!R|1O@tjplV8J$_nF}f- zM)Fiz$GgaTyx)A-V=^@2JL}I1EodpO7nD^;9+P&2aQJvEDLd~c5SjR{7UR)vjV&_n zO52Ufc8&7VcGD82x^XFhj8l6!1fdt^fhRavF1CxL~5;dbc>dK#AM2u=27cL%x&uYSw9KrRKBtI zQ$H>C4pufM?x2G9W=wJ)6V~98YoZhGW#a`ajmh+pR{WRLx=%c@SMlo5qY_~pdfW{G z%BnmDGu_j{kP&R9slyJUH4{Spt#pu5cim2lhFY=?W8A1EYobz2KGk|wJLz+mtj_XK z4kJ(83WPcmIgV;i8_ZjSdp&Aj7aYx)kJWBA>fIEuRd4&52 z!E(w(1rLj2*YtJNzF!z)<+M>dhg<>CG%uedEb&VVD|@qzQQd8YzT|^8*q6V<8N|sZ zW!)HT^%&5HG+ML)$)=46g(BBn)_Or`65Cyz0ZYb}B#LpKV642DMaG%1$L0o_S48bI zgQZ=|N5>vd@jk3RE7o)Tiu^u#IJUfC&WhZ9XboJIyHF+g``)IF=rWr3?u2?Ht+M;# z3L|>+W$g}3p0#YKbQ_5R*pR)IKt4X6Y1CPruTSNx4Y;^q-R%cwg|7QX@eJqqMdB~y zfR2UNliT ztZkx-R2ox@~1szKn|Ixk+;@JsvbuLRurB|kU z1!^QNkdbvE|N3TOnQ=a3{;T9u>5J+ohe=_2iU}=iO2sGL>-ek9Gwm7oWgI(=rd#tm2U(1N-WZ_OyrSJyNNG`2Z{HNw!Bs&oz z76?B=iR(DCENxl&F5<|=e}r}r#gVl4SD3FM-p&#t7KyutmoPov%d?CyR$X(LNyW#H zNWmxxS;th7Fu$vy6A{NfV$3NN?7l~b&5xB%^y^7Cifk*Sq3kAZT3?MwGcg z=P5m;PTpq5`gH`G%qu^jCyG^1;G42SPK)mUHa&5b?upT9J@M~oi4K@l|Dtcp4;beVKFk{eQyGs4WCuIVp}cF7K#pCf`Xx~TqV_bNi^ zqaN0mE&)L!RaU$pMK&%iA%69dk~Bck(VK5k``DIcVu_62a#o2l4voigi<-^HitY@yVO0FXdn72solWc^$=9Yhy*r1~x(hOb8x6ELUNiSMV|+i8UDC!~LW zjFbjgx36~b@eXp!Kz#fc11Q#$X{>sS=^oON#E$d6U$Fvor2Km&vTcew%0hE~Nk*F9 zJ^Y(n6>0rS5?Z$iBMMo!+#~h2etw@+Kzz>EqaGOS4lJ2@r7{!hUXbcMgDzF}NO)k> zPX6qCF-KCn3Pya2WRG75*BlAU_iZ?#-G&N&ZBjE19esSeM+T~SC6kYeIGHOf%=ofwcSqQQE3e|B@MYI)fYi0z1zRC!lf=Z zt4x1Hl0N?dnm%)3tZQbG${Ej7qsDDhNXLM_S=v9K9&WyJDHwEk7iFugjREN#K6$jR zq9h|Tv49v*zMJ1itlh~^IK6H^Nz?J}A}nS&U&M`qq42~h*H z)@Ir$rtWXUdI#_=nUi-zt@PJeRqsub#`AaP@RL~gNMn`U1wlq*z4}jf>i@?f>;Gdi z>RzDK%xdW0 z4KBXkK}XcTJT=d~M?Bb)WU;+|T3AN5|E1FQjRP$n4D;&sC;gF_O^`aFQoG~8lI$Lq z1a40sO(mtLMcf+D({=^ldP}1=zEU}U&n+JY=lCJ($KiyBX(Qj+a7FuH6l*L~lay@n zQm4;QFt`>Szhi1>Mr7)>-yrn*&68x4Mi?J=!UXaDYd_3TYL*GY{u@w5?bJ-9S1#Mx z)HK@XJIwfPioTf_r3lX+?#nJ7F>81o z_E4!|9eysT%{?Wj`0!aH>kg~RP7UwK>>Re2beWmH%;Jn$!|MF9$>Q?|j!6XWJI+_S z;aFeE>SKIG%kq4u%s<+9LdMbl5})&ZYD7m?XU1NC73s5v`Ld?^i~Tr}%egkaM5#lT zFZ)_Icituc%a8M2-kswMcOCA#aMwuRdF_Y!#x-aA3ho=>`|gI}zR}q19EH*D5%alS z-2WY)zu5V%WK^PLbuIO|mio+A_1QL5eU2KDI4Wc0=MFAXs*Xd2N!@m*)$OoM-(mja zG%l&9{B2bK^|KVdk;!`*Pq{lV2Us*K|IIW!1~|p@!)%{#Ebv~Qr?M6Z0vGfA3qMfh zOY!^w$$z?};rTNX$AKGo&ZnKX0oU=2mFO~?c>V!>{6mC4#Pg+6pHD_kC(rrkYf`D> zc}I=U_c7rcdH(E&KA%|EHSo;5%I7Nt&gFSGKT&XPS;5l1TGC;Q4yRTp2`1^y!m`X z=c}$~lg!WNKC`(?7R_EbZT1ay3+GOq9aZI(oL5{7!uT8d``LAM3m4RKry=z{*(p6x zRTZfy4@|GP?9!Q2L(`^Dttg)!3eKEX5xM05;q6`EqpGgO{~0nsMDzqfrPb=FQ4>K; z6qOOw49w_6CmJhQTEzrIkd#*n6Gh7-G)c|*4Fj}dOYgNUwfZk@ZROrt8((z-NFu%h zzVK0v?~K8Uk7}Sme&4nCIXg3%p!WW6fAjgAIqR&w_C9N`{XBcGz1D!~R9#OVZjMzi zy6^&CmHi&&bPYMl3of4;jZ#h(4BlK5XHagH_!Z-daK&a(54eVNbya&j_i#0HUCT9| zYu`-DgzI6hbzF1Vu&Cu)0z8dtELSeqtCU6y*GdVO`}NcZt~+>+aKDTD2CjR!Hgb&v zm&f%muxx_tg12_Ad$`tcJqKNTzIsmc zc;u4Zyobl>YaMl0F8}2kFJZE2BbV^$!nxlh-&~@pynCv?D(Ar~muD|BSgWt{&BWmv z=qJ3$BKXF0^ z_XuxDxbJt#V_>X*QeDm5nE(2l{bg5u`>M12_{ID#4jBvm#pj%H))~6H z@c2*11MY%_vGY$<9ul2?4xg2aVhb1CeBwcZt73Bp4$^)y2M#-H+`#yOvG@gDSWWkK zuuyRTd9X0ohv6RL3(NGun z69Zk(Qj<=v%|O0Gne!SIQ1O3WC?*QqXmmcZn{C1#HmGU19?l(ud@7Qdh{iB(KD7q8b7SeCK#z40k z=xGBT_H~a(MmL}58$de7wFdf)f&OBkHUqtAp!W?_a*UhaO$J&6q*MOsvCJ>j+fqPf z3L5qeH*~Urt}@Up18o4(oNhL_4g>81(sgL&aURd*D&@IAx&(g#q|5CUAYGfzJKp2D zLeY8rF5PegjQ}cFp(h#KPYv`-16_Oq>l2DD2Bi7>1CY+|uTON#BmX4#9diw2<-52Y z2J)Tka#atc^RX64=i^ZWZ8y*@r?_uhWuQ9^v>8aJ@(hqp<&9Hau5KxCad!e~oJ^dTS_j(uuG*SKGg@p!IMxQ#%XgTEN)VW250^f`lj z#Xy}Vv78SsYYlW4ke1qCd8vNyD+c-+qrJviC9c#8 zfi&(>pz9QWe=|5qOo#e`E?1$G4Q?HsX@$c59!S%TVUW?#$#e!9DmKss2ATv^sp9_5 z;Qq@%_XFv=`}MG!qm5LmsEV%-Qiwv|HXquwi2BhUuK-1B5MFuK2(Dep7o`||8 z{s2hFunn{dNawr-2vNiR@Nc{MI2%aE*lM6FzT?u>0qK<28C*AzmcgiN+_5hZ+-pD@mv@7Udjv@5avPA& z+jaxJ0HkIA29WL;{}lnX=y2RGc{nA|vYqfF>%gFDtheiNE+a0Lb$YeMO+ zob<*SXuJufAv>WZ1`3%_k(1_`YVSZY;%c5HhdNZGts%*)4y`me#O#F5HlcG3uGT;? z6S~;omKdnsgf<#nvw>Ec&@~3P)P-avPm&q`by9~73K%W|@$3XiG$#zNeno77j5+e;_vDaj-;W5+uZa74u522gL*en94{26cm2#l=!X6OAVH?n6a3CW6ZHqB^5^EGH> zr8t}B>xSlZXk-OBo90+Ua}hMMvYbtGoS~TljjT9l)A$X|4Z;tFpG|YBp^*))N(!N` zEdB#beFo-1c~^$S9tY#kpy>uvl7Tsl7_#=A{dPWRoZr`=ku~vb8j8dDoeGVtm1ooN z$>98CA3)a7vuT*#JHJbzk+t<~8clFUta@?Gp(QiaA6bRp>tGz3nKNQD91O|zYn2Yo z^@|ocBpw7ZFwNq=gruG3c(0XLX8g{m?W3X5L9?6xiJ3GD`)E`Ndha7Ss!qROg)qx+ zC|8cRPqqrl-x)I(vKDt^wLD9!$??7>VAk)uPCC-QG}!21gj9n+G8lRr&%Ek+Rkb&} z2?<7iKkrYosM;01$EEpQ293nJKLbO`^A>2933FpTok1h9Ud_Vn&cdjI5@M5%thni) zo3hmRJ`C%W?jv`XrYM6(2rkINlxAVRlZC0y!hAmq^P?=xomrSaWns2uVCK!3JJ%Ux zoLtOWSgmS-@UHW<=z9xgi^Tc4Iej%F9SKZjU}nu-uwbDZ$;iiR1|>J0(OH<&vM?oC zm>CA+p++s@=?v8p@^88278g8YVfC!q*upa^Di+0NRL!ZVnmwoDhPoLGXL@d!Q8T|{ z){L3eo|y}7nm?~*v0f)OPwZ-?!)J~2R4tfSTNkUYP&;1>SqH9|wP0bz?3=G&STob3 zo?|nvpIgl`woAFNdS>1HnKR~B-K@8%JZTHs6|)x3m{(mfpB=Cp*+W~ja0U^b3>=*6 z$ya@A%H+ry#l`2Or?s%cU0%+_%7SzzMlWD{^t%_;+(K@u=hEs8%Zu2{t6orXQ_alS zY){RiikUSx)Wl}Yl}xh`U!9(s!@FnRg82(7=G85#sp{KMtccBCSiNZWg1Ivn>F^sD z)Kpau96qbMDxKWn;0qFTD83-cG&!epOd+O)I0c@DVSzt3H=SgmyX)YVjILS-t5X#f zQo$C*=2XmEPVOHM$P`himRwX>Fo8diMn=G%ve}8TSqRX zN;;7}u?6fJ*TinFn8E$#MbtJWthC%N)R={J^9{`av8ZW`9I6}_R%?;KmJ`>!M4Cv= z{8-wV0?nU%$K2!O)kP{gy1VpW9uKcV_n=>65s{}Pue{_c>J;&XcFM7J?P2d#cl4)c3$3%m7 z6H3ZO-H-QamC1>M^GmB9#~>NWMZDX(?@`)79MIvp`xxtfIkZiht=`19y+QA`!ksJj z^zy2@Bb$=T-%y%NyxwT3;d!nb4~8f#r7GE2U89cV?DB_Ex{Z5-iIV*dJxi=x<*PCr zzmk*Y9aw-k-dYtF1geP5+WSZ}-UGvI{`ucipS-jgH@;=-8 z&!M5h%@-#^BfX-Dy!xjS8UIBq_8x%4W#2EY`h+7$5%1GVXLB;4DsO2@`d)xS-FJt|B`BTBflnLHY;yYgT)^<3F zVl-Xs-=Qd~A_kSsp7;G;EIN>b9YK3Xu=UN{VBwBXVaKFqAL^sQywa*?QBjry@#PWk z_R{#3xuRmLy_`g`F^*j{f`k$aMjV9ja}Z+l8je_@pDuNQqt3AtT)fe03k7y8Sp;Ks z>xtFfz9tlSd07?DvBOH^!CdQpUc0$@6eTqo+blXwT^|zHjvPdBb^o|{NBSv{D3(1! zfz8War;CZ^C6V`VdMn9+f6gd{?LA%V(H5|`QpuXPN)WZKq=7WN_M&k(K=5N6 zAZOVh>%SlM)D2I3iyHR4wdxTFF{BcWFUpPJd4W{oSB+K2QLd82CXcmSCB37BC0r=P zugWcKFSWNv#~g|SXw0HC4Iw1({Ss@{HxY%Hf2n9a0ntmsTANtxibh&thP(2@H zc}hG-#wUjmtUWozLD3~c+a|N1AT~21azh32BK4Mfd1^sSy{R+!3${GOb03=$tm=VV z83aKVcNK_5y`*#o&4S{jecfQu;Gd5AeUWu*6S!FsYd-dBuTKi;OOdvqAEf7F>j;kF zElaiq6(iP&LxgUdH6mCb$Ez?GokyI_5(fjzb6d9tqu7)Ujz_Eon}aRiCy74xge%2a zu;rGFfT#KbmShBsrrdO1(&QHp7QmJl7L^F?;8?&A@jPw8@p6NK+_e7GzOrOxo~#7YkPwueW}NSgwLVSW zS?y*LFQI_T``Qi1u}VDTLsiZ0r?pl#=VI$7#4igauJqbhg#uSiv{wBUwgb;Ezrd;9 zWHrxi367;9OiZnIw3>s~{a1Uj!_+8N?sBdQbw8lf9UxX}Atc7?X8fu=T2rWbPPDY} z1=U+!<(C@zf;~BcVXRGJ7)wrQFRO}jdewfZYa2?~luDzxGEW_*S{n6NREVv zLn$~Xyf<|Oe=veSpbt{wcmvCMHW78CPs@u_lXTtE z?cu{AOxHLyulou2?c`IP`fC;@ol}2t>R>c~aTwdG4Ahu}VF>USztSbz?A^XK81N8} zvEaJ?i>^p`*kxK#D1UKe%#ga_c;Q^JMH1z#o_?1ZKUIEL?AoB-sJi5k{X~=_M~S)l ze%=_3TXOJl9pOPSMx)+e(sd4Rk5*y01}|Wxt(}8l2ntX4aOwT?VXDFRL)zN=sttA> z?Qq8FWHn2Sa3E4r6y^?B%*p$elpOZV{3|%c;c&gsyV2<$bPC1EsJ%IAKch|%-UmsU zRGnJ^Lh+UPyCn`8DRD$+r;0!8x`kTQeTrK@1Lwu2xMjjrQtab2Z07v#Y7S^MYbLGd8P%fb@Fg@-M0|$dfuhmplW|=e$f5wnqb^39L=YO23zJ!TQ%dDY^kX1 z23zK31k}3$jxaL%OIIG162%=v8}nr^CTc%TT_xYrZ^(oqessiMn?H@G;vHr7mWX(N zuI5Pz=?DNd;;fznq>crj3Q$_LJ(5_de1}96$F9LNQAYp6)Q&laFQ!;a1`oLM4rb^UNi>oTyx-_LMDoxB~EPF;z@X8At*B(mu}A5}kEr z!tz&=&WbG}G*d|R;I#MdZj22&URL5q$;r&ORVN}lX9E>ADDom1-Zd-Chm(@<#)xquwiTbn-{pj3{J zKc$RL$+$T)7p?gHMb_$_B895QWNclGziF9icrdiL>RHyCBME&-Sipg!b=?AiOoH2ZF$lblM=&?=186G>%EO!}=_&y$R^ z(~5U=pDX=nd}U)290ThW+bDZmKZ2D+z(%)~HY7)bmgi95{iV^;suCYxWme#^Wrqgi z;}cixZ`iZs#=is~q%b(7sQNJJU_>I3F+;IMPwtq>Q~V-5UHrl6!Rvw*n=>*k{MAC6 z2s6)Kn_ogfM&dE11daLf6)pApMeGRTISwlc4L!M5LnUkZ8+=yN72G9Ch$umc&WvL= z{9@qjZK1A=Chp6Zz1g*ApJ%?6$l( zX#bs*RMUfQniLPIg^-r;5=k8UOw|4`M9q>~6tVx-?eG7AsXEBVMh63Yn8*>wz*1jb zcQCLwRuUo$g-P0=wWdR|#odobaf zJh|Ct$uUjRp>s$vJ&W3V#GrOj*JWaUMcKe;{-;zSW_RVmhAm!sFUBT0`&g*z4WHRHSCzi=aao=kj(=XjG+SHLf7{Bo-x!<{DsH+N!6yv&`@yY^w|p z6}u_TqlS{88bM(%pwo6g>#oh8O`z1T5RF$#!Myc8Rb-JwD91}NZjb_^rW9dcA;gG$ z56>9ri%^~|D(D{N-N}ATwb=N$b#ku_0fZ>4#6@r>H3|O0czPb;KlYv-%6ytOvhP4d0dqO94!iT@>Be89wCHpI3W~;a1F-A`n zxsE}w9wfQy4BERKu0l3!(SJuKV7&obyNFt9rsO&l*V*MJIh9dNau}g5Zd7-j&9HY{ z>SpFzroUEoBWT^eIrWjC*GL`76{&Ca4ISjxEpO_V1cvQRsaF}trSZr{zlV8!3w7Y_U@8 zzLxZr7j5>M(!{YZg#y>m5zQc5@v|os*A+AzIDYx?`1kSxi}I`{*_stoZ*--YKH3=@ zd0Ex5zR44g9kL|JqqSNj+z>c^*~kdu7Y^h1-6!22=1!v01(vJViznHM(YSUcBk%8dBfYq(zTJ}E6zX`r-FnPEX38ypH8Q}=gaEMyNUZPm6+RoOdLruG(*-y5!aHTFo_e64}gDto?bs5(-(}NF4V!YI9Tnp+!TDm9I z0OXFv!3UC{WC4`XsNbLrmT_Ct7Vrdd+Tn1D)OUdc7WR5!wMmeV(Cb zZZtc+WAxU4ypiXtl@(2A-GLG=5 zQ=x|r@f_+s(&L7D4;$(^%sa}HaZ*&_#%AJ14)cukR=MXZ)6R=}JdeVQ{E}R|$Y?Hi z+2`Ivsf_~mFmyb|H5Rag`)6Eg37#p2k83>mJnlay;zZ!}+>c?KzZ}@l{Z_6@;5FP& zK?`RtuxRX@eJCg4fJ?Zaco^Y==YBf-7k2??pI}7GqI&)!&i*Zu^NZOh_Xt1z!kU@> z3or1W?LXxdzxzD#0)O#YqJpS?+3{-FzC8Ux&PZ0UT`93C&4JR_bm|bK?4|fm_iK!L z;4q|qk~G{9MGIm8X3eXz`BCXPrw`@WrI3=m;%Xqi=3lS(g%>H_CFgw@@;**VXrOqS zUv|GZpQKapU+BJNAbL{mD*|&oGMoQwBFV*RnbA##vf~fL{XqQG%v|iB*q7Ies=9gC z@a{9zMzw#8zj*q<`R?QI8oj6Oh=BC2xncTrCq4|`FmU{v7S_b5iBcvRc@pZr-@kf- ztuLmAj-U;W0rYuZ0XjuNZyM-5AZd|4PY(U`X$m@&7OSC&Ksv@`14V#zjJFK#eFJ@H zLO%sMU8VN}TDnffHqa^{oyvb2+?@vcjS0Qa;2tti(uAHsf1x>@%;$}U=F@NJx78Ww z79jn$TMcfNfo?OQ>wq-Re+1I$y=9=I=y`PLSRfsGgMq}%yAJ(@!MzN0hLVAIm>c5+ zAWc_daE}>CPQz*L4>eF8kmmjy2A6N3LKAuhkWRTA=rr}-S;JlEPe3~52MzQn&{#$H zvcbJ!pm$B^CkD6IK)oh(_+f6!BMmefXsk;48$hQk=wcwv)n`CjF4rIK;yg!sJfbV- z^W*{PQ1NLl-IdStB#`F*Sp)3^((ic3;C314BNKYS;07}$=v0OQ>9`h<=1+X;=+Ki4 zRAA^}!*eW`*0R?SRl{?l!XXSNG|xaX>gmwY1}E`!T)zp;H@E@=32!>CR$ynnX6={lk1Xr{*MC* z=3tt=zZV*MCrKw$&zT19{Mxz8{3n}cxJ%-BSD+yS(a>C+-*7}A^QG)`@>~*6Av7|N z%BDHO(1@`ZnO|koj50J;(8##0CWS;$OJ>T=&NT}Opt?N>K6UAle>L*KwOX}T#=TXMV)>&R*U>3S&?D>QfT ze`*>9y;+)03PlFZpMT^G7Hc@!JxU? zPv3Q5UDlH_VoCfWgORsS;^|+}dt5Z9rnYbHhw9kPa~GgKCKI9R0q|No&Yi7S$UJjK z47JDk%)Bb}Wb2ID84F`yMS1aHX(_F*g9WB3cbUmv9~+3!QKXzVW3krk8h}ceJ!s&p zn)x$zprcJ^5?EAGwV)1-IK|pQ0!3s8BS0=F-W?>i3jCtj9R3hlS7(;)!Bh{G(wtV( z%TnO0sL+*7loJO~V?&GWym9`%pVnE^&l=YMt`=MIS!X-sqQxd{3A-sYU)@(_Yq9xh zEk38kC}RHnp2~l_7Mt|J`DtxM-d!?)7F!YcanP-GXk`o#x`DLQW;!&Imi$W4V#97T z+CZV4pbxuQvNyc^yhzmu7Ei{ozBC*yG1-bpTcxt@_fG7U^{M32QBZgzf$eqOYByfn zIxS9a&al<2^nyb1Lr~)gTBS+WxI%&Ur6+iIMAmf=XG2HSbkIy&X0d1;@(xG2t8N!5 zX(M$crQ$o0LvgQ)pImxE2)7Vkk~l=iPcE~p`=@w^hXUK`KG3_2tj+Yg@w4Bbb7d~E z55-p87ruYZ8Y%kpOXMb}E9F~#HCKnqIH_^R8pv-#^9`=RKx0iPfX6+lls$6xRh;9YtK`b2>6;Bnom@X4 zjc#uQsq?y>1JXz+qe9kM7LjP?@835{i*z%8*`u`NvTw%aQoD6SkJ8yA@O2QTjd(Yr zL(DQub;aBnH>0x4p*b|1t7paN?Pj0?Jy#(X*4!{V6Qi`&U6Qoq=nVto`#M8MkM|3D z!@$|&&Q`(F8|vI#f1$_oU6q~e-Z06Bj+_`Af3D82%+vp0==Az|=Y4$#q14L<>J9f1 z@*D-zb-w2BB5sN_#gyU&&weLv|n(@z^o$WHk4 zkL#lmi1#MX96j_S(#|36qHpx&fa5b>l;`(e{)b-!dH(2cfDJcHUy-B7?98N(1VtF z+c6=J1EuXj96hDzfg9C()$RyR+?L&(mcE!iUr)}OTn=9D2ja_ z%CI6Vq?xnVylk%fTZT@Z$#1wHKX<`T|9pqUKcPxn5{6y!s7&R%`Fvxucp=zT5LEsu z<3CcHQgM3}X+`X(iy!Y28N`30(W?ZFS1&bI+FIEt6u-_FN&KXM5E0TP4auPmn}>$t z7v~l~jw%0!x7dN}h*mw3srZm4l}m9zhXQsm&>pd_=wLVw2A-&Uf6kD_Xn-etKM?gs z6r1{F`@g`i=r$*Q=(2n^yHCP%{PJAvbvL{zJd0Ae@QnN}Pxx}9-j2ZbQtOJO>{A7v zt?PEUl8Hq>9pLfoX|6|Rve~wQyX?1bCZFIqc6`?)ZVg6wB5H~`YR^$O$`3Ot2x zUfHew#im?LR8IiMwoUWd+?B3dVHXn)YvoBiPfCOiBLYg2NAeW5Q@Y^r7~qtZ_8YdJ z#8n_f?9!URQ2tnUwVHcCN19$ir;$!X6>-Qu+isB0Y5aQm>^Zp&dPi0{Ny!5(Q<1iY zd?2w}R$BE!@dqLM_fj8lUnsSst>IYyn@5tV{hnL{C`cTN+3U3@plBt}80h>Rx+P z#pTTmS z%f6W|ad(Fgi8kF&h5~;QN!2|KUN;eCww1_=d(zQ~EPp6tb{SaYvsOJrbawQ|O6%zq z=iy^H%%FQMY2{;G4j{b?()6Nrxlje)P{WY!=(8Eihe-pCvzJsY;U4c0vT(f z`D5{w)PZY39;S%nKYn!AAK2Qq4*>9+)C{y9nUqK0T``RHWM4*Uuo4gts^RjAQH6_ z&z_3rWs~%Wk~lY&XT07|BQ%J@O_ALs6MHAO-j3%zaYtGVX5qo+qjGd!6QerII$@2wWn!`xco5X{C-e57_ z0fXbw6P4$~r2i;o<`roR%j{ahil{kKC-Bx;z~gM*ChSG+d8j>y{iT#b9)O6WL&a!X zC%1%_5l2ge0;ZbkLB~~rMD6K_+Z(l~R@zO?prhij)SliRi7zdW#={ZZ#LO1;ZGt>X zB||j#isUJ-ik@z^ARb;U>Q0fk_zq1dmg3=_2rj$nF?u_QFkT{nn5H!`q?=I+LnyKd zP!LxYRuaL_D~D*Nj#n?_fs+k;-S>yAGn3X2dwi1ABa*cTWHp?2ar=7ipG1D&MiEfa zQ8V+1G!sol+@kS$<;C<&KlcmHb|i>{nNoWc(Hnyiblz)vWVZlfe<|?rp@21 zyd$iUppwp?^tE{r z)PItAbG`Nj7@#vAkxOe5k5io#ZDlk*y#yjD=GuPUd0I*XgQ_pji$o(0T|Oq+*NVGX zc|oLHyMi$3?Ee)bDc@g4o=*h7f#hie6UIQ7*Ww+8Ob_ zt_z?cyx2=3iz5x;Y2+hZJB7az`Kz2lJDnYw5cXS5gVBP+cjiti4M#Aiw_ACKSb0B^ zGGmd8f@!rjZAvegU?>_$>4ND*)_A&Y66GA-nMt9@nj8&G`hCV7Y3kQ;m_@nV`cA9j z>|+}HZsLAyeY^c*>pOUv`t~zc!FFrw4%&Zsqezr`VNJu=ddcq6z+-hE$k$fbgGv^9 z`O{i?8WFxHI$>T{-81^V%)sj0z9Es=2=ir$Q7drP5@$irYJ7>8h44}wsoI9pdsQ;R z3DUAT$tjUJk57r@lnj8LxLtuy@5iQP;BfAN<9TmH$mMA#uL(} zaW2d0OBp49#1mz-gF{f1*>-$VTR&V6&Xzd{%^mMw;`vKFfJM9?NnN+{@a{#!6-Qr_ zXsg@nKETcOoBKB8#JU}Rw4F4h8 zyoE{8J*S`3turM`YogWo6g@I+t*pFwhpyE0sCurIdLK!kG5+I7;`_PMy|#(8s5#8M z+9pR8T)3y9b&8&TrNYekXehhO>~~7-Pa}y6+Q0$nD70K)cnB9`y{P=P`MT!dXaMFf zMuq*gx;LWsyJhyS)GEGWXi{z>@w(bbqD8fp#5!4l>FY{JYbe*@l9r^|b&O8$64HC! zq<5i8Zvdv&2rpbiTz=?-o`D@TL+W}4cLxNj2gM@~Ka>*f`m+idp&lZEPwHA6#-B%? z%yFK*TSoKL2>e5sA(yynXig1+86175K7msuY$@kp%LwXGpZ=Zi<8D(qdd+RG`)1RQ#g{)oRrjDL%W*db9u-$9Cps$uDBO;h!_Y;E%ZAE#%o{y? zA6WNp3l?@T(ZFq|SGsiOr2cDUb)2Kl3Y7UWFXNo#kqvviv1$&B$F5O<%w6G|Bf@o6 z4SRBzU)S{bviERnM;2s^QZqX$aYsBiUd~w+zLL61nBxiyV}msvG70f+;=p!}RklgZ zgmwK;GXcf7Ap6dst=KDD2eERROkKTvtvlZ&-;*?LT$#!XpkO8iM2154rov61Z=%=j z<={8}bWpd8BMb41F_&lkKH?VdNL_25$*&w>s3#>6qbPDOHq#6+1t4m<`@8PM11LO3 zGIt|5Q|X=(Y9!qHh_i2;(tja} z61L>WS%`RtMu(DV)9pg0hN)kJ0m5mA><+YekY}(>X&40CygYbj1?@o(lCNG$tLc^4 z5c{?~&{m!>lm|oV%N>nec{9^OnBx$HaKnS}i2klHz#%G#I)TFRL(Ne(bx!!Y)Gts& z*K)-9dEFxT$z48=51~ln0;vQz=)+efgNzrQ?V~x+b#!hhenPy|7q(9d+J~rf9Alu9 za~wg*VKR0&MgoG(!#0qw%XAJ0TdQBBYBR`?Ta_V}V^VT(K?GmcLV+BttsG_>@nk&ZTEv6NwKi zwTJQT5nnP4xApRuxRlfUFGgt5k-}%Z{Oy&MJ-q}-3m$a{;E%);HxDzk8 z$DI&pTln|XorLlJu$@DFL4-_o4!3?NJkWd?+>Z2!IP4A%$)+C21>O3-kAtKC zU=RZ_9E21wnEe`)2Zq(cSs|aR^ z%#qXuoj4$UVwP328N{0ezQi-rnVOxzZQwSk@St^II|z_7w#>3#c?yD8c_wnrTi}SS z@Q=;1ZfIwYr@p)rAv?*3BS)$%t8s&jPU~>9MWHaTQZYZ@5uzVr$!^t3k=C2u*jP7A zVQ*A&3eleW2Awoe(aPsOr)W5Bas+Xfcc)~fM7N8+Hi>9F!L5TlsT=7332YPu<)6c3 zKrPv(rV^kQ)%qH#ZkW17_G^5bOFc}I z&S5z_Y5ir&paXOZ{HMfqkhCA?%e^hpc$6+yr8B!snH9QnKDgFS>!7IC=HsHq&l(yHS>?ZCOO4b8XCD4~hCv z>-r|nfq6~ygMz7Qm%fLPRylIs_z19*9^XNJPQ$9ttMNjUm6&{2Nl)G7A;pM%W;G@4 z&2>{Fta)ve7_oEY%>vqdN4C?6E6WFJJnJJm+$CyL%c~ePNh3Aor`TlS<4$)y(7fd7 zNL;_q9@hQNcV$%0e^6rD1N-nqh$T_ zS!<@Yj4q(gI43(ug_?EAs>SqN z&5HdJ!L<+mhG2yV#?{8oiapAotSo#GzF2u{O%LarlKEZP-1zPCYMkfFg1;iLSsbx9 zQ~s;oNe#X|82lZwT@P<(^ze>Aj6BtF_lsz{L3%e05B2RqyovU@nL zmZZnRG{kOMk_mr0DKU|T{j<-PPJ2*nLSp)-tRpc_=2`SC&nA3FwRYyPZW>&%U$$1P z#%subuwg%tC`^ag;#sl3;(d|ZfAPjJleASK0x&hfdHtH?4YOv=QWko8PAcrco=|)` zmJ)V|2k(1d$1IVzBixu|{l3Ev@3Y$rcXDd;XP@`Rr+yl;t3M6Jt9ycU=&fDH2ffJ_ z|K+@Sdk-T_h`la$0Ga?I65@1fze*;`xR8{GQtA+5c+tp{?uPjbhnNr_QIieF*w z9C<(|aephZ~_-{i9=?IW)k`>1;-UWPyPx>-i@#qt&;AG>ODA~esF7%p|0 zw1+LIFsbOx>HmOzuL*_W%Et^OT}sPg(+uE#ScJngJ# zd}(g+XVG}|cnUlo&I=`G55?3-PxrLGp+6oTm6$fP;jNxX)hki&Kf`g~{SV$rQ+2m+ zf^>XR1JxY;MJkb6LHa9GDp>}UPgHMUx3#Jcl2ZG1`|}_tJad=-wzTSRq}IK0O=6{2 z98WQ4vF?4Qwe$Gap5sq0K_$l9x?+#2W~+WC!+i^FRCr(gTb^k4wK66*l@A45oJ_Y_ zKtnG1Dv}yNM5&$3V&c>HB}N_Z6l`iAZ`1RdFrO~c-Y5cOkRJ$-UV4-iLqpeD@;Pa5 zqKX%GgcGBF5Ufe69MQ5?o-!CY)Fh`JLk{Kna-PFAN!gbS^PD(N!dtFDS`UiL8za=f zl6)K$%5k`0i?}y^P~2S;D&ka~f$o+bygxmX$QxO&UaKc#dcGyyU?HkA5f=7ouT;%x zy4dNxq|BvWz(mxpnlO=KJ7MzVO7unp!AHavqu>|IVn7o z?GYA3Ll_akT|NC5>xbc@-VRm}BYh+xR-_||OMNP`oSJ4h2^Tq$LiW_$-VUl9RJodu z$hxSv$LSeidA!xQgV(Z8N(cA{spX;g^tsfYaN$1+chfs1rg-*;5|Q4*?Nmg}c+ni5 zVvAuP)*lMD1t0kPH>8FRvqIZ&IUKSse>}*NVi2h{iefF>5uB)Au}3z;gVr6b;Y8GX zAl~HXHDS9G`7`gRgkSayFe?zx5&i`%C*vjYCK-r$Vj3YU|4jJeO?kqhnh`QVV^wIh zNQ0xhN{BRJnWa3#n-SME*5?%(T+^Sykw-y?5{r9T2nM@0S=fgGV-5T+}$f|9L+W7q=b&cvl z4$8E#w9I{NY$4Gg6q9P*!fqq0epB=+&uPU;DiO-RDx9YcCQy=6gzh-kBp-My9+Dwd zhI*^<6rK~lM~TM1Pg6blO89P7gXRjp&HUDgy9Eb0JI@;7EY>e50pGM<<0II)QLD7j zl(P(RdlG!Sq}*1!IO(r?hK1IJK6+q3J!S z=xVN^P7r;cH_1*?uGBNtInpDvGK{haN_SRPlN1-&rLA55U|~|F#EbUPiRGr&lZ@_P z(wRFB?7`*WA;0xbevw0BjfTsS!|m%eJPkSgT;}`8p%OXVodh;=fbSn;$Usj=M6v}) z^EpjjLW)gqYdAuRiBC0LPKvktC@ILNk>by#)KrSnDW-mqqvEY-e}tWmF{nPjX{%ltOjBEIJ`dco8`Scwg`lx5-n2HqX7gDqmAJ~Q4UY2jh- z9y6dWhE+Y4IUWcb5^6Nu2<(iGKV$w7{JfVF`9ER&4t}=D8#_LdxP6?&vxl%4ZLxgB zqD0#Fu+3NXrd&pGS*rdxHJD(fr$aLszoMnDZjWX0GhQz$EmZHtrS?a(N;*k)A_2J1 zl~o$Rl6cU^t%&t#wYLO}v9K_)rj~a-)5-DZH(8498=u4W?$j!z@5%!;sjilnDFOSV z2z~YRR|MRex)J}^im$$rv;vN(h>WB=>V_$?G_7U*mhm)o0SO|TWuF9VTE)S7>Qz&| zbhS0l2+kR+MR~Drto{Jr`rfdgr|x^5@lz&>labDx)`@ERQuCrXl=QYsTk&?JZs84V zQo;?r-(sqeTWU=+ct{;Z%%HY|C~byLA4)Wa-Xs&LQXy~QuQ$bXN%v1IBiE}c*szO% zjS&vPBg^qjSL%V?@5xeZwIq)G%}%65*BK#1?3|Q1_o6%iDS@s(e+&iR&6`;-{qpqQ zJ6L+7-Sn#erSyLDVn6<Rzg~Z&VHVyR%|or$%C@kM5WVO zc8ItaX0XZDDleTxF!M)peKI z!{`-`qJgbhvAQ;$n*NXizEscd%>_2pju)mu&Y(ydRAhC#aj^e_XFJ^Kc&y-0@ zUg*3R%{#bUbt1{aV3-;~$-`gJ{zvyYjK}HaOrNJxQ)8+07fX1tl(;VC*LZSEx!oz{ zC*A%rh@5P-RzQwIP&)4~i;txaeqc48hJ=G{VPVa~!U3Nds}hh2Q%+S>Tz%F(N!Ocx z_3u$#{|swy&>^Wh2z$ZeFhwI3aY7o4kCdv)+uf?tb0325p}P-Pc5$2i^w8XV`RmKDQ`hn(Q~qsDr#`7YtlHC zQ09zT3_;fx9maZR)dxc12FoTyK`Mu8pr|=bvI~o`M@rISW2{_Ko>mqgBo76mEd@q) zr+m!KT(vLid_iQ)h`Pa4xA9spnBkyN((tG{H;}Op2SxO!cZyw_L+Ty{UQL|Am^o~gP6tC@x-04~jA&z)TPk%n)(54e|zUIvtJfbMC(yBc? zIlN<7XRh`JFYme2d0+bdaO{>W>}R*cT7TRf6Dho%_4MvR|^B zyX-N^NL9~$1-*Md@EY!8`Fx)UT*Q6S2)Zrca_&j4T41^Vj$^Tlfj4r0i>n@ZH}^Y^ zM!gq!J$Lb~dJk|B_f;HQl`&-vcbkc(EcdSA-pM63O72AknqRrsbM=6idpTz@r10gw zj%yU~dhT~~NynRQw?rN0n^!kCR)aI*>KVppzgRKx3}70>*uOXppBmr8i^46+IeMS} z1<&tt@lMa-qyOf9KUW9v1}+~xY)>IFu2XMvOLS>@eX3V)M_c}*{2awugo)3IC)lt5dCZzuB!*AN3Oq#{%)iPA|1fvbvD_+-mkOivOQsE(|i$Ai|Q6GZYLzpi+hVzJcVpgvO0A(4kanjVmzFB%n)G+&=?drl9RWQ3bsVRHmR@8W?NtymuLp zPUSA3D-_O0bJMs&AdPz#s9c3!M1Ovzf_@LAd42-un+o@W!8OxLLkjmO&{Ya5rk{%_ z=t=`EG0@L|bb8V^YG|8*st$2+iwx9YpxX>|;xN}s=WRe!)!Xg_x>`Y}9P0WUo(?ok z;r0Q2TS2c4cjGQT%=I8#52Sgyl-^nAXf2S&{ilJF25K|Vn+Ez1W{X;Ce=^VzMm?R% z;Rf;pU88s@Hn{Nyx&Y`}6?!ue-54dc+s&_LH2XgN@&im?qy$K4H7s&I!L z>q29Ibm-MUx~}ay&V_=07pgMQjRx8dq-*wDK)PlZoXBYG5xHChbiIPU3sj|`4L~y$ z^aN0~g5CzYTtO`-xo>;XKqut8xI<2MbAAbs=Cr~<%MH}XyyF^`-cNzT3VILdVg;R7 z;QFlp5s;STLqJ+CFB)iNq5F=lK)SSKR-j|#jB%lPK$@3a<_a2GYM^hN?$)}A2D%JL z^Y<+vo!)W--DW~hHz|vEZe0(^f*N%732PaCbJRTWd)G;V% zht6+85wn9s1P(+toVY~>Cn@MuWCEbyJJH}u3?%u|p%H_N8c5oK4xM6f(iiBIuQj2S z1~=0{vrXt+gR3=A%!DpBxFrUvH=&IN*KDAbCUlL#tu@d(6MBcitvAqJCUk?r-D9AQ zCiFgoYcbHnCNybqTMX20LZ39a4g)=BLU$V6s|M;cp>G>pmw|Sf(A@_2seyV-=u8La z^UOBTTm#h_C}yC=26AQZ^VAz$qk)kQ<|-sf3waCaGKgMscb&_)B@ zXP_1X0eEI{G1T+B!9bEzjjJ(G6OjD89=}KFL+VHV2N`HRP$gG3jSsB;J;Y5Uo=wBx z?fhQgUIGKzG`TK``ofm4zHAzXTj%#r?)9*jO*33k;Dqd|x_SC5F`LG6lTkJyk~|5A zISMQ4mGV6)v7FBgh4*+E@|@oV(8y2Tbuhb8*FhuSqS@)FwFniz9vb;Z&88Wx!sYMx z(D=FfIDCjF`I^m+^>q_V4i?B)ZZ^%ahDM54zJ9Z57$}{e9~${8&ZhCZB%V@e33ss1N4@&%rq&S@&H{K?j*{G_%VtaM}#Siat~(>X)M zmA~CiI){8gI)_nd5uw;|32Fr zeri+ZFWdIUJ_s^CbO*4Im4`%+%1nqSIY}u$8Xe`u^q}7_-Lix95;Gb znt`biN2zDK^+Zz6w)3UDqRtX-<>$WrI~nN^YXFbAb2Dfp_R=iOkFqfGMW=uAt{L-c z&x+!SvJWE;Ap3qUKg!axLc5qJGSZQt7cwwk#gDAS+LaMYFbA?QBL)jze(u{(&B9!g zg_)g&S#B^MrzRw_Fk=1G{plJdwol#PAkPClr5T8EK-WiWruW!`1maf{-=hZ)>>reF z!b|&_R11$)3uM)~u(v9zDNe#?lvi`Y*3&18V6M<}F#IH!fIsJ#Iwj z&8V%dnSaBg?5J*xK9h4_nDD@4ZX%8|-7Fzx@pmvnosjECH!B?-++T!lg%e+`2Z{29 zbcE4%QF+d-xN%0sTo%R;##Wkp-RzhDi>`M2H$Rca05~NH zZ8Oi6o6bjut6r5Uw^aMeJl%Qi|Av*RjMs>#VQd@H9pi-e20T$$VOCMQ(skP83_Rj$ zL_b%)RdZt%^JZ3I`EbF)oA6~_QRQ(xPG3JmEx}eVyrHIgQHHI*{;qs8m~odf2XG9X zMwd}hGd%594d_5uQZ*YOx>97s>gQlry(xo06-8#bxlg(iOMA-5tntq~{%mc#(64^k zu*SHS4bMAg{CS#EYML-|UL=CP+VN|AV&h-lyzBV>N&g95`ixJ)N{~r= z7Tv(MymFD<)y^c7Xxfjj7?~O`x-0lv{U-WK%R z!n#C^v+Z^@F+*_4&g}j6V{oBttYjc#K6E{fx0;_Cb}(>ztc_r zj^Jo?uy{vW`lx)7Mbg9!m&)MZugS3Sm7c%YcO~d%Xi_T!W{IT~X z`7jx%d*jQ?fo$lM(wI&IzqEW%BKS9*Ar=)NvrgH0=~U`!H?_Y*}Om5wBSDcs_f5R6A1ImRE8dI;KE zEflnTOdS6h|t%UPp_?JtHP(sVl{6*KAa- zq*0@T*&3@>-mQ2ddQ_c43!^m%NS7qKfuf!hX;_lK7$r@Rv7w>|w%8tovei%}UiT;+ zN2gVL)WqVyQx03J({|mjILK!mq&mN~Rnuqa^l>EZ95RG9BgYr9ZWM_hF1!?|GVUu{ zd#h8jVzo!geMRpx3=!7qXNbF^SB8sa6^Yf8Q9{$kXr6uZ^G#m%_^hT&q5gc6d}vus zAE}t4)MP1dj*hyU=74OXXLFR+@uDgeWvIKA5dBRNeLU<7CTDU{7bhwp;aCh%!G zeAhV#OsHx~mkFsW?r~Ino7Q8hMrlgQF=Fow1*&tGKN+cd9qR55sW&Tn2Qi{qO%-Sj zh2kUWpseO~Fdhm_CE6;WX#5(q;LsOc!2{v3V6dTeC^}szVT+YLj03!jl|5`Ve&$x< zwEhJX5=BvOyRo^4AMm>FQacA7sE;W7H7k05rv$VG2E|46{uO(FD~P72rDnLQ(5|p@ z6+by*jE56I=|Yp%jXoUQj!~*;&+%wf(aKOoTb2kWu2h126UDd}y2HADB+}$3DpC0l zN`dtWks^f=DqFq?Q){7#Kqn$ESD+7-R=p+yeU$gdrz%~%ZxAeE3dP4zb|R#({dD)Z zS!>eY7q+`pPMq?xnxsATmDx)o0jJE+#Z#|%QY($!521?nm36#6Ma}H1=y>(BDHcKC z?rFzR{Lka%F|N*siQzDL68EKtiSV%q<}b6K4B5&AkhT~mtMc?rPOldhFX>?)RVopV zsKa>@a=wVleoS=lL>-+Y4;;zGV}0`YrtT~Ij>9E}mxKc!g{{j!LI?lcu>JV}Cjxa} zt&bR1%5e!LBW>9te5`s1aU=z{?N(9vY&vcbwL~l0CK{xrqe@9Uj(j8=c6*ySd>9` z;{c~4=&63x;tN#nWH1VkqUj$VshS#%c&GXcZx_atn4mdLpGh4@JE9EVrZ<(#NeUm%>Wkh-a~Y(G(w z?csDqJE)A)6|>e@&1)qXZBh;rdU_`)nOm#hf>LVGD(Mt4P!?BWjwYsFVwdd-+4Dw4 z6Id=w%;Svj=pd##l-V>n?o*eC(a?xpmutTev6o^=k;A+_5%G;&y_=o_6KDn9gOL}< z>jTxuel8~)IAo0}9Y@7p4yDRN2^@&ma`jLo4^gCBAVGfhOhqEzOI>>vB0>MaMF|hXa+n<7KN=pzTLd2k)0-M2DEPJClFsu{o_bS9^B=?-rX}~5r(tdYV>wQPc`=nDqG``^MuB-} zGjUYAYs{~g=&rz}@kJ7YFa87@s%U9TzNM}DyS8v{h7oBR-$fapB3~`N7o7A+&Pgx! z|0}&A9BB~#`qIlkNP24A{34xRrqf=km+|N4J`Wz`87#WD!-sl?Ps%MCS~;Y4aJerz zs1sUO4_EYIzj+XS*kcZDIwohNw{Rf6-h=7aN_P0&rf;70++!3$F86xwXVabJ0he&! z$Tb>R?g!|4{lF|cc=mFQ6L>JYX>7Yp1eALLiAaxJ#QjvpvuOfz{~lKiSag&)t^1v9G`9!$rzKrP&)ujn}uDp=zxSQH!UA6Il%V64BO z;FOCO75c}x!0CP+lck)q&pNG8g%D+r=XE+cmD!&=TG40IIG1QmJ=kmfH%E_L2cINX&LB#!4Y#OFXT>i96@ z6%J1NqGP#ols^}#09t2D85+q~J@KAs`VlLii~sF=^UZr%HJi> z$oQ8{Gs@7+5*k`ZHjT6J4COLtWW3C#8Ew*$H3k_+vuXM?dG3cs#@B2bolN>30;J?! zM&hyN9RmFOG()6zX8y7@L+*g!)C?lMBdAeQqjJ0(Gs4{mSI>hYgJz72Huv;>0;zR5 z-U~$pS-&?OtkezN685FhY0M$|28AqK$?y0q%$XUOud1IA%!nl^%TY@GbKg~!g}E^c zb88mnXIU6&ZJ8-Qk%bWx+?h1uG1mPJ^6cTs*|WXnmRnrV-3cLpZ*!0Yfd$4E& z?Xqj0(7+26g^83JV9=J)*jJ=eAKo+W+5m*TEufc@S32nijDUZFGmoX)eN>$ksK z555Pw^xwhucNg~!iHdxn{au;cZ^)t>cz-vF?9NBV@*|qs-*qg=Htec&SGHJ?wVw}( zXBvDt43*W1Y0CbWwQ3TPtosMWuaqU~#;@~qmUm)~b*sz`WRWS=B`GWKBdd-A7pa=; zON<(C-FsUXPb>B&`GT-k?}H|i81>k0aM46G|65@e3e93eVr7GJ50Pk`+$>2Dpr&TB z&k|vibEMk$i6k0Fj%IO2-WnC(3@N>eBJXD_ttwJA$Jg9ArijT=#Qr<>Vq=j|;s)#m zul#)sCW?1lp77l*#EIAidLf*kF~yLV*`2Og>cqK&^J#>AxV9iD zkj{S9u7U@IM#+|~vDnbI5*5z-3O27v32EAX2}_u%0^Z_orC`z|V?Vjy`ut8}%lf=b zMywHaTubw?8_Y_hHKL5|E+>yamu3)(-`yePRhw9&UoVi2-N~cHlSlE6s9n|}D}~=+ zdMYM56QdLm^jRy10fkwSx5GPwg_|PYWLsF0BYGJB5_fo|ob__5EU7<4`QXoiHNxF2 z>g|l!uVHLT-WIms3(KNs=Kg`PDil_88z)GyD{*#wi*^N1c z{mVN_tFXAV69X#|`x9#6H!;xCB3%l0|GRj89*{SE9I?V;GB(3aNHh`4FJjTtTJ?XJ zRfS}E`{k(p5zKb7OI3m`qAvW%Tlx^)mMmlU`+o$1<8jyum;R7 z%fjc5k9e`?Tl)b>(Z|4sQV~{s_me~H0Wa7}1pb0nb*r}Zy8uJL-+Rka7J!w7;8*s{ zvR;33meu_X1P2Iw&mztY>;%_*PP_xqD?7pHd%ObtI<|tlN$UXn#|JvZ8%u(CaIk1+ zu3+2;3>nMAGOU0lKWiNtJI~&RzcCa=`mO&WS>IJO>RA{36SHW)6uX7qU?Sx85-+fQ zIhAGGWb4r-mmLVCR7W!QQ;^1?7%kcsOf1PA?O-#jEgi4gDsUqQOkBR0JlzO)E4d}q9>kc;a}_)Hc#olB34huhgO=~OBJ3=f6vxUJ}jCQ2ORIZa971T7NT$ri?&9s`J0X+Jpea7U>( z84YZ<8vY2nv~Z{RveMcisXM@sHLW$GTAN!EnB7aJZl)Y?aUEH?pRs$!gu5tnoYWP1?Qmys z#U4?mu~r>LcsQ|?41J&&gXgluC0?ayL)W+=YK4+?lN;fxMZHVr9v@(^t%Z;tdYp}P zJ{vAggeG_|Nz6TrA$^)R)Lg*MRFWLamRjR@a<4t~=nhGSL+MTkPpzO5#Fw@wmJFps z)2*dWIwf6w8anq;XBd#;uJCW)nyaJ4@DI zMy5O%N};tzFFU}{A-R;nfRR&A2As^bV0JNHmZa{amJpFDqAG@X>G6l{H<%C=NP)!b zy2`M{^RW_AV5`-5x4fn0JTlm~StXQ;!1V95pvwl$!mdoCpHh})gty8SdugM;OnTdL zmbOpOVp)A1!H!tql1;g(osM`K+Oa9~ajcJJtBPPMA(2Zq?5I1S?KE%WPya z3mi-T0gPUj7(xzrTy_# zFYQY0(L5j`PwFOy)INUNb&ssLQY^um%T?iCDF>G+5qi63>K=J1OmxZHnDhkTEH*os zu=n$VV9O!CK`XYWN^zwYX-%CH^gjN8bmvYQfMC~+1}J%bgcAV>uw;GA&66j z5M<7Woc+&wvVjmvxT$Y~@dAJ-a_$9kR~xCvVE9gLr0VfV;)f&v$KXDuKgBz~G?}iQ z80=F?z{*su0H!_UCF#6eZI)>K?)5y{D2=JlIrIzJna7}gBu?{alG8~{dS0gSdl1e&cl+y0MIgKvyR%y?6&*?iI|H#lIl&wV*nLJNM&(Z^*{{b}*vJ zgPnQNhUp-)DvM-q8!69pb6?r@-H((3`KDE%L=SMLD7}Wm_I5te^2_O!a4m$v#%uX5 zdrD%7RVpnrv0qg#R2_8b>KiX11S&6*(zR~U7w^C(mnaf~P&G;9Udcmdb-I>)`6rd+ zmz^2dykv}#{#unC5&sc5N|4MV{>@rv@@4h9U`u00M_lgmflM>@_g(Ao$`=2MT|1#O z{-<)3;J~2Cg9wXAvDAJFzp)}gi>DA1XQlIOt(Fh^AYOB~E-!)-z z8@D9?+6bR7m1{)Lg&KiM&c`U`hyWtcyGl&1fYLs@(cI}PaUpF=E7X|Vr zc+^$T;nc5?PK>VW=_{Pt*PRfJymnmxD^vj>=XxRUCts|2^{be2P_3BH807kb$W<23 zm1fR1k*IRj`!pjAE=#GUd}`$XC#gy&AQG2VhuMOZg?f3Ym524t!(w^ZO^uautQRED zwbC?rZsu7^wL>^UO=p_ZU&TqJ_>~F&m%aCni{jb>$7dVtE@hUEg0SkUV4+yS7FHpG z!Dy`53)VclqQ)4-AnFFOM2#jfi6*8OHJaF!q6k*N0tzBZ?@NcJ?Dw3xyTdLEO5X3i z_xt|wz5AJ+Gv}UrZacTkopaCKB3lUAXX}SP91B)OQNWjCG+-nc)K{^CXrsnZ#RSk& zh=97aLk7wajAM!g8td*G@aPMr$fOxKf;n?I5}+c#yo5Boli&}h1Yx-FlC7Tpa5_*Z zpyY}&rcL39LfRDiU^bv7UPYv4g+!YqIF=18)`Qj-AV@32$l!r#Wq3vo(*fl#gTswL zs66DH8~C1ig(;Ht0sg^7n&QVIE&k`j_shDn+`HQV(g*o(wpAgj9!|`VZ=_Xx7j}R<54E@O4@p>=L{I z)$2I4A4<2t=E66i)^WG?pSyPc$|Z|lOQrb^z}UrG_gYHWYW7d!I2R`)+KtyK*-=qlLQ-Fx3hzzG74BaGvh|b7ufQWv`CRm{%Atu98;&6uUFhC>) zi7fP$P>b6IA7Do?bTI%C>q993^=5DxER+S%TMW(zmR(7RuUSY6IxQsZUo5l}S~JO! z9~2fz)kgr4)E@$fr2Y)+YY%OkgqRHw2@wwvNqsg8xkI}rzLNkVA?5)@(!7L)mIFl6 z{24$bgquWLN7DczxJLjHE;TH!$U;l!1-%MM!FU$B1`r8b1rP}v4fTe@5g#tn{}J0s zQg|Y^lX@}`6bnUg5Sa$D28j4#A4+^3SqPtrFZQJP#AEtV0`-Mw;)^LFI7}~rFh#@{ zrGemlSjZP3;_J`i0$FGR>pPjn1+ma9)^{F@TgXD#ZxhbTS=?$CTF?4!VsTqoD3tZx z!Q!yLm%_zkG#Ux#{VeVf3mpZB_#R_%$64qk>-!Un`;CQ8v%UaOK5%gmU}_rg97ut_ z4i6vEjC)6Ohh_paLii_P5eeG{}=U+zoZSO&VyB z8iyMdquQh~)sj$20O1&I7#B1(jmv|3W}7q;EeT}|{y3%^@`1*oaW3GGW4$4bwUz|d z_sAT;kj9p!nE^C7H!!5JXK7X;4P?QPrbUMb=QKE%Fr;y0`5XWmoMRZ$II%Rp0}akS z3~307p)L^4Qn1HpasNGCp!k+ZG?1Y#&^hqwr$=X~3xuuI(A|N&qk~}uWWEmr>;4() z0-ex=(4_(!BN{BW9nw}%nzW8!nplihKL-uZ6NeZ>8!YID%i_doL*a{l3#Oy`Li6=Q zwQ0F!YipVnt2Qjsi3>rrl|-C2JwF;?ZW&&#kBFI; z6O68!Ps_BrT|W|1uNsL73thb&q5@;6|D)lrH=))`*K0I!G-^0=S1(#Smzj#UqY0#m z1Kxxdty~AMKI`TNgY94aGScy6Vx%-SMsUuommVF$C5u)rT04LJB4YJo@%;7k^>vX* zp56OGsg* z7X-cRY6H5qwSMAZ9Lvzei=DHNWP*?Tz=66^Fe?SAK$qW|ZiuG=T|UrNw5Id)#Md6y zK~||cpldXs8`7U80s!gI>Vy%<06I2({rh_m4EaYowCo`a6TGI_4V8HJupnKnd&Pz{VFnt$*ZhdRIA*{hQCYeYV4|H1% z=!O{7TPo1)ZcXQ9SkC!C_o)HhV1xYD0o}3Iblx8L8qYdRpAh<%9}MUQ>P`KS&K~Ga z8_;=qv&;ZMIv1e3)QS$wCj`QVNjN(bmOwr5a9_B4;b#mFv}*th^3eszN2|5<-=@c} zG-1UaiaTK{UdYf$IXU5eqeng_Lh(!y4POdfK_R6nq)S+x(MEgC7iBnOJ(&NoKPOS0 zNmi>Fs}X2=Kx5YT(pR5if57U;3;{1 zlT=B>aE2lV2!FtW>WelZTwvdIvI*ebV+TmaHwRZKK#MwIjf)C-N<7>hfpjOlC z{D*?gn;SttO35hrCZ1BUx=G9la)f6%~aw@33j29-U;8awZq5|(z;M7HM)BAD( z3XGgA^O1)&!+G^NP=|4wC)b0b8JtqT3QntMPp;2{ zXWf(QLC38532LN85u$&1a=lMY9KBwqs0_uv;dWV@+sxX3SjmSVD4d0U_Ak$!|0va<|hf470kmg)~dMRzl0Tf1uzTHq59m?PKmiRDC`|(-5HoiZhk}-iVDfDZbaYWs zc{9kwQ9y&3;8E{z2H8TH6!g6q0Rg}L7z7?%@_z?q7)2bIo#=%psvifu=2&+G6);|= zf_{My>0X$x#=ylhjY%9EAml+&3Jd}`BL}uD8|LKqLAs%(LRa8_ zbX=q<);A+G`>w1{8z?Kx-Y;-240P9tcW>mb=%>K|ec+ zMSCX;6e*$OFCnQyfl|0<`}MvSFCh zXiNBOn`IPZQ@|~MA$dVd# z)Sl|Q+YY{{+foioZQ!f-0E4gF%vbugI$!DG!yW%y;Mw|)@t{SCkM%7|0Gjy{gLe&l8;S$1y<%3_uapX>f`!!hX$99MN)IW!%Qy+kj!<`Sr69AULeLY;rD-`b8 zaLof)3HNHamI91hYLj8He>s3CA(?PZ2HuwpwzknnW{cH52~Jyhy4anS~_=P)W{+4A+$1s_PqO`1NbzjXE5RqrnfUhgXP z9OTyD4Koa`)rnUc8dx%FIVWoU^41?>+n{RatF8fM+ZGxO$P5iM7?8XsQS(=Tk>OP< zmj$nD$wiwc7<8Dn5vHwA%&!ljU0>}+3itp0|IbR`XNYMfTzo1P{@-PwBKV)oK(+8c zg@HKGSXMERB@5x#t)P01>);fJhv*(4nnmLhzvXn9o3gx)}hGGFS&tFoW9+ z5Gjq%03u;e07SxG1!y5dcN-v5R#^ZoVsM23ky0~)UW|mb0EmR`2hd`MZYV&cOeX=f zgu%@Kh?MRIfJoTw0Fkg?0fap(=wARt%Kjoi>qxu+t!JRA&{KynP%c2r7^nfD4a67v zXM+0|Km->D(EE&U8jGW$|0eae8K8}f?{*fK0MI4|m&oGoL2pmeA_Jd)F}_&KI0Epg z9q_+`fyyBcl0JB3&U_+T2)^({v=GKJ5ROI!Ct)FLfQYX>i*sZlDeH?VAYolts4we_ z!vgUg$UZU zJ?o35O>kRSD3tXDfbr!+Noye}W(`hTl6*>Al6*!h4*Bt!k)A->lH^lTHmr<=v?a-B zwBkqzZAtPO{S$(N0MrL?K?ebDGYfslLYM>M`w6v{%|S?FUH3S*%iEELW{ zJ6UKK3+-kh1q*$`LJ=&qhlL_pXfF%xV<96xfXtH=zAh7({6kl#aeaXXONm3ZrqKij zzli|OYm){VwZ?4%n*ACYea#H4HR4WcSlYp3dm0`|8XDWz&`{8ap~J6K?eW3W7|}Vw z)gH}c4Gq5av`6!ih6Z!d9?ef08i&`=Bxz_+f1*7;xY-UJE>X8f(_2&I*uS(#gZ(tl zld(?q>jL`|;y%{!v4O|-_~33ubl57}qj{{MpRYQY)TzfPTK!fixhV?ZV z*)#4hpuu+>Lz1oG!J(*iO#26C#D)&%J?OAsB|ZC(8VvTL1oJ?H!9J5<$~745AqmC?1qU4V zhXgZ3gTda9U}kDC*w+!vM;Z+FYy@*cgTa1`VB$3x?6nA{T!X>>hhR*w>4U@GhF}J3 zFxa;cOt1!nJqf{lqQT&eV9shV*h>)1GYtl7fM9so9Km4^Krp>E7;NtZGf9KN)=ehf=SU}DC~^^3l1Y;oEGD%hcP@K3yCM{(IDo11I!)+%y9$E zH9bs6^|v1Ag~HfN4KP-$h->rJhs9`1!`lGkXMkC3fZ1b!`P~3>-vCo-fU!kU(l}BV zLk%#~4KSMwFh>k9Xxd!s2(2AG2em{WR~1bEU@ zo>~ZsL^7>CEbWmo#NJgaB{!`n7`4)}(1z2TDFtUstzWxr6CAxWAI@rDv1}6_-h&am zkv?SY`lbIc(E5K!zwRH>FJ89zAE!wh=HJMA$Ae(TJ5G(+?l?8p+-s-}b?jQnNNYi8 zRfcbp0}VMz!JCLzwGx_+(szivu5`VDaZ7!?flyZhJ3!i@bm}&>H}Gt!TaAh#S(C=~ zW<8^JaCV-KO*@@M*Va@z*6m(HrDHo!hpLSwU+n_!o3inbgybIzAS?cFN(2yUd$>1{ zwlu&F5O$Dfhehrmi1A`=d+MOvwQhYx-M2-i+Nfbh&$(5P@$YsiB$XW+U0TUNhr+Oe z&{1k_&2@ynMI<^({ZF{J2uDY_>q4%dr|lk0dnBTsGQI(4a;&3r^3$!N*AlkW(;LY;l)hFq_FASaZQGo+4UxQgkkZxSNaJkPnwUK$tC#DY2TbNP z9Uvsrnhp@Q88thLVY)Zn%`MsvgQaK{MawRrwn8z!xulxSG8ZSzbt{%^m~|M#ola6%!AASW zzM6GBTrZXW59}N7fq0H-)@U_LL{xh_om^l6ay&bMpo5L-h6oqh(<2Pyyx?10fLXSC}Q+1loeQ&7lDNVUvb0qdv&& zdq7oa8E)*LPsN}WTu_H*PO5|>LdS8U@{v9eOizJ#MjE7~vVH`0E0YzyXfFyuao#=yK5JjF04thK1t}5jq_A3i}7caiPAHo@KZ| zFyQ3@COP+30|VHm6IKWN5WWXuxdLp>Zb$<|x7nUi^5}dpC%dMSh8l&l-}LN@!L9^T zF8odAqPbf%a$5+7YRzC>7B(HE*^v91$6%XwO(Og)xd|6;J3vFV1&isWV9s{lBe;qI z1}nI0Dv(kMe?^apq1&>DkO(NeCb&1n%4rMu-VOi00tslPN&~?ZboeGvLsi6wWh!f; z>Gf{@M|F1=fF`T&C=UFaj*>@b3jIX!i~SV7exRNUn+>A3Wr7hb&4Idd1z(1m<`Gr$Lu@FB*M~97D%Cr&Ih}&TlUNo*1KubsykZ78KMAHN$OcMY@gcLASWq3t6ZAZz zu)quEffO)W#6>5W2*5%c^rNg^xQ|?McXXtw5cfjGf*D1I25rfQ^le|8AFO!SLGx|d z8UTB9G)8ctU%wR@XeFqD9JP~ZsIUV&=xnmQo|1nTY-(&G=|{vrR5{>%KYc7VTEAhDo{k~sl) zUj#*PKrqKea2Z*}Q*|dWQVJ!87_EFV-7PBh!$HUvAp-2iV~J$xsMa$h_G^^XIl9{D z?ZHMo%4h-CgEJ2_2!>dO4>@W_x$m~6dTp|yq)V+SwCm>4+O8YgmFtMcI%=>lK+Oc= zj%e=3;g0fxeUT5`Q{lqz+L>_g4FiA-U=O%|3EDg!03V0@7SQep0yq@z$#5-2dbsz8 zPyed{_JRASaBV_*xL3m!3NR&v;|$^2jW9Tff&$i{hX{wCw)*|&w9d7soV;0|TuTYB zk*`q&Csc3LIwxeW)jB66Z;T2S+uo#Vu1)zH^vktm+IRJCQ1GoB>I3$H3$zlUdI%K7 zLKOhHF}@a1aqbMX0w5O#IuDR51KB~9B6B|F%tCPhk+78jp)Bz!AHJ4u5XhA$gBuPI z;fMOb{TbXPfJj(8*N?<20*M>I_@e$UPW$;(0ziWp$Q5LcKnfQ65g@|(F+iw|1ScA@ zI3++Nw(;Q8oAE7!|Go^g4|3j*fjB~KZ1Bjm6{1m#t%hjSGFo~}iy}B2(g}o`9>f=) z2@bVU2qa~FvDFbArk6mN4iXm2j^HrY1oC2iQS*u5d|1er_4Q|Qfh;tE^_|S(f>>x4 z>pPFdEo7mktnYFbx0;34v%Z^H+!huJWqko)Y}`R1gTwrxLoKTIG(D$*|M50yK%_Ko zE8Km6r6C^>6OH>G?wJ}IeeFW1W{r!3JNALdN1w)2OG0I7AbiDbPlF71;9&o0$Vc-s z31Pbf4fd;sG!izhp+JNEtsxC+01-C{Xs{nPq(K=Z4&Mo|e>S8+O)KK|01furhBU2Kx6j+N1`25b+!49UK%<2Q}ITYeF!=*gxF8fevK@#0XrH z4jsW{uo#fBcC1$k2`Pj}8g%^)FtZFW>kTlU8DM@fz{KleI;vNNb}5O|rm)Tc(*=5e zt>aSeI4Uq!Y-Alj$xi2cl-}2SH0acUkNim1fv@~X*7irT*5jyw=7E-=W1ogwe=^jD zZs$88@op#1Ha-Rt`Zr_+&wy($9olb!di6xUR~WAF>Y1l%wP0)T0i&hDIzrj8PZ%xL z){3Wg)Kc;G;3 zi+j`hu;<{x-h>|4hp{Jv9iZN1ZiMq6-ToW>e4vx+3w_bTA^1dgB}l2&^K|=g;$!#^ z7Nnxw7OY!m=%bGbMY>JQ*BI907(mMn*I#{f`*S45S%wfaV86f#4>;7^^+AhStt??h z!PIp<(CIoKYF?Y`!vi&W)zYFREOL&_Hka)xHK4ai;V>Z2WKKe)+!$UF!=fbH>x-LU zMeW^0MIvJ<08TzgjMaG`^pBKFbf3`iX_EDW{qyL$QaIL zf}^lteWJ6UViey`F&5Sow)TNF1z2I^B(z#@gDu(Ca5j|!*4yCip6l-&A5si&rqY?Q z@(ccu;x^7^*{{1^LyntZP6L31`3IAQ0oZ=N9|Oa>wc?hqH=e3S4%oAR69D#@fLws3 zn7yAf{0D}^jhL{ z^oB}U2NVTx%H^;%{YRz{$bNfR9D=o>e{UyzpmBZZ8x~XSAZzF;+|(3tVKc@cfm52 zN0;!R^nD}Or~{&N1OdTOw9CVBky}Pd#zk6>qT`Y&-TDXHD^P3_(;Z-Q#)#gD=?(}k zr(FsH6)LEd^K@JZWYt#yUm-%l56?4$L#Lvou*3)TvMLiUC9F(l!wF`~s=yG;4SHrX zJzYi5jG`s0mGtx&dU_K*{U#mM49}T#P#kTsNl9DyWWq{q$e(x;+1t2O3nmrd%((NK zNObDokG}3Zije?FX74Dd?O3=#27>q>SJD>zk0CL*aGN3ri!pG}45!G~`xsMJbhs4F z5lFDSC0O1q1{lb35O4}ZaCy;A`Gt=mUu{>PPwV<~L5V&J^w2_%V=$c-G7u`nKbUDD zu2@IVLM}0!by^4_3|a^_HEbbAH7!K9exQB8X%tKgh~!TqO=QwP5M}!MdvL(wbuXro zf%t+w3+QK3pdHy|$@ z4ihM#JG8smF(BKpl6C>2^bpgToQofcFDDo%) zH^4h7x`0Ge(3Lp%mH58CgXg#p6!IJz5?%1!B?^$j3=dH%9Hwujofwun# z`Ax<#00ehJW6MAgqa6o8qNm^03Ncid&R9T)FM*E0U_5ADQ#z6AfO_(Kxh?tqL4Ki_ zDY&-fcbZ;}fD9AMFDSg&kNCnz;^S~-$jN zX1_ssd)9cSdBOp6clSg8?U{^1>KhK7PaJ$$*~RpaAepJ-g46cH7=psbN%TJ&z;vZ> zC|9gMBa8kZj2!*}{8)ps_!tgD@#W-lisatSAwTKLB6E0-`~sc;)Ta2oX1RMmgn=~! zxCC5E(4XVMF&9$i{WWKjOfZV#Cvr16oz1AuU}C^#DVLIbZ0cb$PrO>BbJ7S$BBCUt z+Q6gbl~FpDR}vwfw)nRb9+JhZ4dUGgE^F=i(Qx(3V1{E9PH`}@fz}5wsJMvJI1hZJ_*^= z(r+Zg1dc6%x2CqdksP?S@79~1GQDByV&-Bt3hoZ35db>?4)6FI2UvV^FkNpt%5)ra zM=S|9ilIY1#;}1(Qm-iP%o)e&rLr%bWis3>x_oraI{vk)fO>0liyHf~(KbecorRrB z?~6R^`8EEM9|RfA*2cEV^EF-)XOnGKduRnG(F%F3vGJ?T67(IaZ{zsp(xT@kUr~EiD@%75pDkCGO)j^#jB9c<^D{DG zUYO>nds|Ny28puGii#-JHzs4M3M&F?KP@sBd|CX1*-C3!0Rt;oLN7;Ev~>5q(oV!&<_udn{B>6WG<7l&m<=z>usCEqwMx(s zV*a(&)}lkUd#V>U6bnpC%&ewzepg;BI9l}~XI0aY=F$plz5`ETJxh6sI}@Z3*TL7T zWtHa}_Hd{1))yTv|D^P1ivpz|e?p&*o^0M&<#66F){azJmRZ5YaUCTR%*H5jUV;g3^x^iZt75A?C$L9Mc%~jvir|M@m%qst@ zqOLricCHXt@2S{j=iTH~>0MJLx?=Mi&&&8c{}*~*#TU(<7D#hIt-+O$DnY@urU+~ioNZ;Vw<+lqr5 zb9uL#KB?Mab`rY}*gyHcO>P;N*G+iLYL_yHbD=2@n&M*XO{(eU?^4grrZs+FpfD=U zv#t@Fe%0(|{CQKog~a4@D6M1M>lI1$qskZ5Znpgfdur-Jb05wRq7fW3?%L97vA1}< z=w?l2Lp8mPS5fk*sb}t;iii0j0+s!!=8?AM)Pm9i$@}JiHsgEb#G25m>rFp5byiy# zg_a<%E6sbUz{;;0r}2NN>u)wae{m=OoC}i8)i$L+yi%RN~As&SKPug0y7 zpB1JznTV2`Yy~@b>uevY-ZDC>iYQpkoyydsoa4pWBkV0Gp>A*!o4*mI3obPXi_II? zFy+#6B|X)#tZa#JF!e<(rfY~( zCjCgcyGYLcOL$nhz$DA;SK5dtuZT8|D3_FvHphs@YT@ixu3(ygN0XjS*Gb@dcVX+H^X9Sw&>M zuhqJmO@&ibXSrG27o0H3r7AO8NSQQWq2i3WbcS%N(L}Xaa8xv!8c{jFFpD$Y_Otp$ zosL!<=TEE@NR&)Hx;O8)9$4}V_n?tq=|YpY>L!^qn#%aerFKm})|!=D^WU@YWM15K z#du%-K(4#GM3rh?R6bDEgR-K^RZE(-HAm!(HW%4ft4JQ_H@jJXs$R>T*7;G@0l_$a ztZD-FPH8>0OQKNk%3oa7-}-jr{pwQ|!|nd2va0=fCkoeb9;-t*OPbsp_BM^lauW{| ze8TW5YC1?c2VtvT6xesxw^pooN<>zmDLIUGu2w-+a{iM(>TlO9#B8! z9N@{6;oR?>dCO*4{Ve`*s#y?Mfa%((PFGtS^%GbajjcV&KW!~3mRlaNui!+M ze9!ySB#S;-l3`R_=Gd@D#8-wGnOEPlTEVyF-K&|=I4|#gqbB3oWiOO{)Hso&s6)*^ za_p_rRU4?WjhE@Q)B%&I%HdU!jfr`~1sf`-HOv!unGQF4w`yml4R>(Y^P&MNA-^Bz zV&?UxjXYP*0w+(?gLU7k)w#&4uk23UF>aRFvo=EPQM07{`|`5#>bhiVX8j!1`s(dw z15KQT*K6N5ZQ`rUHd()0(`eer^HVmMPb_~>{&nRm)jiR($^`Bo$ckFiyv(YsChFy! zPCn&vf*Njf-I&@QN?)sF@#mH+D$@8Pcp*)eMS;!ZL=yxYadPDhUPb9_dZhVd^(&Q9 zeT?@zf0tl#@os3LXd~!jfmpE3^sK|}@>A7w`2DCc&7FDu8>bb2!26r?s&X4I%go2} zvgI=V(V8Q*_l>@xgmqtZ>0NiEAja6Iurhax=&)*I4LOe?O}!@nkJ3$z&rHpYLoFYP z%JQG)KQkIwHmhb)tz*f&rjvq%`V;lM`V`3$^@OIO4c+*oO$S(h(`;A-}A)0ABxA+WxN`1d6pCI&_@|p z$>E$;7Uj)1{)SgX%{M;Z5N*7HyQB7x+MgV5Gaq&y!5Qs!Vmr>4-0G|^t1Ft6RVhYY zO`cnBXiO+O)*!CRbuPEwUB4|q+O$@5ym3PF?d&7=LF#bcCC;TLGfpuju5jk1at6U{ zhN61#=G6GuOG>AzALiH=&M{wEmSoYlX+hyC(Iv+OafJB0=2G)3HR(nx3L`4cHANJx zR0j5ZsZ?7x)`Z&hua2yb5}fXQKrCU}qi@rqhW-}&IZFh;@?umU*;$BJSEO=AoBSd; ztL$o$Znf1)Vl$N!Tdc0&7wxTHk?&D_rSU9vTQtBqy1eE^uqxPio@k6wPC2IQ>yp{Z z_@3d-f7yIt{_FGKxd)8XiUJ1?b1o=*=c#M)4a;O3Bi`-8J{;GlQKp}j$0`ptB;>6$ z6&lU7S!{l$I-8E*kAW6%#K^=uyb|8?hKC$ZL%-Ue?H1JY3Noxyi)NL7q4KpD)8tVn zEq65iNNlIH;*R93eYsLJ#bk`x4Hc*BBJ&5F%8HF5N;JEAG8I#e>GE=~ZRU!{HS^1M z)+C!rjMV&fyh~+c#pd~cnjA3CsQ%H;d38UU7^z}K1xAkq zU*_DX?PPLE(1Tgw6qoaq`$eZcy`7dcMKz|ReQ9#M#*X)%u?PRJCgrP*&JBXllBHa6 z{fw&XJ>DvQmOs{LW9bJDZW){HQVJxteR<{0HFe8NU2rQuwY%|Rb3d~i4R+33MeiHO z*JRgyqq^4cTj{qIe>5zpl+h{bx}rS$_&Q1XBim)%nRUA*a>0Dn@9JEW-s*`qqY9sw zE-pV*kkl1NW%gL zVc{Pp9`+%bWoCO`ToKw9U#M*=U0XKOs;1XDZs$Vebww4NU8RmLzRZu{P7wGw1)ANn z+FWx)?I?O8__M@I@Th!W<1eCkqg=JRd7AK8={3%_rsF03#Ob+4o&PG%;GJt2T7J^% z03-J|tHhnInXRO2`HOhDRRand1;1CV=l|Mt+Gu!PzmoS#zPCL2JW6Oq=?LXnvs3`FtbLwq9ffrt_osrcJ3nQxr zNsz?`%XcgvTUuEaS~ghhuz1IEhUF&`iA96ODa%uqkHDuv^2jpCVg^%Ui@@UBhL=*y znyfvAHkMJ+gPp!8UdA1g*MmQ;@wFvo9g1ctY1pqxKC~Jm^a^C^>*8k z@<7$EeB-J6 zq4-;;>*X(A^f&1&mZUGK%c(c9_h?FMm{Bpev{Um<>0w7}vyJ5$=Ic0;oVS_c{IR#a zu&H>z+1b*IMo!A}MMD0EMO}J0*L|d1S9HI5e05pTd$y1Hb?Qp{<(#BKnHky+2b)9c zif;D&kE&;It;_nCH8+i{|59XMcBt0a%(r@7{(1UKYCG?cweO3BE?JI^oMp5KSE8t1 z)DX$p{7V(zX7{g9h_lioisqZRQL_s9(w(+P#4a^ms=lrbY+PhzZv2zTvAcN#o^k;; z;+jXR!-U`0ukXa;I6B&;r4&DV>dF0!?qfDM<0mVQS*~b1_p~swz>aUL++m%s{+8*( zk8(?>qAGjkI}O97W(Aw;1FBamKjOq%x zEQ>}A^}^GsW|iqS)qBc6tDYB4gad$Y)@G!rts>~D%0FzLSlTuXs;R4tmV94cBH5(s z-|%$E^>SFw|x0vSWa0bk6D0d-)|Iqw0npV&18SA+~>(<%$lL*7Ho&%WM?&2XhK5UKM*8 zRn&ag4MW|a_~zgAs5R+Z`la26;_hPqnhmwTQ&aN)eDO!k=NyYJ^D;JZs!A%Ynl z_vma|Se;aZDePvES$U&U`D8G6zO~SLiIuP5Lx)kSnN*lsRxwc8r|LoKCqj#!skyne zgUyGjKFq$))YtpMbI!fd0W*_L7_s=JYKp3(iEIlo!`zz?&vu?_EEo-@vR zd6j9UTVC9glgxNE)^kbuY^pN<2T6LxEDL8-&+2!~Mp)%ncW+!{wY7Xw9pChA&ep2B zYA%0PRW~!!{6F(Cg=_3QD8IVHS%=ILYv;Nqnfr<5&5PoWl>e*n2WeL4PR|CGx_3Ej zl1hI+V1Aam@)x~gi1U0j`Y{a`xjRfOsc&$@1Zl~3(L(?5h!7Awjs z3r?7uiO-u=y!a%`mr*Z~b4K<4q&VEks_AUc34+-wFYcKJj_5194B^v;P-u(-yX6&s zJls`(t|`*QqV`P2kxYzhBURPxE0R{Nvl>v&E&R~c)Xt>XuC!2@<>Fnjt@(axeQKj+ zDsQOBShPInoc$TjuPy2>7`(&o-|gSE;T#|)yx!=f)a!o?gL_X0^xSCwImr1&d%@rV zJsT20i8l`{Sw2_f-NWTu>eq@XW>d^gJe=EiZr>9R@#)u!Un{iFQ_PSCL*9G3(H`s! zcf-&FV8Cfe*l525dM+&=(8P>U$B=V!RJpLOHUdK;gs10_C$K149AOpoq+lrMfj*g? z9bxqyWc4RlcCx$qlWPbwCzNC|KdFTL$Wx}~Nh--iei$;OIOqvQ6%57D5wLReokP$Q zyPL?3@X^Mo%EdIPWAH+pQ74in**Wc}a(*2|l8^d|wybl}ksXv%L);`})LarF{j!i-@iW=>Op$EZ?DA_5Y*VS9d zjqnjOwT5wPQ&ZCuah`)n8j+83B<73c;|z>Rm=j6#_$rdm?R}BvO-?J1ucuRQ!(oUS zidqpq6u15%foCtzUSFT_?6qRZiXlA@AdFbK8UC(UAPgr!R}jeQ%B#_!5q5<-PR*kz zgwY+0Uoiy#dmebU5_%AT(ajaj70F9ZFuf&3ov69aBXf!vgX0=&Fyqo`={NH%) zoGbNW$@a2EJ?H&+#Pw12yPN~m{KhcTZTVkXIF=upBZ+<2n$rkk#H4E{|7QOYvgd*! z!Zp3(<&Jin1)rN<5Unw}JMLRgj`eLbQMr%fJlG&p zS=KLkOSfm;57r%joYRzg^NSvb9)IJhGpj@l$qE$-`@qdx5Y zmfV?nw{npEo;v<{$@2pDTON6he!ZRQKg|E*qDS$Co{vi%#7AYG)jfJ;xnIfMmg-RG zT;DmiciJYWY^#r+R|`9P#PLtLo^L)g=;r7mW~_3M{hE8d|Iaz;cEaR&y#^+hQdg^n z4fs7_vdNBHY7Sp~i8`G9+1Rtm#|v{4^SQzHr)$3DRdbq?GA6neIVI019L6hhDa~E3 zsYhFSgZ1;celNu)pJxwv5;*wNzVGF%ssHe4iaq7NKDk%I^*(2e5|jtHUwIFydgtL4 zY0B^|H-2aqJ1uG)=kkl0-JJ!T@a{WGN!dHU*fwB>*}KXOu~%I`vrjLJ5Zrsd_3x?l zvo0y=vn$65|1`6@_x5$VETHqrI|-S?sLPz+Y(wm3-#nI{{%EfG-?0(5Pf#Z`=?W?e z={)>Sea{WW6H2V__w2p)-tj)h+zpT21a9tI^BxU)eEkz)zO{YXl0m~oIhkJk{QJA# z3C{gEql#YP80~T^?X${aL4i3**OeX$rxm>h3>xVAag|x0DctKlF8RhAJ$Ym>ugN5e+-2 z+?7{Ve%s??lhEn}zAcP)*iS4<|Otf+tO0MArJc1=A^sqRG#Q1 zeKn~r;_0EB+-&3PgjZ+N^ZhqIH!rWS@#{4|`mE(c)3O)&om@<8|CYMc*QVACaGssC z$7Xe5c+9M!8;dQu7X@?j0tfotO%m~wo3pO?_`LX3@Ds9vbS;Z+Z z-LF>o?pE0h+HSGVsGn`W&UtC(xq+1{UIujwXxuuWXU(6!2d@g~()-^FJsxJ}>{ZpI z&n;P=WtU8NW!^C<@8w*6Gt*&T#5)$Nd}$EvFx zv#msy_fzUUc~x)!Sl;D+uCc18%zVtth3XOMhuoT#_BR$vzV2LA{-e8{czx4;V~5Lx z*Wi-W8#C&<3$FKS9_=Q%qB7>lDwQ$sU+t0SW;UDKMQz=*!fxgG171_4&3W(C3t;ve z&owf#N-zFyTx$5UB-ul=GT4_#cn!_+lX~8!p*UGd_EK)xU0qCAis`bX{!ZaJNfMM%}SQNgCmGqu(X&#>a~< zZR9^sdDf7p>f~Nq`}yOW^-FubuZ|u1tx0dA4~9LdecJ1Y@l5_1W3w}km;c~={q!xf zK-a%SUq7v*x+ET|3U(*FL>A_y2jck8EvvtL`An8DXm|hposZXS?*657pOo@PQ)5CO zJvQ~0ED*n&;8$$XBgkR!=+i@cRX@&nqTc!>l4|NdQl!cWs3p84O~Fa;KbgzV1Lhl!QqU%B2-ul7&{7J7KnZjBDrOO=~y?o&rRmzfk@ zS#y5%vu~JpIS1K!%Oy82R)y5vNv)P2x8pauQGZ!Z8+Jc>Wcn-75MCp-RXs1`gL8hK zVf}`c*3?W^f2meBia4kGmrUON>fzn^&WkPWxXGHb-<7y1rGl$g75knNUu?)o`B8dQ z_~8Dp4(7ZH-?aL}MvJ5N)Rs1VktC%&uhg3~bUIn|sQmkPZt}Ja7$CjhILaxu+$?@4 zGgWbrJ;)yQ=!5QI&v*5HX}L50ZPAY0hi?r@+iuzD{-pGy>hS8#gASiJH<|eGQ27T| z65HYuF84iqy9=S7*R8+pqs%^2G38ac>qaFhuhH%^Q_KYs!iksT#Xr^UO5K?`p}d#p zrw*e8nWGO^S60m#@qzgx&e5d)RF|voSx>c}VVP`~{r9%|%)7ItyHygWyLl0J(3&o` z$1l207+`gZg9xwj0I>+LZcZ;kp(N2%e>oZic+&~AjfPYn;Fh6qbV5zmh zpBqztCHZUV7`JPwzM>~*w!O4B-^e4pej75QZmq+&gKsL=KHXX;d2+D)z0!vd>@9rq z51Ji0UwG~v!S|0j4&Qg1U=}7l)VZkW^zBVf)5SbTk?Zo@VboM zeOyl59=7X@9<4*?+T7 zlewe#a?|gPyNzGo99+D3=vtreExGixX5;!l)DvA7sYb>ws@d(c`7M&JU%bcLDU3Fo z(HRIWM zY|)Z152!hX*Wf49(`+BkYCdGm2JKCG^-N7 z?SFk&b3M&6{b-k=WwV{q(l2Ui`ccmmw_WA=MaJ?24l#Lm@;6kwrAEs6_51Vq$z!bi z>52Ik_i|;gp1xJ#cJsYH3zJZy5F3; z(*_U7Dy%(lJFe`w%@4Iac`S#-bwqTcJSR4|$g06OD2$|_8(k)4j%+r)<=kmm??~v@i+V^peeg`g zozT_wf@_~V^MI7Qo$q#zYz)(cI_7pdf93PwxSmfp}qE}d%q(Cg0%Q`Zd9FpCFX zjnYvCGdaUNCe;s3Uq)?=`uaY}S5^P_)uUwQqe`1kri@hE7YwwS)jTUPh|?+K0JZb{ zvinQbkK<}Nhg}wP9$AUGj?pC4*cXMjhcz4$*pJL{GZ&31aT0H=oN;=8-hwLg-hc8B zH4e+&V|8I%L;X~f<=4bjxMvjG*O8tME;}5j_nJ0jwJ67>uyT^gjZxit+Gn{rZY@fF z@h|0V8=+5b!-b@}#;L`&F-FXn2nX4x#fQ?bx?c zH47W$d5`Wqd)A9n8gRcSC@nXW6k6nMPm6iDrk6tcg1X}U%I;7~=h&~xTxCVCx~5%i z&TzWdrmQipl3(aPl((lQu+Hn&3Q?a? zk34P*o)lkeTHR}Tg<1SXaluFuS9$ti_pvq;avt9J-ci}TTg7M1XK&ApJ)n9d8<%~p zu&e3a#-dBE>Eg5-Hl5Pb9&FLfTlT8G`rUtYh8m-;v~{^Ov+GdxB+exD?-g;^65n#p zi>4+vALaa|zWw-C){{Wv+uqCy4aWD++`rquYs0~UV~5sPB~pso*VvV;bYxyF`*r|H z=d?asp8uUkT9HILK6MT6Os_e2L#wFFP+0L1ru?0^)c)s3NAnQI5EAK`UTb*cQ-Q_= zXkcxz&gJjCqj^6+T569l(kPv;*<>bPvq|bP>=7k}i}JF%eZ#S*s=~1+oKMZHiz=P3 zJ4u;wBXPEqd9lCvo)XOI!R_1?aLT#ye1r>I>gaV@~ zILQZIza#HgDeL1zMZL&DP6P}1WCrV_#r_;CABg^){svl#z({-j4stL{11wAX7|IYR zqA2R;SkTyp84L17?DadiE~$&1#>`KV0HnF;lbqvw4?!_^<8`A zqQ!%tG3%@Pq1t%z0bCTWpn~&(yAJ~a4EHF_TB{G-wRnxUHXib5u)YfYL8u4#p}dZv zn$>T^9ZU8dxb)mVwL<~LM>)d9r*M)(pa%dUb3Qeeqs8^*YM}uvF^pTsj+TLv&-I zzCtZ-01J5mL_&PZ;w(j4x=t+Q1Q4Nv!ePEX5K9U;q!3FA*h%piO9})hVd<;^BEI-f zd@A$ zvABgSw3PJ)fTHmaoZ-HE5Dks%1$Q5~3~4|tG!9?zFja;$P^}s_3GSJ28PY)YY1|gL z`#{u&G|VeEL-hmxL)3;e%WlV;q9{y3E-jD|CpE%qFhkEvgG{j$b zUn`Kp{(aQQhk;ec>F#O8Qq{LLK?rsCvqH0Ln?`3ZE0#QubBqMm`PbdY3fTvmC`08?k7 zOOpi{DKw0hP<&wb6EwdTj1(|YdKfuiF2MKF7Mj_B$<)Ja1&oWWF3lH!@zKLv1)4(0Y<8a*#H=OD9jc<`vK#lhdBk9-Flb= zzz~NP4<5mj&MtZVtsZmR+g^@%W;b*zdS+vJE1ba=dN|jhO^dw-gzfH&H=tyXN_bP; z$nrmIfEZbleg5s2qt=$S*6eGWh1Z(M(CBJMtoIt2aBj^;*M@AH>Ch!;9az_tY@2~~ z30eo%wJ6(WU|j-(z17*00AGTIo_{ya49|#-F z74iOxBzYLJRp0=V>f^M$87t$Y0@C z2OIAdx5p{sptQy*9)zdSJAUI*)b^{pfm`ThnDg#93XtX**08_^E9)OM8=k{2!1(GP zTIBe%$W7z>wvoBS0jfLtQ{R--*f--vNba zhhoC6VgN3EC~8Lx9sUQv5q_J%@T=lG00R_C2pgy<&xZ%90OvfMw0}S52*0Jik)~AJ zk%t&ZjN25Ru*p0;ZCn4A@&wzd0gAdnMMWSOrrp-0Rzsx;Zb0Q1YzEO?ydTPM?-Z!4 z$amCX^;~-ABuJy;8Ayv6${CmpjV|P(by+$tf%505_R|tD5*j4|BMKlNN`LRfkP<~| zSY~5QEN{J(4R#bDKG{kPRu*aJ-0*`uGReK&0G&NNbRN$SD{I zz&rwH(*Rd!f|l{L)z~U%Bo_R$-m%-#H07@b1FeAyFi;E@Ng^1fCI1M&I{!Y#bhtBQ z$v<+D5Dn7KkF5v;oaU=Y^;OI+_FtAZb;^_}(RI%L(M3|%Sda*fI7M1R z@U~<*F!*+~%6Z(f1PsVXHAuHcur*R05M3aZyCxAamukezH)3{iEBOWyo3bf9d6Qfo z*1)CrC4e+_pvDT+j8)x&7eEe{MGDS!tfHAX_QnD+h-QHR?u;DBFW|`5vXLA=k})3p zNq;Dxb|DoOMq9ScYo&*_I4AP}AKK!I%m?n!>+X`77>>Q{42AF!opMkhuUQ7~lbe>p^~C zer3lorVBc-2i{XIBtnyOO$4zz90=ol#6eRC9HtV{`%VIpZ{#Qr$_t3uh#SQ9`$SjneM;1qywK0~)M}OMn~DCeqePK)V=B=0F9;MR910 z_dI9|xirxGS%|_vBJ3`c@XM1SF8|20%u`ri9pngO)fB$JA`H_4)H|50gz+&&kp$^l z7r8qM<;XQIx`0>d|2@=G#Aso}K`ea8HClegdL0#cZm0(|KC~OXJx&RQ0G?>XSyP){ z05Ua6>^Wmi3W!WX-20owhvf?bMSW<~_8UelIAXHaC@D2ayOe6ouL%efP zH4#h&MfxuY%jeSm(O{g1@!Y|9f(alm+F~f&Hc4oU%@PEp0Knj}Y7-RDW-x6u0@4VX zCe4QF9>+oL(h^MzmS|hBMB9QT+7>L)wqVIIO$(N2Td+jif`JWb!N7-U!P{?Om!W5? z=GJ!1D3;ZQOvvD^?bxDGFiG>gh;@W*bnmQ2wK_~?Pw(yFh?rn{->A+2#OOC#x_1n; zKWMYq60{9dGU6^X2?GQ+P;B9eTEu=pXqs#Idk}@6q7LllJNt2N`9iT^70Hv-i1FoV zAM1RfpFy93z7PN{P0#a(6C8r@NB}7mUw|T?X)_SdF$O`qrO}^kgVbvK7XQf65->_5 zv1!DiKk|^w1%e@>ixk=^q-!^=#k1q0YQ2|g2B2+i-jmjKMII7Q&id=N73nJFl%`PKu{?FFaeeH z<6{hrk1suagpfRew;$Mwf(WKJf#;kQdeY>)nqn3}CPn7WCYgc7M2Wy_|M0qj#=@Gy}6PB(VaVPt)!% zF-d4t4Cm3$;SO_aNf&KeUtlaGt?27Z2Q?uE%_hycMejO8IGSma3M?c&G)c{*U5bIU z9~kQgYiD6GAWE}J0j_~ckwUK}3sda{j#DG%_cr-L1^pxs;GnRZdqlLrwWoaF~Sfe;oG!E)Mk7=GYg zz#4ivE3E}2|Tn53R6DT?{kC0_HJ^2+ddQ4A+SY{Ohadp8SNXD2*{x;2U z>8FT+>C-lObfNQ@$l+W#mIWI$v}-?3ygaF;InRdP7bZ94eF>9~A_okkQQqk3&oRp= zbE%8v^wtcW{GkB$(>9G4NQUjG2*_3f*&GmOmxhgVAOI0LkjHwIFWMb+`T;9o1QFT< zbXpoogc8atSPj|GO^+G2<$|$*g`pM0;d2qx5uTWU+?nU&>@(B$3sylVrC{a>1tu_M zz$p4M`I6Xpz&#*xnh=NbeA0fQ=Lv}1hc9k)TsBEUPWof+0lI*m5>HRPhmFBvEWFbs zh5QW3*&{oI=?K07@FCeTfN>&s96Dwk&MN7-xAFNjK66qL{0YEYp*LWlKo=2)^El>> z8dx2L;6Q?P26>BR;DeopFTHh#%mX^>z{qnl3Db_Y0sy_UACv`?ewn5#AA#?Fw96grQ0T44 zm@ZsSTj#@-3GpOD@xDvDK)1EE1g>m)dI@bEM_Xsm(?PViM$vI;@4`I|dx7a-{cJic zNnnE~)C@(?O(XzbU?L6*&J9hz50Z#%wOxBP3|FhhUN~nM^WCW-^&1 zVy&6TLm_=EIl3!7duUTxMcS6-w zwSc@+6GR+zqk{^0Of4Kpn?Py-lW3fG^C6XQsWCRs`FcxD!(Nz`BEK zx}_F+?k6>zTBzQime9l$RbUz1jK&Ybnqr$t&f-qHr4!ILcI&+2mnE&T*;Va z%|t-X;ES@3kXdL(6@i>Ep+#GXgVZn*0523#c}l{uynMJPErrxEv1aC#0R?0TglWWk+%@Iwz1u@7n*0XX_9;wE1GDRH)KAJe6%^i^7S1qeklZOP&bB1w z=DHSnf|m+~A6N)LOl-czNGBl9J++TrzE9gkQRTWIE>Ir)5uWaQq zt56xZH?j(rgJLaI6qD72_}^a2X?P_^$r&I9&Qq!1p)Htne99iC#>)F@Q;O2a+$#nj z=e0O#59b&brxZOjl3s8EX5R@ZmHk$vh2r=jgKshTqj(V$*`hh8C^SNqqUieJ8@3(< zVgvl+KT>jMcFxA&6kJQtQRdzm3X0crV0Yd#T;%95kuoH$M5>V5XL_MOOF^U}(xMNr zRM3%x$mU;06lUdBEgGi}f#I{7dJi9S?)%gkoI50pbHqu;g-V`V1#0UJlNPmCnxd;n zi>gMZX3)`%#gsd$W~hxTp*fn-v^D~w#ml)!Ww;tfCu%Ep5mz$swcYH)Dc*ld3C8E3|dyU}|^3yw;=4JEf^nC|EjA_y!-G%p&mz zq)a3R-yf#5AHd5|W`S@v1B0dCP_LG3hK!=8GDwIRL_XQPl-6c0d}OSp>r^CS`; zV4{l?$}yHga+6+g9CmXMt?+3fvPwaO@!6fY9#-Er>{+~%g0Q)=OXT4;_UEn3$$CbE|ssk+pFBDjhJlyQxCJ$k?5haEU2; z28(9WbQja2s#3VG?ZLR%4H6Df-A~p5v5!xaVf5avq*p@8e5}r=gfZAy(k6oyL9}J~ zYNWU$U00kQWq|#TW)dtv0(+tZ(q0+@>tQJjilshasucdWFIAvLPX)yh7EMs9IRRLMO>%Z?+X^bNw}BD^CBY#$oNgU0A^q|nKZ zpspC>B}TDS4n4{>^VHmvdgfYg(jBBoPNgN3=KVx9<^r`WxL-jWIhc(bWtrFLiyQDn z;{>pcV#$QvQE0%T8`8~m{xwBR=DU(7Kb3$;pr%m%HE^AQ*_0fVfI{{b2?V?!0}n@; z*m7pqR}eg+)G{$=)0o9ZlJt~>3)^X?4s{?KX9JUSKNE}SbNZP-qt=!npC_fd-=NE5 zhT5({`t5s=pXe)m>n#$~ofWA}nxWtz*Pu&~x8nV1Xi)#}GeFD0g6O~# z$sCo>K5j&dtO#U6MSUj_3q*(=^;q^Ho=E{j!tl#M<>y8o3Z$H?vXJuyOX>_0d*&%9 z&#V&|?f(!>42?9E6WbU=CeIl~f8<&rng=ZB^$wJ!RLW%Yiick8e^xnVDA8mTvJzt` zI3zyd^tOHARmIJIp^-8mLF#VW^jZM)+77$bc>Bnu-Co=|9hY5Ahm&)S5z@4ouvQb< zs^+NY!mBy-5lWMh4JT^T==9vbBgqUh30Aq1g0hw}h8Ov+KI#H2omDDMd7;$seluYC zrCPny``k+fgHkKO;DWXJUM1Vh7d%ElDHj+3rQ$>2c_Ih`%AKkQKkx!agbvEWCq5&uGb^i*`ayCu0=mXjSWH#}FqaC~HQ4@MBBCKpiWQ|um&( zt^_Zn3F=WKYB~cJL9-96C1!#9KNr2LF5x z&!Su^i4R#Zl|dF%ILZMDRB}CRMc^^6XlhXA+07KisT40;^ zd$ouiF`(4XGRi`FB0aBovzt&h`tCFo&vBKF-W-Y?ZGy6pb0uX!S`lKG1*M=Y;5c3R z1y}tr-|+&XfWh%T#iRrk#+r2^8Uvr0MnDHwkz7vGwzz+TdpR`0A_KrTG(~-h#=t31 z{A5N^crti^o84z zStqhGV6tv0TI+4l>9CQ;1sDfN=kvEMCKz=5vDp(YNwDD%ot5^vTAl@OH%4F?x z^PPK?iwTpuww^qh_p$~sfcJKfwM);1d{v!~f~NeOl)ech(l0S^CH)_!hmhSQYf4BA2&1;TVA`N9(y zR9Db}C;f|ETQ5UnB!^KH8ee(> z>GC4YRZki$dIDlV>VhZh--2uig~{c?#v!rtI?{XE7gnnN9n9Yd{)n)P2w+0L-L@Kp zI&XRnYPZl|7xt^`JZ*7I*}wM=m`Qg0rxNst z86Ey)EPo(!Jib;@A{U5LhO(^RTH0{iHH2JuE*byvaj3%2_*E4;A1H5a?vsuwx~{aS zvs&FSMxxiI>tN1nE43Tzf_HY^uQPBfibkhL;ak?l?+%aJy%c~@1tSj3o?KYoy3&$P zyOg2;Ws&CLkV z>03m)2SWI$69%`PB{hl`QWoM!?96~ow+ea-ol2C$15)%-8Rj+G00uTNi>S>dy=#ic zt$|!aTTqfJ54P!<=zusXlnXl z8Vq|FO}=&F4{f(+6G{fls9Nz_QVU>J&_cP4OV}La4R?L~q zxY2B8Lki>Zh!3N%uDZob$4V{y$C6w8U#5m)P(U$2;cTF_E?8lhikerHBE#qry}(>% zPYDJSQi`%^wG|xx&MY~q|B9d2rF>H@)vdt1?f$LMQ7J!B3F?1?XS^4DTWrT_s6V`U zj1SVw--!*8lk_RKV!efnBOl)JU8}7n`>AqcSW)lKh2`kHDoNFy)qja`=o_`@7hgbo zCKX?CB1KAHzAytE~g>Ztm{4=~jZ9d#jvSz=D8XZHLB-nF>{sc7Za zVojx0)1_k3+-hn(Hc&H?MOsg*+oWEqyPlzIj=Gwn8_Lkt3GEY_!__G|v$pWTEO^4Q zOT8BBva}hXXQd@HB8Fs|T3MP(OYG8^C1t7N)>Qb2ONIByj=*b}HMc}{?ySiBo>pY7 zCC?kQg-9ZI-vajey0W?GNe{LIuPP5M$aQm?-*#>PqCckDzSyCovmXT&1M4Oq+m zGS)UJ9oH%C&-Znz}z6(5i(;}olzC)}307Q2)?HAoC%`V##j>zC+JcH$_dC<>-;*j}k9 z5IlH;2PJrrg9pb_X`J0^$rIwG@JCT@wlFW|76@FW?|nta2)R2u~6z`;9I|}b7QB*Z9ab!hafloyt=+m4*J8Y zjZ8oRhb%{yV$dxQF$4&&Y4txGB_)2ehsHFp+T&g;q@LzAtxwCBb zt9Py$J$>i9+npAk&7ZUIN_g(Vr(<4Sxc85rElk^gdf~25yBBuEI3<3yJ}_}uGAHrP z$@Ih}S&t;HQofKF^G0Ffp_A_>uKZ$e;$Y(b#Fw5umDrn8m6%}YOw64zo;c6JF-cLT zOgh)%pR{IaMABKe_#{zqV$wwZ;-nLEA53Bw3Q=9N#D--Bq{XGFOrt}>`R(cdLU`Z8%LAecbrXW!N4GmmN1&u`Uy zG5rAhT)9)T^W1LD(vyXn`6u>hhEKnt`L5zEO+>?=HO#(me`>xh{Ff%+hc7kDTE5X-Qti|He9w=Xj+cMdm=7J&xQ-QTni7v`mgk?; zEIx5Y^C!3Sn$fI_n&&>btmz-TrU^)`(Nz7ZPUEO=)ciB4RrBDR9U6IOm*(q~K25-1 z1~hJiM$PUFvu4_-W8hE3_I)8{4`$o3&)Un_fq&bxQ8|vR#o3u%{(}n}y~dS&!G&Qx zzE`jV54*FD_MYrrU#i%bAM|E_81Z3i-}hs$umS9Aje)HBrC?SX8p^(UcpCeU6>8So z7|vcTn$8ZWqu9uwXRz;NMziAjS?uP=V%amJajf~3*=!Fpm;LA4^H{0>e75!d1#DKp zoov#(3)upXBz8uj26CUwsy3&v^BWhjPiLmGM}J6Ty8;%o6M0&;yFQ)$uf%)V#y@4S zon4vivzjIBYeh@hnN|0*fuRqwTh=XOT|a$@?P^-ij`?S?vG-@QTV7tlp8oo`tXtJe z_O6MC*{t9k_TI$R?AWq3?C&=|!tQ)FmrdNcmi_S6b?o-n)`S1wu|MzHz?xpz$iDyR z@7X_Q|AD=jyop^B_9!bAKgRY~J;n}x`8XT!;uCE8;wRY`lt?Y-e*|xyl2_D2cKnqG|#ag{QMkS8vi`|^go_w z4=^vVe|zc$R#N!_J2dA-cHJLeWK(NiWWS5p&c3#3JKOcecJ?3b+u7=XJT@;QkA3N> zJXZ2v9$WrX9vg5ek3HU&$DSR^V`InjP=5JzadP-Sy_bjY)BHK%OO4Yff74ic3N`1x zSf?5JY=&lTTt(7~epk8<0UrYGFx(Eq&tdpIj4+20_VBJheyi~>cw4hNEM0SKQBe}E zL%@eYBRB3MOf+&MKjfFfP*_Tv(xkK~4T_uMrF1A=;)D1iK8Z)-jd&tnh=&)G{;8=b zbf$|icM&EUx&0@1ij&f&^e8?`llYi7(=V(j^{({SmC);Xb z7XE7i#Vs;RR7hB^67i23;Er9iWKk+fBA`R9e>xfb%e6@4WpWv zrqM&<-_K^x|LaHA^Q*<|_m2l1p8Imbk)*YnBS9rahw!YyGmB>y&q+-|w7PsB(ha!_ zS4!qpN0u(053J0(x?4k+?HOqb*hZUr|35w1V`|v{35Mw+Qsa_x-yY-<6(WzwBH$5y zPL^y3spuV`Xbw^LjKWJr?h+eqClDXvUjk|r&|Ev4KNqPjzO_Jnd~E^>gayXa=nO5G zR!Acqh>v-xfF1&h`I{7+&kJN&|Vf$p@1$4s1nQD_^=}aa)PnU(~<@B zdjb6wh)?5)fa(O-f2(X^&k3kkKw|=0f%y=^s&>Za5;KPn#T%Jd7prgQ}8lbscYC{5zrpbBN`2yN2 zpwJ+jYm$JL3g}S*y&|Bu1$0e7F9+LVDH2eFfb;_L53%{%CZIP2v{yj;1ym4f3voz5 zrv+3A6eXg1-YL-BrrAQMf#z|pGl6Dshy}vH1wz~nG?POs1b=lvd|BCr*^s}0GJ$Bv zxK#8T0lg@=z9*nM0ksKeLO{_-fKMYyK=%n~t$;QO=v@K*Q$X{>ZLusD&`Sc+3FwG` zTqA5DVg5ziqU4XV@X3N>U!Py$w0 zR7)!jRmO@!1XtpSkB{0X9z_eTaRQBM9q&pd5rWH3lqk@W1(XUDg3C^%6=)d(S_%|` z%MO=F1fxeJ<`BiiBN#4Lq!G|&&JWQ;aEg!hfxAhBhd%;^!0lE)_JW_kSrHm1#6#tG zBQje|#h}ty%dPRa!-fA<;nskQVzv`)ePqpJYqy9*`2qz%q{sE4)aHjMgSe7$@u2`j zQfuKU0p@pm%fgd$;;|WDClj|B*!9 z*3T^`P93xb5$?C1IJFFJXdb8h_%n8R0`~^RYAWtqes58R@N>ZRYbQ?q&FY8bke9ok ztQ3+2o^r}cq4&;Hu30IREKg~(Qa}LZ(RGjJKJhGJ`h{5i3<`d16k5&9|7;ZI7Rt1n zD4QPtoh=CN6bk1kJ#MR&JOzH@Nn7wgTg6D)OcI4oBYQ8lA4pVW$T*UKjh?Y_(RU`wyoK`Rf9+ z<4->}J7>caA_9+Yd?p9FMR5Mlv2A@ENAPUi`YeK3kK)<5W%HVtIN@lXoToQFhNwkr zbGPPfUGvDMb)qd>*XFEWw`S|M&FgX=*|=uQFB9Q1eM?3->g_WElw-%$#Mc{VI8zVNSJkA#7a1iC1>r%-)+R%M)<-TTf?uMI^y#B-ducUfcw$u zNxU+;ibe|NI)1mFooSw8A=`2&=-;`gnC@y)b)+HxuKlZ`j z>(+*rv@GXu^OU9lG^)a7x5T$JId(1_ru@j?*zNq?=Jcd!lnI?qKtB`_cY0FFLn+Iw zD-p8(o0SNK8$GqU3tHXPOx>}aJRB&PinZ8%bgrXlx~QB;MGJ1!{-cs0HzUDv;U>MjJ#wJMy_iTqe4GI$I3Hx&;zZ2O(wanCV%4MTz$rP&rsLLQa=6g%ARg%}NSYS9@tI6G zp4VCU8=rfMq;J!dsy9j;o|4Fn#eE*VGu&r+^7n8O3_J&t`HH(LgzlKSEE zr}2d8^n?7A{1kbzO9H4O{s1eiaIsHn(UVx@=Z*R6=pv~T zZdkmNQZV7hys{F8TMDM6vHPn5rJjyM&PqC^sK@CzRw^B}FNJxWDtiU$8f{@sUeBy8 zU(Y;_Eo-aKtYe362 zi9uFoqGBDFW+pUmE7TT(CoC7hqNzLuc*@}U%b-;Y_Z7)MpgN(|UD48sK3ZKptvXnm zQHZ4n8I*x^an%h5dH%5@IYO(ew(7jwb9#F!_~-OH_CeVAFNS8Z@qgx4{%gTMP9nlW zhXu$&Mk@FMbKsn`9w}4Y14&D2#YVvJUnt6XoT4QCV^xevoW=w}7QzSSH02H5fER{$ z0=+=%kQ%u?me38Aq&J(PH!GnxYe~;l<6l-a!YNnb7>nMm+bD`4{Nr4*t5Lt3$zL;+ zCsyrnJ49%G6Sv!$Q=UiRrIrHFz5whefM<#sn;Nl+6x)QLJhwsZkg_1XS;M@tp4X+y zAYu!NZY$(#T0P8y6y3RdilY7KAb{0qJ5ow|q!~I>x~@CDq!qQT2a!H5RZ~PQ5D{$i zI+46Sm08<>f2eV3%<3{MsNQf4=y5&$YsBM|tqkCrR{Yy|E{*wZ3v#=@m_jt*=`{Ih zVL-dD+!o6kie=+5azzCsrv^rOH|k$Evy)l^-T8tx169O1I7!X6)oqHXKt9&aqgCd# z9DfFtms0uzcoiq~=QZQiM`!4aJ59`<#mI36WFncwIa4nZ(Tk$*n@Gq+D4IbUF9B2(9I<%8;gtx3xeo4W3Rs`yVX3ugA${?1EqLo+=hSMK1bk%goL#DWjmLX!RFO!{ytz~pl z;c;9iu~vVPAtT|;wiex)f~GOr7#V=`4G_OV`Zn~6^i8gkyon&cBGsaJ5>UuN9IJ1V^Wefqz6lE4gCsK1X)e@OfDW|{`=5Qug zJxPJ5i*c5Nt^E<|4@3!#4>-qm#pBY17u3;t@1o8`XX=b3xM&=Q;(OA?<9zyD{kW&- zg;}CZU1fa5jhZth(J2u1XFh2MWL7IaNv0zeG-s5;iYFdW2GvM-N)=Af=e+|bS|NuC z!s4zJzMjyw&PLEnJ4m;;LbrE7x3^Lyz}CQG^Z<}Uszy-BXYfy%K9*3z?0K154R9Sr zZ$N@LxYhSK>VG+} z{voZA&CDxWJn0|CKk$#mzBnZWJD{1DKLkUx`Ht$1w&;26?`R*<>O6F%SRv>Il;JU= zjT$YI`)H%;tjEO_az#N)`pKTt&a`88dLT4y-{dSuZ1?9aM{F13Ek`Wj{}5hLiZ;s; zo0)mb5u3Ys%MsfQIm_{-V`PTf%yl}{^5|YEv-1V;xwCld%ghg0mxJ>+6epi`c@~Dyd*QBYFn@N9eo=1fL9e&Xp$(R8e048M+j!JlnDRlMkI=wqoqvotCGU!T8gdCX(5T4>b}|NFmPoj{}TWJyUyiv;wffSwi5 zO9Ijf=nVnAE1-`A^e+J|gp}}UpdhV?q>ZP&BOnqyp0-y&-w24#_~l(o1k{a^qmHsv z^cw60-u2G{`ba?k641W|bU;8y1w_a7@v+dpD;`}J&|5J3dDnW$w+vFm7mfDuHLp8KbV;3HP?4*Z(-`nlOLCjZs&6 zGa}Ju2tCQ~ZAV=>`py4BL`zpsm8F|qZ#?0w_u{EvRk`7EGs-7_zZfMA0YkO$7Hyrz zqIaMCD`(7)@yrX6BHiTG)~9^yourSXfUlJzQL&ZYKoR2?Vs!`sCch0-wdrRaZT1BW zp=ayQZJQ0<{{GtD;QW`5K6L%5{G)mKeffDChVw&b<;fmgmj8MFrMR>CF?oG2Zo5|a z$x8<(|M^YG@2yppC*-GtGLBy(TM#|(|n@u{C7`+ z@|ajYK3VC7j}yzq+9eL>z1>|^#%@{WM?Or#IXm^dNvT^CBE6=?2o`=4l@xSw! zqg%Z=ZrHffZl1Ta#Y@tBNG*4m-<9P#+0|zk6=E6~J$YAx%6p>q$Bt)I?$>9S?Jl)v z)XjD|^NHPS-J`M0MWz3Eo7vgnp2}qNXvc+)ag%@F$hcc;sO$6qb9JC1+GGB>)8NUO zGlNC{=<_%w9cmB%eDIHjV<$aiu;pTXuoU`cun7fj$u^Vk>Px{ z#eC$RNJDdo;llOtg&&^3a^-0MNNrvHfXjH5bk9!Xg)X;K;+Z9Wk?y9pf&S~)jUks0 zNxU5FhMUf{1lV^frnQ%y8L`M5Jq>-A{q2HB%O}UTx-4$I_~`7;p_3tt9NYhK#^kZD zZcSWmZ&OvZ4q6pF{s0?+tJHaQ2+AY5w7rM#tJ2mn3@8T_eMTvj`Ar_@P3_^lzlBCO6$ zc}Au(JFp8F|9$P!wvvEDWiv1S961zuxJDEfepcFd&PC-`H|BFS(90n}9zMOQC*|1r z3GoBdJ@i*P4_&VdS$}_?`mRgjLuF%Oo&k%OUmyOmPVS_axCQ7P+GjZ^YeYT4evz#P zrL4=_XZ7j1Jp&f;?7HP=BP3f3TGW{S$fNAh8H!C__<4cI+YWH{vyZTc3D@4+5I0X z2g&zovq$^;60gn|kJ&lLCe4)Gct|RCzaXmaSrHgE$FY3%iqVj+R303?W|vUrC(E`W8Y+N_ss6G zmb|wE?@n%=UO&3Gvdk~K&s%cAYj|XN{FP&3=UM8rW1$go`^6rrBht}Q|8}Lt{!&$i z)Gu$?y}oxmR5Hr6brzQN9Z&kf;o^z>LyRF`F7b3KubgRDX{fezwiu3g+K;*n9j^2* zz1$NqK7Ayz!&No=Sm(TEi`eV9NLjVTYtrlx*xJ}P7#cm|66$jw;}JB`A&Zx(0|$)d z^{T7YL-T9y7!j+LqB+3}9fsyfLk8kxCc}u>aH_-NKH+U2V=;M#UGBf2R>;iX9+KZZ z{_UC1J7POSRcVthUTy|MXN%G0T5wx^&zOJ6d6}uYb0n-$e68OY9ng18uaxN91_%4> z-5T4SdZv#^gC{Q;TRyJnS3T7^>zqq>H@>j^x8?@@)m38e5q7-7qb;QJhc>^l!JzJ@ z_JPJrSER?vhw3B}J6Faeof^^6Ad#lJOyg zC!n#z5bom|hQvt@uAO#r5>-< z-j~o;{hi6){(IlQ8AB_?u3h!r3d^{0tTn>%+C;s(N1M30b8O(W@tbj%X)ETqC}*nk z8UlakgWd0#zHlFXV`xpWuj|(>4gRw0dZyFE>=fjo_aB@Y*lcmCtgmlyaw#9t zm&GVMuZHzsQ%noK+GCvWP?c@0_Z;vFvFo$AO%u=W`gFwI-?drA)SnwRN)5fEnY=5REV0`@I zccRR`(mqdjanq=!aiB)z<=hnB=WZ|au5EC0 zA3R!iAi7E6KGyDbzu$=d_}q7tPdE4}#on_5i%$-us|E+14w}pCe5#`R{oOo^$ZyHn{Xoj7p56?y-n_7EVt#`Hfc|v}-!& z*wb1uXYL*96)W9bM@5QKh0Dbn%c$y(lOZ!Knqh^?$H_DnI@`5he7)S=*mr196>(W@ z2vo{U?#c#eYCp~GV78TC>*yNp#$+Q&!rpH!7*9vBq;AU0Qz*s}przp@8d z*ID(^WkgAIc)DBwR4z0+so|MExx-Y@S3w>v}K|uq*tjicArZXayAtJA2+5U+EDDHOo`V-+MJrnO>mUK7Fvcv@hg-&`| zf>Qbd?+I@+F&!RwLbv0Z)pc-t) zdA_O3>94MG?|V;~$Hyk?88BQFiJo?kXjEDVPEGLY2mw7lf)w|SO?CPv^j+N?_aZ%-N&dfCZ z#6|ChK~r1Xm?C08G-Nh=Mo%`h8l;w9H<`3Kz`@KIyD!x$mQ6d}Ag$YDl@vv<3rxzeLAOzL0tiGS@hiK4^Tp-ZN8?rd`E zJK7eiy7SyXTD7Oc(y=3!)_Fs*cvFe%za5i1d4-RRg-gc!(dG59T=8u0b#4{4v>Kgz zC&mnZZWafNc-};_w^Ng4%(CEaQGIvca7WDOpJOY|d3v7xdeVIFXx9;Wnb-ND_&YMb zcAj^CM6<7KEC{bmkJ z8zM`~)Ls!@>JRNr`U?zf8c~$0N9A3mx7f!nklrKuYv9F!T2-f)!d0#qbyW`bi|g9_ za9BYhpCc;4bcLQ@AzyTl%}C1ManR3}V+&i^1$-`MqDjfU4QSy|+)*H|*IyUKi&w z*x21PEE$qbIyyNy4!ZZ%jVg_zF=vT0Ga{K3$y~<`;!!(?mO*>K>^t^Up?hne;rQO>=s&PpfeK9i@# zYkt@_YTu4p#;GUEBl+BQrN|s*@Ru@^?uOW^Gb66;-RpZtqg`4SOU{kTBwr3UJF3p! zSz~sMaGExGO(pH9t?vrCcS3w{GPXIyQ-0(#nPSK%l5y~8>r>iK)HDr;yi_G>Y?XF; zTfPfB8dB5YYMiVbSDIa27WO#X_ljKYEP4-@KG(@_BG0BZpAn6PwogQasrzFjgNBh2 zdB^bS(cusm2YIjQU)6Vnx4U0)Xl*^?>g08|eVEz1|FXs5@aG|sJ;P>szk|g7x=Vn_ z&rK|r%H6}fddJ-+9o$9+#--yu=E?R}-?oX+&enG6u!G94$$7Zj#ot-h>oz9o8#UvA z=|Hi`UT!2Pqu1E5*wZPZL3BxE8FXoH=nU>K8pp;w>%2Uj+WkiZngi`smfkz0 z72c|WM)kE0sh6{hgSug`tj{N)rWR3GhsC)|CTBTM_o;SuG&uUYI5Zm_Je#U7x%IXT zrzClutv0A*JgY*ES}e8oy)6sY&2nkcHy`!9D2em$6Gw{e&M{#DUSac&Hp?wt&UR=H z#Fk)}aj~h)e*2%F%gfKh-zIrpTz)OV{JiXZ{O905zVduA<*U4iAMVfEyJXJAH`?4D z-21vu*n5wBP<|=q!DHXf>b~YAoqNZ_CaJ64^Y*LWI55!YET8wSv1{V;{*@^w7gXn+ zqIi~X`#wLf=Y!XgPWk4i^7FU#S)N5258Ux~US9p$D!u!^>zXH<^4jw9x99uozuo;( zo_0~7!{Jkf`Tvty7g~{q(>U;-MF%9EMU+dKWr2e z<>R_RnSrO`n<%s=ipDB$_(=nWMlEkpR*)ZzTi&3o2Zcs3dCIMG1mt}4Z|b-%jhEhb zPs0DszJ)2{xm@aE8qvLFm{VjMM&;)SXusFZKkJ%xj!5y`SQ#IJrzsQ^e6m2v2bwa( v&PT#854Yl{*KPdWhOJxpmG|*eoZ@qS;k0>O?zYW06XT}%j}x4xB>2AobK%F^ literal 0 HcmV?d00001 diff --git a/native/third_party/ucrt64/lib/libvpx.a b/native/third_party/ucrt64/lib/libvpx.a new file mode 100644 index 0000000000000000000000000000000000000000..e30a170c308077f48ee38e751f2dec0100cde3a3 GIT binary patch literal 5628452 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

(-M`?z8yatDy0NnIcj=T9zgQFKpn08g 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

gZMq5`+)8h1#| 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

I-)a;e)`pkW@h5e(LWEGP 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 z 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

f6lF3JW?PtH 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 literal 0 HcmV?d00001 diff --git a/native/ui/.gitignore b/native/ui/.gitignore new file mode 100644 index 0000000..36905b4 --- /dev/null +++ b/native/ui/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/native/ui/.metadata b/native/ui/.metadata new file mode 100644 index 0000000..12552b9 --- /dev/null +++ b/native/ui/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "abaf9c523780a608bd46686fd5e53740a07077f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + - platform: android + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/native/ui/README.md b/native/ui/README.md new file mode 100644 index 0000000..5361502 --- /dev/null +++ b/native/ui/README.md @@ -0,0 +1,89 @@ +# NetDesk UI (Flutter) + +The premium cross-platform front-end for **NetDesk by Akronlogic** — a +self-hosted remote-desktop client targeting **Windows, macOS, Linux, iOS and +Android** from one Flutter codebase. + +This package is the *presentation layer only*. The heavy lifting — signaling, +NAT traversal, the encrypted transport, capture/encode, decode and input +injection — lives in the Rust crate [`netdesk-core`](../core). The two are +joined in **Phase 3** via [`flutter_rust_bridge`](https://pub.dev/packages/flutter_rust_bridge). + +--- + +## Layout + +``` +ui/ +├─ pubspec.yaml # package + deps (flutter_rust_bridge/ffi land in Phase 3) +├─ lib/ +│ ├─ main.dart # NetDeskApp root, dark theme, home = ConnectScreen +│ ├─ theme.dart # color tokens (#0B0F15 bg, #22C55E accent) + ThemeData +│ ├─ connect_screen.dart # wordmark, Partner ID + password, Connect, "this device" ID +│ └─ session_screen.dart # AppBar "NetDesk — " + toolbar; hosts remote texture later +└─ README.md +``` + +## Design language + +- **Background** `#0B0F15` (deep near-black), **surface** `#121821`, hairline + borders `#1E2733`. +- **Accent** `#22C55E` green for the primary action, the brand mark and the + "connected" indicator. +- Rounded cards, generous spacing, TeamViewer / RustDesk-grade information + hierarchy. Dark-only for now. + +All values are tokens in `lib/theme.dart` (`NetDeskColors`, `NetDeskRadius`, +`NetDeskSpacing`) — no hardcoded hex elsewhere in the app. + +--- + +## How this binds to `netdesk-core` (Phase 3) + +1. **Add the bridge deps.** Uncomment `flutter_rust_bridge` and `ffi` in + `pubspec.yaml`, then `flutter pub get`. +2. **Expose a Rust API.** In `../core`, add a `#[frb]`-annotated API module + (e.g. `src/api/session.rs`) with the calls the UI needs: + `local_id()`, `connect(partner_id, password)`, `disconnect()`, + `list_monitors()`, a frame/event stream, etc. +3. **Generate the glue.** Run `flutter_rust_bridge_codegen generate`, which + emits typed Dart bindings under `lib/src/rust/` and the matching Rust + `frb_generated.rs`. The core is compiled as a C-ABI dylib/staticlib and + linked per platform. +4. **Wire the screens.** + - `ConnectScreen` — the Connect button calls `connect(...)`; the + "This device's ID" area shows `local_id()` instead of the current + placeholder. + - `SessionScreen` — the central placeholder becomes a `Texture` whose + frames are pushed from the core's decoder; pointer/keyboard events are + forwarded back for injection; the toolbar actions (disconnect, + fullscreen, monitors, file transfer, settings) call their core + counterparts. + +Until then every control is a styled, non-functional placeholder so the UX can +be reviewed and polished independently of the core. + +--- + +## Running (once Flutter is installed) + +> Flutter is **not** required to author these files, and was not run to create +> them. Install the Flutter SDK (>= 3.22) to build. + +```bash +cd ui +flutter pub get + +# Desktop +flutter run -d windows +flutter run -d macos +flutter run -d linux + +# Mobile (device/emulator attached) +flutter run -d android +flutter run -d ios +``` + +Enable the desktop targets once with `flutter config --enable-windows-desktop` +(and the `--enable-macos-desktop` / `--enable-linux-desktop` equivalents) if +they are not already on. diff --git a/native/ui/analysis_options.yaml b/native/ui/analysis_options.yaml new file mode 100644 index 0000000..08e8251 --- /dev/null +++ b/native/ui/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/native/ui/android/.gitignore b/native/ui/android/.gitignore new file mode 100644 index 0000000..137dca9 --- /dev/null +++ b/native/ui/android/.gitignore @@ -0,0 +1,18 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks + +# NetDesk: prebuilt Rust .so are build artifacts, regenerated by cargo-ndk +# (packaging\build-android.ps1). Do not commit them. +/app/src/main/jniLibs/ diff --git a/native/ui/android/app/build.gradle.kts b/native/ui/android/app/build.gradle.kts new file mode 100644 index 0000000..465f7de --- /dev/null +++ b/native/ui/android/app/build.gradle.kts @@ -0,0 +1,83 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// --------------------------------------------------------------------------- +// NetDesk release signing. +// Credentials live in android/key.properties (git-ignored). Generate the +// keystore + this file with: packaging\build-android.ps1 -GenerateKeystore +// When key.properties is absent the release build falls back to DEBUG signing +// so `flutter run --release` keeps working for local development. +// --------------------------------------------------------------------------- +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) } +} + +android { + namespace = "space.netdesk" + compileSdk = flutter.compileSdkVersion + + // NetDesk ships prebuilt Rust .so under src/main/jniLibs//libnetdesk_core.so + // (produced by cargo-ndk). Point AGP at the NDK already on disk via + // ANDROID_NDK_HOME so a Gradle build never triggers an NDK download; fall + // back to Flutter's pinned NDK version when the env var is not set. + val ndkHomeEnv = System.getenv("ANDROID_NDK_HOME") + if (ndkHomeEnv != null && file(ndkHomeEnv).exists()) { + ndkPath = ndkHomeEnv + } else { + ndkVersion = flutter.ndkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // NetDesk application id (reverse of netdesk.space). + applicationId = "space.netdesk" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = (keystoreProperties["storeFile"] as String?)?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + } + + buildTypes { + release { + // Use the real upload key when key.properties is present, else debug. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/native/ui/android/app/src/debug/AndroidManifest.xml b/native/ui/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/native/ui/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/native/ui/android/app/src/main/AndroidManifest.xml b/native/ui/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b7ea6b4 --- /dev/null +++ b/native/ui/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt new file mode 100644 index 0000000..4f5d5bb --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/BootReceiver.kt @@ -0,0 +1,42 @@ +package space.netdesk + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * Unattended auto-start: when the device finishes booting, bring the NetDesk host + * back up (if the user enabled unattended access) so the device is reachable + * without anyone opening the app — the Android equivalent of the Windows service. + * + * The receiver only acts when unattended mode is on (stored in SharedPreferences + * by the Flutter settings screen); otherwise it does nothing. + * + * Developed by Akronlogic. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + if (action != Intent.ACTION_BOOT_COMPLETED && + action != "android.intent.action.QUICKBOOT_POWERON" + ) { + return + } + val prefs = context.getSharedPreferences("netdesk", Context.MODE_PRIVATE) + if (!prefs.getBoolean("unattended", false)) return + + val svc = Intent(context, HostService::class.java).apply { + this.action = HostService.ACTION_START + } + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + context.startForegroundService(svc) + } else { + context.startService(svc) + } + } catch (_: Throwable) { + // Some OEMs block background foreground-service starts at boot; the + // user can still start the host from the app. + } + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt new file mode 100644 index 0000000..e514d68 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/HostService.kt @@ -0,0 +1,311 @@ +package space.netdesk + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.DisplayMetrics +import android.view.Surface +import android.view.WindowManager +import kotlin.concurrent.thread + +/** + * NetDesk host foreground service: mirrors the screen through MediaProjection -> + * VirtualDisplay -> a MediaCodec H.264 (AVC) encoder and produces Annex-B frames. + * + * Right now the frames are counted (so on-device / BlueStacks testing confirms + * capture works) — the next step hands each Annex-B access unit to the host side + * of the NetDesk protocol (register by Partner ID, challenge/auth, E2E) so a + * remote viewer can connect, exactly like netdesk_host.py on Windows. + * + * Developed by Akronlogic. + */ +class HostService : Service() { + + companion object { + const val CHANNEL_ID = "netdesk_host" + const val NOTIF_ID = 1001 + const val ACTION_START = "space.netdesk.HOST_START" + const val ACTION_STOP = "space.netdesk.HOST_STOP" + private const val MIME = "video/avc" + + /** Screen-capture grant handed over by MainActivity.onActivityResult. */ + @Volatile + var projectionResultCode: Int = 0 + + @Volatile + var projectionData: Intent? = null + + @Volatile + var running: Boolean = false + + /** Encoded H.264 access units produced so far (capture liveness check). */ + @Volatile + var frameCount: Long = 0 + + /** Capture resolution (advertised to the viewer via host_start). */ + @Volatile + var captureW: Int = 0 + @Volatile + var captureH: Int = 0 + + /** Real display resolution — viewer coords (in capture space) scale to it. */ + @Volatile + var realW: Int = 0 + @Volatile + var realH: Int = 0 + + /** + * Sink for each outbound access unit: payload[0] = keyframe flag (1/0), + * payload[1..] = Annex-B H.264 (SPS/PPS is prepended to every keyframe so + * the frame is self-contained for mid-stream joins + decoder recovery). + * MainActivity sets this to forward over the `space.netdesk/frames` + * EventChannel into Dart -> host_push_frame. + */ + @Volatile + var frameSink: ((ByteArray) -> Unit)? = null + + /** Live instance, so MainActivity can ask for an on-demand keyframe. */ + @Volatile + var instance: HostService? = null + } + + private var projection: MediaProjection? = null + private var virtualDisplay: VirtualDisplay? = null + private var encoder: MediaCodec? = null + private var inputSurface: Surface? = null + @Volatile + private var draining: Boolean = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopHost() + return START_NOT_STICKY + } + goForeground() + running = true + instance = this + startCapture() + return START_STICKY + } + + private fun goForeground() { + val notif = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIF_ID, + notif, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + ) + } else { + startForeground(NOTIF_ID, notif) + } + } + + private fun startCapture() { + val data = projectionData ?: return + val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val proj = mpm.getMediaProjection(projectionResultCode, data) ?: return + projection = proj + projectionData = null + + // The system requires a registered callback before createVirtualDisplay. + proj.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + stopHost() + } + }, null) + + val metrics = DisplayMetrics() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager + val b = wm.currentWindowMetrics.bounds + metrics.widthPixels = b.width() + metrics.heightPixels = b.height() + metrics.densityDpi = resources.configuration.densityDpi + } else { + @Suppress("DEPRECATION") + (getSystemService(Context.WINDOW_SERVICE) as WindowManager) + .defaultDisplay.getRealMetrics(metrics) + } + + // Cap the long edge at 1600 px for bandwidth; keep it even. + var w = metrics.widthPixels + var h = metrics.heightPixels + realW = w + realH = h + val longEdge = maxOf(w, h) + if (longEdge > 1600) { + val s = 1600.0 / longEdge + w = (w * s).toInt() + h = (h * s).toInt() + } + w = w and 1.inv() + h = h and 1.inv() + captureW = w + captureH = h + val dpi = if (metrics.densityDpi > 0) metrics.densityDpi else 320 + + val format = MediaFormat.createVideoFormat(MIME, w, h).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000) + setInteger(MediaFormat.KEY_FRAME_RATE, 30) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + } + val enc = MediaCodec.createEncoderByType(MIME) + enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + inputSurface = enc.createInputSurface() + enc.start() + encoder = enc + + virtualDisplay = proj.createVirtualDisplay( + "netdesk-host", + w, h, dpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + inputSurface, null, null + ) + + draining = true + thread(name = "netdesk-encoder", isDaemon = true) { drainLoop() } + } + + /** Cached SPS/PPS (BUFFER_FLAG_CODEC_CONFIG), prepended to each keyframe. */ + private var csd: ByteArray? = null + + /** Ask the encoder for an immediate IDR (viewer keyframe request / recovery). */ + fun requestKeyframe() { + try { + encoder?.setParameters(Bundle().apply { + putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + }) + } catch (_: Throwable) { + } + } + + private fun drainLoop() { + val enc = encoder ?: return + val info = MediaCodec.BufferInfo() + while (draining) { + val idx = try { + enc.dequeueOutputBuffer(info, 20_000) + } catch (_: IllegalStateException) { + break + } + if (idx >= 0) { + val buf = enc.getOutputBuffer(idx) + if (buf != null && info.size > 0) { + buf.position(info.offset) + buf.limit(info.offset + info.size) + val bytes = ByteArray(info.size) + buf.get(bytes) + val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 + if (isConfig) { + // SPS/PPS — cache it, don't emit on its own. + csd = bytes + } else { + frameCount++ + val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 + // Self-contained keyframe = SPS/PPS || IDR (like libx264). + val au = if (isKey && csd != null) csd!! + bytes else bytes + val sink = frameSink + if (sink != null) { + val payload = ByteArray(au.size + 1) + payload[0] = if (isKey) 1 else 0 + System.arraycopy(au, 0, payload, 1, au.size) + try { + sink(payload) + } catch (_: Throwable) { + } + } + } + } + enc.releaseOutputBuffer(idx, false) + } + } + } + + private fun stopHost() { + running = false + draining = false + if (instance === this) instance = null + csd = null + captureW = 0 + captureH = 0 + try { + virtualDisplay?.release() + } catch (_: Throwable) { + } + virtualDisplay = null + try { + encoder?.stop() + encoder?.release() + } catch (_: Throwable) { + } + encoder = null + try { + inputSurface?.release() + } catch (_: Throwable) { + } + inputSurface = null + try { + projection?.stop() + } catch (_: Throwable) { + } + projection = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + + override fun onDestroy() { + stopHost() + super.onDestroy() + } + + private fun buildNotification(): Notification { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel( + CHANNEL_ID, + "NetDesk Host", + NotificationManager.IMPORTANCE_LOW + ) + ch.description = "Shown while this device can be controlled remotely." + nm.createNotificationChannel(ch) + } + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + return builder + .setContentTitle("NetDesk host is active") + .setContentText("This device can be controlled remotely") + .setSmallIcon(android.R.drawable.ic_menu_view) + .setOngoing(true) + .build() + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt new file mode 100644 index 0000000..b89e916 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/MainActivity.kt @@ -0,0 +1,212 @@ +package space.netdesk + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.TextUtils +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +/** + * Hosts the Flutter UI and bridges the Android "host" role to Dart over the + * `space.netdesk/host` MethodChannel: request the screen-capture permission, + * start/stop the host service, toggle the hidden launcher icon + unattended + * mode, and check / open the accessibility settings. + * + * Developed by Akronlogic. + */ +class MainActivity : FlutterActivity() { + private val channelName = "space.netdesk/host" + private val framesChannel = "space.netdesk/frames" + private val reqProjection = 7001 + private var pendingResult: MethodChannel.Result? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Encoded H.264 access units flow HostService -> here -> Dart, which feeds + // them to the Rust host via host_push_frame. payload[0]=keyframe flag. + EventChannel(flutterEngine.dartExecutor.binaryMessenger, framesChannel) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + HostService.frameSink = { payload -> + mainHandler.post { events?.success(payload) } + } + } + + override fun onCancel(arguments: Any?) { + HostService.frameSink = null + } + }) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "startHost" -> requestProjection(result) + "stopHost" -> { + stopHost(); result.success(true) + } + "isHostRunning" -> result.success(HostService.running) + "lastFrameCount" -> result.success(HostService.frameCount) + "hostScreenSize" -> result.success( + mapOf("w" to HostService.captureW, "h" to HostService.captureH) + ) + "requestKeyframe" -> { + HostService.instance?.requestKeyframe(); result.success(true) + } + "injectTap" -> { + NetDeskAccessibilityService.instance?.tap( + sx(call.argument("x") ?: 0.0), + sy(call.argument("y") ?: 0.0) + ) + result.success(true) + } + "injectSwipe" -> { + NetDeskAccessibilityService.instance?.swipe( + sx(call.argument("x1") ?: 0.0), + sy(call.argument("y1") ?: 0.0), + sx(call.argument("x2") ?: 0.0), + sy(call.argument("y2") ?: 0.0), + (call.argument("dur") ?: 60).toLong() + ) + result.success(true) + } + "injectKey" -> { + NetDeskAccessibilityService.instance?.key( + call.argument("code") ?: "", + call.argument("down") ?: false + ) + result.success(true) + } + "getHostId" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getString("hostId", "") + ) + "getHostPassword" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getString("hostPassword", "") + ) + "setHostCreds" -> { + getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit() + .putString("hostId", call.argument("id") ?: "") + .putString("hostPassword", call.argument("password") ?: "") + .apply() + result.success(true) + } + "setHideIcon" -> { + setHideIcon(call.argument("hide") ?: false) + result.success(true) + } + "isIconHidden" -> result.success(isIconHidden()) + "hasAccessibility" -> result.success(isAccessibilityEnabled()) + "openAccessibilitySettings" -> { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + result.success(true) + } + "setUnattended" -> { + getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit() + .putBoolean("unattended", call.argument("on") ?: false) + .apply() + result.success(true) + } + "isUnattended" -> result.success( + getSharedPreferences("netdesk", Context.MODE_PRIVATE) + .getBoolean("unattended", false) + ) + else -> result.notImplemented() + } + } + } + + private fun requestProjection(result: MethodChannel.Result) { + pendingResult = result + val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + startActivityForResult(mpm.createScreenCaptureIntent(), reqProjection) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != reqProjection) return + if (resultCode == Activity.RESULT_OK && data != null) { + // Hand the capture token to the foreground service, which obtains the + // MediaProjection after it has gone foreground (required on API 34+). + HostService.projectionResultCode = resultCode + HostService.projectionData = data + val svc = Intent(this, HostService::class.java).apply { + action = HostService.ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(svc) + } else { + startService(svc) + } + pendingResult?.success(true) + } else { + pendingResult?.success(false) + } + pendingResult = null + } + + private fun stopHost() { + val svc = Intent(this, HostService::class.java).apply { + action = HostService.ACTION_STOP + } + startService(svc) + } + + /** Scale a viewer x-coord (capture space) to the real display for injection. */ + private fun sx(x: Double): Float { + val cw = HostService.captureW + val rw = HostService.realW + return if (cw > 0 && rw > 0) (x * rw / cw).toFloat() else x.toFloat() + } + + private fun sy(y: Double): Float { + val ch = HostService.captureH + val rh = HostService.realH + return if (ch > 0 && rh > 0) (y * rh / ch).toFloat() else y.toFloat() + } + + private fun setHideIcon(hide: Boolean) { + val alias = ComponentName(this, "space.netdesk.LauncherAlias") + val state = if (hide) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + packageManager.setComponentEnabledSetting(alias, state, PackageManager.DONT_KILL_APP) + } + + private fun isIconHidden(): Boolean { + val alias = ComponentName(this, "space.netdesk.LauncherAlias") + return packageManager.getComponentEnabledSetting(alias) == + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + private fun isAccessibilityEnabled(): Boolean { + val expected = + ComponentName(this, NetDeskAccessibilityService::class.java).flattenToString() + val enabled = Settings.Secure.getString( + contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + val splitter = TextUtils.SimpleStringSplitter(':') + splitter.setString(enabled) + while (splitter.hasNext()) { + if (splitter.next().equals(expected, ignoreCase = true)) return true + } + return false + } +} diff --git a/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt b/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt new file mode 100644 index 0000000..77dade1 --- /dev/null +++ b/native/ui/android/app/src/main/kotlin/space/netdesk/NetDeskAccessibilityService.kt @@ -0,0 +1,84 @@ +package space.netdesk + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.view.accessibility.AccessibilityEvent + +/** + * Remote-input injection for the Android host role. + * + * A normal app can't inject touch events, so NetDesk (like RustDesk) uses an + * AccessibilityService: once the user enables it, [dispatchGesture] can tap, + * swipe and drag anywhere on screen. This is the skeleton; the host session + * calls [tap] / [swipe] with the coordinates a remote viewer sends. + * + * (Full keyboard/text injection and the on-lock-screen limitation are handled in + * later steps — injected input can't reach the secure lock screen, same as + * RustDesk.) + * + * Developed by Akronlogic. + */ +class NetDeskAccessibilityService : AccessibilityService() { + + companion object { + @Volatile + var instance: NetDeskAccessibilityService? = null + } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // The host role drives the device OUTward (injects input); it does not + // need to react to UI events, so this is intentionally empty. + } + + override fun onInterrupt() {} + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + /** Tap (x, y) in screen pixels. */ + fun tap(x: Float, y: Float) { + val path = Path().apply { moveTo(x, y) } + val stroke = GestureDescription.StrokeDescription(path, 0, 1) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + /** Swipe/drag from (x1, y1) to (x2, y2) over [durationMs]. */ + fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long) { + val path = Path().apply { + moveTo(x1, y1) + lineTo(x2, y2) + } + val stroke = + GestureDescription.StrokeDescription(path, 0, durationMs.coerceAtLeast(1)) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + /** + * Map a viewer key (DOM-style code) to an Android navigation action on key + * DOWN. An AccessibilityService can't inject arbitrary key/text events (that + * needs an IME, a later step), but the global navigation actions cover the + * phone's hardware keys — the common remote-control needs. + */ + fun key(code: String, down: Boolean) { + if (!down) return + val action = when (code) { + "Escape", "BrowserBack", "GoBack" -> GLOBAL_ACTION_BACK + "Home", "MetaLeft", "MetaRight", "BrowserHome" -> GLOBAL_ACTION_HOME + "F1", "ContextMenu" -> GLOBAL_ACTION_RECENTS + "AudioVolumeUp", "AudioVolumeDown" -> return // reserved for later + else -> return + } + try { + performGlobalAction(action) + } catch (_: Throwable) { + } + } +} diff --git a/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml b/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..1cb7aa2 --- /dev/null +++ b/native/ui/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/native/ui/android/app/src/main/res/drawable/launch_background.xml b/native/ui/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..8403758 --- /dev/null +++ b/native/ui/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/native/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/native/ui/android/app/src/main/res/values-night/styles.xml b/native/ui/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..360a160 --- /dev/null +++ b/native/ui/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/native/ui/android/app/src/main/res/values/strings.xml b/native/ui/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4736450 --- /dev/null +++ b/native/ui/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + NetDesk + NetDesk uses this to let a remote viewer tap, swipe and type on this device while you are connected. Developed by Akronlogic. + diff --git a/native/ui/android/app/src/main/res/values/styles.xml b/native/ui/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5fac679 --- /dev/null +++ b/native/ui/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml b/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..8310760 --- /dev/null +++ b/native/ui/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,8 @@ + + diff --git a/native/ui/android/app/src/profile/AndroidManifest.xml b/native/ui/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/native/ui/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/native/ui/android/build.gradle.kts b/native/ui/android/build.gradle.kts new file mode 100644 index 0000000..1f88145 --- /dev/null +++ b/native/ui/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/native/ui/android/gradle.properties b/native/ui/android/gradle.properties new file mode 100644 index 0000000..118d7d0 --- /dev/null +++ b/native/ui/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/native/ui/android/gradle/wrapper/gradle-wrapper.properties b/native/ui/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bcef617 --- /dev/null +++ b/native/ui/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/native/ui/android/key.properties.example b/native/ui/android/key.properties.example new file mode 100644 index 0000000..71802c3 --- /dev/null +++ b/native/ui/android/key.properties.example @@ -0,0 +1,14 @@ +# NetDesk release signing - EXAMPLE / TEMPLATE (safe to commit). +# +# Do NOT put real secrets here. Either: +# (a) let the build script create the keystore + the real key.properties: +# $env:NETDESK_KEYSTORE_PASSWORD = '' +# powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore +# (b) or copy this file to key.properties (git-ignored) and fill it in. +# +# The value below is a PLACEHOLDER, not a real password. Replace __SET_ME__. +storePassword=__SET_ME__ +keyPassword=__SET_ME__ +keyAlias=netdesk +# Path is resolved relative to the app module dir (ui/android/app). +storeFile=netdesk-release.keystore diff --git a/native/ui/android/settings.gradle.kts b/native/ui/android/settings.gradle.kts new file mode 100644 index 0000000..c0298e3 --- /dev/null +++ b/native/ui/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/native/ui/assets/icon-512.png b/native/ui/assets/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0b41e190c168b0ddc2fc29e25362e38652e95fc0 GIT binary patch literal 5204 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 Ld&^V{&+z{P!+l`T literal 0 HcmV?d00001 diff --git a/native/ui/assets/icon.png b/native/ui/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3313ec997b307a1930a62d4712099a16994488 GIT binary patch literal 2743 zcmXw*c|6qH8^_O#WyT0oqcZlo8q1AI!Y^t}a&zrVg@%c6QHU&IFa}9oiLP6bFe%xx z-0Y#amQt2vUup_5jD1X)VdnSM@AdoZoYy(e=XuWSoacEz=eDi2sRT+E1ptsR``yGI z02nld0i-B2xC9k=0f3^KnVdQo{&?ZDZwmQ57O@H za~ZedR1rxc2;*z2rOb{I_e+;-TRSu;=9NxF8PDiARF3RnFL~jStl`&={!?~kZ6DaE zct7hi-!L8SxKpWZX7iC8yObrQ_sS|SZOHW##&+YQg{-jIMW=746;B-*;)rWd-{tEW zSUrv|b7HkP>?>oIVwh72(u66ja0wnJZ8YU0Ooy|&`oTbr`uo${ zqnYZE!82W73G2qp+I3f{$Zn~O|8N%(+^kUWr_$_WlJ~_S-KeiTcFW(r{V09`Ipog8*|5^W@cw#mFU-`z>W=)ne0>t`bA*Jo}YgfYh zAf;_}$(3+wg`n2`yVUuyav`Vij&`Mfw+d3V7&2B2Am?*?#P(k_@Zm_^zd-3}7M~+S zF371!aY4v>B1w}RdUZcth}I+wgs)ozAHkEWGefnK0FHrzXBx(dn<}o&E5$jz;y$rk zU;*VLFaSBdr3eNE=ntf>l-KCiMOPOKM+JF;oOQIU%1drCagXw|yke=>d*P-xh$cTk z+>{S=vrA5A!I5If2Q8vSQhYBqz-OaajiPCx3;$P0yq?go2j zi~f^9Jjt#1hdX?8isNi5X2!L#miTYC2?*O-kEKYJ5#ObV72?Ywuj>y$g2jcc;|+w0 z-2j~q)SR@!7%tuM9bz=&?+zh#SyGd`fyn!+;h>XvJw1Ic!PZpUBQ^3~ayLHyTF_d% ztdIf_)>GB^P)C-p*ITL=JQ`qI8eP15U-K;yVSCGCh%oxA+2t9z`#_CY*miC%!t@|G z>PXprpUSUdODj+2xofuRP?WHD@}Gt^%P@ByBel?DXyDp<8E3oS7JC6!M(d2u@HprfV350!?09$DG$-+lks~` zOOMcTf7Du(BuKxR4=NTn7|SJvqS;<|+Fdapp<9i`D4p9n9;oo#8xv@bBVTk$nMKmn z)BPfX$he=GHpXA8`nC_{VX*QfK(4rx{9~pppOE0r1UUM}hpz_7HsDUjGmRvct|IJ+ zXj-9V>DXY@fp`0Io*cRTUax^lQ0-?YC^scUNr$J6wY*diXxu4%sz;v@KZVOUru3t> z3|BhSwi)_0sWrF5K7W8!w3k^TCu9TDN>^c+=@`!5U`1-})Eq5zT){msi!q_zG`5x8HL=GR-S9~3jWX%aj$u6eib zi3qf3rEKoefrvKU)y4^X9T+h1xw|8Uisz`?){|h}G9@*NOq&S6a*U_Acb%rDAjCEUh;wt z$6kk5*0tu=S!-+u)j3XThfNQva;Mc>bzVeU0Hhz4qr@F}p2eTSBnvu+JA3t*KrN2w zUm=7p{k!Re|6@szi)hLi%bxdMHpzW7xmGeL@c%uU?5JUIQ&->xh+^Hy!FH(QSVC7Z z6NOs-Yi3bVODeq?k{qLIj;@U1x&=W|V!NzHkAgo&UOdoug%69+iu@xU zGWajLh-w2PhI&g=GhW*;Vs?8W8OopG;js0E9%}DhAU!d!V5Cr79SXHV6Bcjb&7cTz zv71V1s28*vJ%=IIY3bIZdZzwni`~t|(#b?rp@b zC}f3e4TuX>WLAlKe-T2%Q_o:8901/ws` +/// from the Advanced panel. +const String kDefaultServer = 'wss://netdesk.space/ws'; + +/// The landing / connect screen. +/// +/// Mirrors the mental model of TeamViewer / RustDesk: the brand mark, a big +/// Partner ID field, a session password, and a primary Connect action. Connect +/// calls into the Rust core via flutter_rust_bridge ([nd.connect]) and hands +/// the returned session handle to the [SessionScreen]. +class ConnectScreen extends StatefulWidget { + const ConnectScreen({super.key, this.initError}); + + /// Non-null when the native engine failed to load (shown as a banner). + final String? initError; + + @override + State createState() => _ConnectScreenState(); +} + +class _ConnectScreenState extends State { + final TextEditingController _idController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _serverController = + TextEditingController(text: kDefaultServer); + + bool _obscurePassword = true; + bool _showAdvanced = false; + bool _connecting = false; + + @override + void dispose() { + _idController.dispose(); + _passwordController.dispose(); + _serverController.dispose(); + super.dispose(); + } + + String get _digits => _idController.text.replaceAll(RegExp(r'\s'), ''); + + bool get _canConnect => + !_connecting && _digits.length >= 6 && widget.initError == null; + + Future _connect() async { + if (!_canConnect) return; + FocusScope.of(context).unfocus(); + + final hostId = _digits; + final server = _serverController.text.trim().isEmpty + ? kDefaultServer + : _serverController.text.trim(); + + setState(() => _connecting = true); + try { + final handle = await nd.connect( + wsUrl: server, + hostId: hostId, + password: _passwordController.text, + totp: '', + ); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SessionScreen( + partnerId: _idController.text.trim(), + handle: handle, + ), + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: NetDeskColors.surface, + content: Text('Could not start session: $e'), + ), + ); + } + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + /// The form half of the screen: error banner, connect card, device card and + /// the host-mode button. Shared by the narrow and the wide (two-pane) layouts. + List _formChildren(BuildContext context) { + return [ + if (widget.initError != null) ...[ + _EngineErrorBanner(message: widget.initError!), + const SizedBox(height: NetDeskSpacing.md), + ], + _ConnectCard( + idController: _idController, + passwordController: _passwordController, + serverController: _serverController, + obscurePassword: _obscurePassword, + canConnect: _canConnect, + connecting: _connecting, + showAdvanced: _showAdvanced, + onToggleObscure: () => + setState(() => _obscurePassword = !_obscurePassword), + onToggleAdvanced: () => setState(() => _showAdvanced = !_showAdvanced), + onChanged: () => setState(() {}), + onConnect: _connect, + ), + const SizedBox(height: NetDeskSpacing.md), + const _ThisDeviceCard(), + const SizedBox(height: NetDeskSpacing.md), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HostScreen()), + ), + icon: const Icon(Icons.phonelink_setup, size: 18), + label: const Text('Allow remote control of this device'), + style: OutlinedButton.styleFrom( + foregroundColor: NetDeskColors.textSecondary, + side: const BorderSide(color: NetDeskColors.border), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + // Responsive: the screen must FIT the window on phones, tablets (either + // orientation), split-screen and desktop/DeX windows. + // * wide windows -> brand beside the form (two panes, half the height) + // * short windows -> compact brand + tighter spacing + // * scrolling only remains as a last-resort safety for tiny windows. + return Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, box) { + final wide = box.maxWidth >= 720; + final compact = box.maxHeight < 700; + + if (wide) { + return Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + _Wordmark(), + SizedBox(height: NetDeskSpacing.lg), + _FooterNote(), + ], + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(NetDeskSpacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _formChildren(context), + ), + ), + ), + ), + ), + ], + ); + } + + return Center( + child: SingleChildScrollView( + padding: EdgeInsets.all( + compact ? NetDeskSpacing.md : NetDeskSpacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Wordmark(compact: compact), + SizedBox( + height: compact + ? NetDeskSpacing.md + : NetDeskSpacing.xl), + ..._formChildren(context), + SizedBox( + height: compact + ? NetDeskSpacing.md + : NetDeskSpacing.lg), + const _FooterNote(), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// Brand mark: the NetDesk logo, wordmark and the Akronlogic byline. +/// `compact` shrinks it so short windows (landscape phones, small desktop +/// windows) still fit the whole screen without scrolling. +class _Wordmark extends StatelessWidget { + const _Wordmark({this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + final logo = compact ? 54.0 : 76.0; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: logo, + height: logo, + padding: EdgeInsets.all(compact ? 7 : 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + color: NetDeskColors.surface, + border: Border.all(color: NetDeskColors.border), + boxShadow: const [ + BoxShadow( + color: NetDeskColors.accentGlow, + blurRadius: 36, + spreadRadius: 1, + ), + ], + ), + child: Image.asset( + 'assets/icon.png', + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), + ), + SizedBox(height: compact ? NetDeskSpacing.sm : NetDeskSpacing.md), + Text( + 'NetDesk', + style: TextStyle( + fontSize: compact ? 28 : 40, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'by Akronlogic', + style: TextStyle( + fontSize: compact ? 12 : 13, + fontWeight: FontWeight.w600, + letterSpacing: 2.4, + color: NetDeskColors.textSecondary.withValues(alpha: 0.9), + ), + ), + ], + ); + } +} + +/// The main card: Partner ID + password + Connect (+ optional Advanced server). +class _ConnectCard extends StatelessWidget { + const _ConnectCard({ + required this.idController, + required this.passwordController, + required this.serverController, + required this.obscurePassword, + required this.canConnect, + required this.connecting, + required this.showAdvanced, + required this.onToggleObscure, + required this.onToggleAdvanced, + required this.onChanged, + required this.onConnect, + }); + + final TextEditingController idController; + final TextEditingController passwordController; + final TextEditingController serverController; + final bool obscurePassword; + final bool canConnect; + final bool connecting; + final bool showAdvanced; + final VoidCallback onToggleObscure; + final VoidCallback onToggleAdvanced; + final VoidCallback onChanged; + final VoidCallback onConnect; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(NetDeskSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _FieldLabel('Partner ID'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: idController, + onChanged: (_) => onChanged(), + enabled: !connecting, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PartnerIdFormatter(), + ], + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: 3, + color: NetDeskColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: '000 000 000', + prefixIcon: + Icon(Icons.badge_outlined, color: NetDeskColors.textMuted), + ), + ), + const SizedBox(height: NetDeskSpacing.md), + const _FieldLabel('Password'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: passwordController, + onChanged: (_) => onChanged(), + enabled: !connecting, + obscureText: obscurePassword, + onSubmitted: (_) => canConnect ? onConnect() : null, + decoration: InputDecoration( + hintText: 'Session password', + prefixIcon: const Icon(Icons.lock_outline, + color: NetDeskColors.textMuted), + suffixIcon: IconButton( + tooltip: obscurePassword ? 'Show' : 'Hide', + onPressed: onToggleObscure, + icon: Icon( + obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 180), + crossFadeState: showAdvanced + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox(width: double.infinity), + secondChild: Padding( + padding: const EdgeInsets.only(top: NetDeskSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _FieldLabel('Server'), + const SizedBox(height: NetDeskSpacing.xs), + TextField( + controller: serverController, + enabled: !connecting, + keyboardType: TextInputType.url, + autocorrect: false, + style: const TextStyle( + fontSize: 14, + color: NetDeskColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: 'wss://netdesk.space/ws', + prefixIcon: Icon(Icons.dns_outlined, + color: NetDeskColors.textMuted), + ), + ), + ], + ), + ), + ), + const SizedBox(height: NetDeskSpacing.lg), + FilledButton.icon( + onPressed: canConnect ? onConnect : null, + icon: connecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Color(0xFF04210F), + ), + ) + : const Icon(Icons.bolt_rounded, size: 20), + label: Text(connecting ? 'Connecting…' : 'Connect'), + ), + const SizedBox(height: NetDeskSpacing.xs), + Align( + alignment: Alignment.center, + child: TextButton.icon( + onPressed: onToggleAdvanced, + style: TextButton.styleFrom( + foregroundColor: NetDeskColors.textMuted, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + icon: Icon( + showAdvanced + ? Icons.keyboard_arrow_up_rounded + : Icons.tune_rounded, + size: 16, + ), + label: Text(showAdvanced ? 'Hide advanced' : 'Advanced'), + ), + ), + ], + ), + ), + ); + } +} + +/// Small card standing in for *this* device's own shareable identity. +class _ThisDeviceCard extends StatelessWidget { + const _ThisDeviceCard(); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.lg, + vertical: NetDeskSpacing.md, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: NetDeskColors.accentGlow, + borderRadius: BorderRadius.circular(NetDeskRadius.sm), + ), + child: const Icon(Icons.smartphone_rounded, + color: NetDeskColors.accent, size: 20), + ), + const SizedBox(width: NetDeskSpacing.md), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This device', + style: TextStyle( + fontSize: 12, + letterSpacing: 0.4, + color: NetDeskColors.textMuted, + ), + ), + SizedBox(height: 2), + Text( + 'Incoming control — tap “Allow” below', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: NetDeskColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + ), + child: const Text( + 'BETA', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: NetDeskColors.textMuted, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _EngineErrorBanner extends StatelessWidget { + const _EngineErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(NetDeskSpacing.md), + decoration: BoxDecoration( + color: NetDeskColors.danger.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.danger.withValues(alpha: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline_rounded, + color: NetDeskColors.danger, size: 20), + const SizedBox(width: NetDeskSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Engine not loaded', + style: TextStyle( + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + message, + style: const TextStyle( + fontSize: 12, + color: NetDeskColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: NetDeskColors.textSecondary, + ), + ); + } +} + +class _FooterNote extends StatelessWidget { + const _FooterNote(); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.shield_outlined, size: 14, color: NetDeskColors.textMuted), + SizedBox(width: 6), + Text( + 'End-to-end encrypted · v0.1.0', + style: TextStyle(fontSize: 12, color: NetDeskColors.textMuted), + ), + ], + ); + } +} + +/// Formats up to 12 digits as "123 456 789" while typing. +class _PartnerIdFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var digits = newValue.text.replaceAll(RegExp(r'\s'), ''); + if (digits.length > 12) digits = digits.substring(0, 12); + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i != 0 && i % 3 == 0) buffer.write(' '); + buffer.write(digits[i]); + } + final formatted = buffer.toString(); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } +} diff --git a/native/ui/lib/host_screen.dart b/native/ui/lib/host_screen.dart new file mode 100644 index 0000000..e0cf53b --- /dev/null +++ b/native/ui/lib/host_screen.dart @@ -0,0 +1,539 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'rust/api.dart' as nd; +import 'theme.dart'; + +/// "Allow remote control" — the Android HOST role. +/// +/// Turns this device into a NetDesk host (like RustDesk): once the user grants +/// screen capture and enables the accessibility service, a remote viewer can see +/// and control it. Talks to the native side over the `space.netdesk/host` +/// MethodChannel (see MainActivity.kt / HostService.kt). +/// +/// Preview: screen capture + the controls are live; the remote-connection wiring +/// (register by Partner ID, send the encrypted H.264 to a viewer) lands next. +class HostScreen extends StatefulWidget { + const HostScreen({super.key}); + + @override + State createState() => _HostScreenState(); +} + +class _HostScreenState extends State with WidgetsBindingObserver { + static const _host = MethodChannel('space.netdesk/host'); + static const _frameChannel = EventChannel('space.netdesk/frames'); + static const _wsUrl = 'wss://netdesk.space/ws'; + + bool _running = false; + bool _accessibility = false; + bool _hideIcon = false; + bool _unattended = false; + int _frames = 0; + bool _busy = false; + + // Rust host session. + BigInt? _handle; + String _id = ''; + String _password = ''; + bool _viewerConnected = false; + StreamSubscription? _frameSub; + StreamSubscription? _eventSub; + + // Viewer-mouse -> touch gesture interpretation (coords are in capture space; + // MainActivity scales to the real display before injecting). + int _btns = 0; + double _downX = 0, _downY = 0, _lastX = 0, _lastY = 0; + int _downMs = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _frameSub?.cancel(); + _eventSub?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _refresh(); + } + + Future _call(String method, [Map? args]) async { + try { + return await _host.invokeMethod(method, args); + } catch (_) { + return null; + } + } + + Future _refresh() async { + final running = await _call('isHostRunning') ?? false; + final acc = await _call('hasAccessibility') ?? false; + final hide = await _call('isIconHidden') ?? false; + final un = await _call('isUnattended') ?? false; + final frames = await _call('lastFrameCount') ?? 0; + if (mounted) { + setState(() { + _running = running; + _accessibility = acc; + _hideIcon = hide; + _unattended = un; + _frames = frames; + }); + } + } + + Future _toggleHost() async { + setState(() => _busy = true); + if (_running) { + await _stopHostFull(); + } else { + await _startHostFull(); + } + if (mounted) setState(() => _busy = false); + } + + /// Load the persisted host id + unattended password, generating them once. + Future _loadCreds() async { + var id = await _call('getHostId') ?? ''; + var pw = await _call('getHostPassword') ?? ''; + if (id.isEmpty || pw.isEmpty) { + id = nd.hostGenId(); + pw = nd.hostGenPassword(); + await _call('setHostCreds', {'id': id, 'password': pw}); + } + if (mounted) { + setState(() { + _id = id; + _password = pw; + }); + } + } + + Future _startHostFull() async { + await _loadCreds(); + // 1) screen-capture consent -> HostService starts encoding. + final ok = await _call('startHost') ?? false; + if (!ok) { + await _refresh(); + return; + } + // 2) wait for the encoder to report a capture size. + int w = 0, h = 0; + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 200)); + final s = await _call>('hostScreenSize'); + if (s != null && (s['w'] as int? ?? 0) > 0) { + w = s['w'] as int; + h = s['h'] as int; + break; + } + } + if (w == 0) { + await _refresh(); + return; + } + // 3) register the Rust host + wire frames out and input in. + final handle = await nd.hostStart( + wsUrl: _wsUrl, + hostId: _id, + name: 'Android', + password: _password, + width: w, + height: h, + ); + _handle = handle; + _frameSub = _frameChannel.receiveBroadcastStream().listen(_onFrame); + _eventSub = nd.hostEvents(handle: handle).listen(_onHostEvent); + await _refresh(); + } + + Future _stopHostFull() async { + await _frameSub?.cancel(); + _frameSub = null; + await _eventSub?.cancel(); + _eventSub = null; + final h = _handle; + _handle = null; + if (h != null) nd.hostStop(handle: h); + await _call('stopHost'); + if (mounted) setState(() => _viewerConnected = false); + await Future.delayed(const Duration(milliseconds: 300)); + await _refresh(); + } + + void _onFrame(dynamic payload) { + final h = _handle; + if (h == null || payload is! Uint8List || payload.isEmpty) return; + final keyframe = payload[0] == 1; + final data = Uint8List.sublistView(payload, 1); + nd.hostPushFrame(handle: h, data: data, keyframe: keyframe); + } + + void _onHostEvent(nd.NdHostEvent ev) { + switch (ev) { + case nd.NdHostEvent_Mouse(:final x, :final y, :final buttons, :final wheel): + _onMouse(x, y, buttons, wheel); + case nd.NdHostEvent_Key(:final code, :final down): + _call('injectKey', {'code': code, 'down': down}); + case nd.NdHostEvent_KeyframeRequested(): + _call('requestKeyframe'); + case nd.NdHostEvent_ViewerConnected(): + if (mounted) setState(() => _viewerConnected = true); + case nd.NdHostEvent_ViewerLeft(): + if (mounted) setState(() => _viewerConnected = false); + default: + break; + } + } + + /// Translate the viewer's absolute pointer into Android gestures: a quick + /// press+release = tap, a moved drag = swipe, wheel = scroll. + void _onMouse(int x, int y, int buttons, double wheel) { + if (wheel != 0) { + final dy = (wheel.clamp(-3.0, 3.0) * 140).round(); + _call('injectSwipe', { + 'x1': x.toDouble(), 'y1': y.toDouble(), + 'x2': x.toDouble(), 'y2': (y + dy).toDouble(), 'dur': 120, + }); + return; + } + final leftNow = (buttons & 1) != 0; + final leftWas = (_btns & 1) != 0; + _btns = buttons; + if (leftNow && !leftWas) { + _downX = x.toDouble(); + _downY = y.toDouble(); + _lastX = _downX; + _lastY = _downY; + _downMs = DateTime.now().millisecondsSinceEpoch; + } else if (leftNow && leftWas) { + _lastX = x.toDouble(); + _lastY = y.toDouble(); + } else if (!leftNow && leftWas) { + _lastX = x.toDouble(); + _lastY = y.toDouble(); + final dist = (_lastX - _downX).abs() + (_lastY - _downY).abs(); + if (dist < 12) { + _call('injectTap', {'x': _downX, 'y': _downY}); + } else { + final dur = (DateTime.now().millisecondsSinceEpoch - _downMs) + .clamp(40, 800); + _call('injectSwipe', { + 'x1': _downX, 'y1': _downY, 'x2': _lastX, 'y2': _lastY, 'dur': dur, + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: NetDeskColors.background, + appBar: AppBar( + backgroundColor: NetDeskColors.background, + elevation: 0, + title: const Text('Allow remote control'), + ), + body: SafeArea( + child: RefreshIndicator( + color: NetDeskColors.accent, + onRefresh: _refresh, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _statusCard(), + if (_running && _id.isNotEmpty) ...[ + const SizedBox(height: 14), + _credsCard(), + ], + const SizedBox(height: 14), + _startStopButton(), + const SizedBox(height: 18), + _tile( + icon: Icons.touch_app_outlined, + title: 'Remote input (accessibility)', + subtitle: _accessibility + ? 'Enabled — a viewer can tap and swipe this device.' + : 'Required so a viewer can tap and swipe. Tap to enable.', + trailing: _accessibility + ? const Icon(Icons.check_circle, color: NetDeskColors.accent) + : const Icon(Icons.chevron_right, + color: NetDeskColors.textMuted), + onTap: _accessibility + ? null + : () async { + await _call('openAccessibilitySettings'); + }, + ), + _switchTile( + icon: Icons.schedule, + title: 'Unattended access', + subtitle: 'Start the host automatically after the device reboots.', + value: _unattended, + onChanged: (v) async { + await _call('setUnattended', {'on': v}); + await _refresh(); + }, + ), + _switchTile( + icon: Icons.visibility_off_outlined, + title: 'Hide app icon', + subtitle: + 'Remove NetDesk from the app drawer (it keeps running). ' + 'Reopen it from your browser at netdesk.space.', + value: _hideIcon, + onChanged: (v) async { + await _call('setHideIcon', {'hide': v}); + await _refresh(); + }, + ), + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Enter the ID and password above in any NetDesk viewer ' + '(Windows, web or another phone) to see and control this ' + 'device. Keep the screen-capture notification on. ' + 'Developed by Akronlogic.', + style: TextStyle( + color: NetDeskColors.textMuted, fontSize: 12, height: 1.4), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _statusCard() { + final on = _running; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + border: Border.all(color: NetDeskColors.border), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: on ? NetDeskColors.accentGlow : NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + on ? Icons.cast_connected : Icons.cast, + color: on ? NetDeskColors.accent : NetDeskColors.textSecondary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + on ? 'Host is active' : 'Host is off', + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + on + ? 'Capturing the screen · $_frames frames encoded' + : 'This device cannot be controlled yet', + style: const TextStyle( + color: NetDeskColors.textSecondary, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _credsCard() { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.lg), + border: Border.all( + color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.border, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _viewerConnected ? Icons.lock_person : Icons.vpn_key_outlined, + size: 18, + color: _viewerConnected + ? NetDeskColors.accent + : NetDeskColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + _viewerConnected ? 'A viewer is connected' : 'Ready for a viewer', + style: TextStyle( + color: _viewerConnected + ? NetDeskColors.accent + : NetDeskColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 14), + _credRow('Your ID', _id), + const SizedBox(height: 10), + _credRow('Password', _password), + ], + ), + ); + } + + Widget _credRow(String label, String value) { + return Row( + children: [ + SizedBox( + width: 86, + child: Text(label, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 13)), + ), + Expanded( + child: SelectableText( + value, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 19, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded, + size: 18, color: NetDeskColors.textSecondary), + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ); + } + + Widget _startStopButton() { + return SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _busy ? null : _toggleHost, + style: ElevatedButton.styleFrom( + backgroundColor: + _running ? NetDeskColors.danger : NetDeskColors.accent, + foregroundColor: _running ? Colors.white : const Color(0xFF04210F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + child: _busy + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + : Text( + _running ? 'Stop hosting' : 'Start hosting this device', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ); + } + + Widget _tile({ + required IconData icon, + required String title, + required String subtitle, + Widget? trailing, + VoidCallback? onTap, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.border), + ), + child: ListTile( + onTap: onTap, + leading: Icon(icon, color: NetDeskColors.textSecondary), + title: Text(title, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontWeight: FontWeight.w600)), + subtitle: Text(subtitle, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 12.5)), + trailing: trailing, + ), + ); + } + + Widget _switchTile({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: NetDeskColors.surface, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: NetDeskColors.border), + ), + child: SwitchListTile( + value: value, + onChanged: onChanged, + activeThumbColor: NetDeskColors.accent, + secondary: Icon(icon, color: NetDeskColors.textSecondary), + title: Text(title, + style: const TextStyle( + color: NetDeskColors.textPrimary, + fontWeight: FontWeight.w600)), + subtitle: Text(subtitle, + style: const TextStyle( + color: NetDeskColors.textMuted, fontSize: 12.5)), + ), + ); + } +} diff --git a/native/ui/lib/main.dart b/native/ui/lib/main.dart new file mode 100644 index 0000000..cc53d92 --- /dev/null +++ b/native/ui/lib/main.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'connect_screen.dart'; +import 'rust/frb_generated.dart'; +import 'theme.dart'; + +/// Entry point. +/// +/// Before the first widget builds we initialise flutter_rust_bridge, which +/// loads the native `netdesk_frb` library (built from the Rust `netdesk-frb` +/// crate over `netdesk-core`). All session work — signaling, the transport, +/// input injection and the H.264 decode — runs behind that FFI layer; the Dart +/// side only paints frames and forwards input. +/// +/// If the engine fails to load we still start the app, surfacing the error on +/// the connect screen instead of crashing to a black window. +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Behave like a true Android app: allow every orientation (tablets are used + // in landscape), and draw behind the system bars for an edge-to-edge look. + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + String? initError; + try { + await RustLib.init(); + } catch (e) { + initError = '$e'; + } + + runApp(NetDeskApp(initError: initError)); +} + +/// Root application widget. +/// +/// NetDesk is Akronlogic's premium cross-platform remote-desktop client. This +/// Flutter layer is the touch-native front-end; it binds to the Rust +/// `netdesk-core` crate via flutter_rust_bridge to drive the whole session. +class NetDeskApp extends StatelessWidget { + const NetDeskApp({super.key, this.initError}); + + /// Non-null when the native engine could not be loaded at startup. + final String? initError; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'NetDesk', + debugShowCheckedModeBanner: false, + theme: NetDeskTheme.dark, + darkTheme: NetDeskTheme.dark, + themeMode: ThemeMode.dark, + home: ConnectScreen(initError: initError), + ); + } +} diff --git a/native/ui/lib/rust/api.dart b/native/ui/lib/rust/api.dart new file mode 100644 index 0000000..67f3a7b --- /dev/null +++ b/native/ui/lib/rust/api.dart @@ -0,0 +1,258 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; +part 'api.freezed.dart'; + +// These functions are ignored because they are not marked as `pub`: `cancels`, `host_cancels`, `host_frames`, `host_pending`, `map_host_event`, `pending`, `rgb_to_rgba`, `runtime`, `sessions`, `with_session` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt` + +/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and +/// return its **handle**. The actual connect + handshake + H.264 decode run on +/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with +/// [`session_events`] (passing this handle), and drive input / control with the +/// other functions here. A failed connect still returns a valid handle and +/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream. +Future connect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}) => + RustLib.instance.api.crateApiConnect( + wsUrl: wsUrl, hostId: hostId, password: password, totp: totp); + +/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`]. +/// In Dart this is `Stream sessionEvents({required BigInt handle})`. +/// Call it once per handle, right after `connect`. +Stream sessionEvents({required BigInt handle}) => + RustLib.instance.api.crateApiSessionEvents(handle: handle); + +/// Send a pointer event (coordinates in the remote screen's pixel space). +/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas. +void sendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}) => + RustLib.instance.api.crateApiSendMouse( + handle: handle, + x: x, + y: y, + buttons: buttons, + wheel: wheel, + hwheel: hwheel); + +/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter"). +void sendKey( + {required BigInt handle, required String code, required bool down}) => + RustLib.instance.api + .crateApiSendKey(handle: handle, code: code, down: down); + +/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`. +void setQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}) => + RustLib.instance.api.crateApiSetQuality( + handle: handle, fps: fps, bitrate: bitrate, scale: scale); + +/// Switch the captured monitor to index `i` (as advertised by +/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the +/// new display paints without waiting for the next natural IDR — the messages +/// travel the same WebSocket in order, so the keyframe is of the *new* monitor. +void monitorSwitch({required BigInt handle, required int i}) => + RustLib.instance.api.crateApiMonitorSwitch(handle: handle, i: i); + +/// The real list of displays the host reported (its `info.monitors`). The UI +/// uses this to show exactly the monitors that exist — never a hardcoded count. +/// Returns empty until the session is accepted, or if the host sent none. +List sessionMonitors({required BigInt handle}) => + RustLib.instance.api.crateApiSessionMonitors(handle: handle); + +/// The monitor index the host is currently capturing (as reported at accept). +int sessionCurrentMonitor({required BigInt handle}) => + RustLib.instance.api.crateApiSessionCurrentMonitor(handle: handle); + +/// Send Ctrl+Alt+Del on the host. +void ctrlAltDel({required BigInt handle}) => + RustLib.instance.api.crateApiCtrlAltDel(handle: handle); + +/// Send a chat message to the host. +void sendChat({required BigInt handle, required String text}) => + RustLib.instance.api.crateApiSendChat(handle: handle, text: text); + +/// Push text to the remote clipboard (and announce our local clipboard). +void setClipboard({required BigInt handle, required String text}) => + RustLib.instance.api.crateApiSetClipboard(handle: handle, text: text); + +/// End the session: tell the host, then drop the handle. The event stream +/// receives a final [`NdEvent::Ended`] when the network task unwinds. +void disconnect({required BigInt handle}) => + RustLib.instance.api.crateApiDisconnect(handle: handle); + +/// A fresh 9-digit host id to persist on the device (shown to viewers). +String hostGenId() => RustLib.instance.api.crateApiHostGenId(); + +/// A fresh friendly unattended password (no look-alike chars). +String hostGenPassword() => RustLib.instance.api.crateApiHostGenPassword(); + +/// Start hosting: register `host_id` with the server and serve one viewer at a +/// time, authenticating against `password`. Returns a handle; drive input/ +/// control via [`host_events`], feed video via [`host_push_frame`], stop with +/// [`host_stop`]. Credentials are derived fresh from `password` each run (the +/// challenge carries the salt, so the viewer only needs the password). +Future hostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}) => + RustLib.instance.api.crateApiHostStart( + wsUrl: wsUrl, + hostId: hostId, + name: name, + password: password, + width: width, + height: height); + +/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`]. +Stream hostEvents({required BigInt handle}) => + RustLib.instance.api.crateApiHostEvents(handle: handle); + +/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap +/// + non-blocking; dropped when no viewer is connected. +void hostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}) => + RustLib.instance.api + .crateApiHostPushFrame(handle: handle, data: data, keyframe: keyframe); + +/// Stop hosting: end the reconnect loop and drop the handle. +void hostStop({required BigInt handle}) => + RustLib.instance.api.crateApiHostStop(handle: handle); + +@freezed +sealed class NdEvent with _$NdEvent { + const NdEvent._(); + + /// The WebSocket is dialing / the handshake is in flight. + const factory NdEvent.connecting() = NdEvent_Connecting; + + /// Session accepted; the remote screen is about to stream. + const factory NdEvent.connected({ + required int w, + required int h, + required bool e2E, + required String hostName, + }) = NdEvent_Connected; + + /// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`). + const factory NdEvent.frame({ + required int width, + required int height, + required Uint8List rgba, + }) = NdEvent_Frame; + + /// The session ended (socket closed, host ended it, connect failed, …). + const factory NdEvent.ended({ + required String reason, + }) = NdEvent_Ended; + + /// The host refused the session (bad password/TOTP, busy, blocked, …). + const factory NdEvent.denied({ + required String reason, + }) = NdEvent_Denied; + + /// A chat message from the host. + const factory NdEvent.chatMessage({ + required String from, + required String text, + }) = NdEvent_ChatMessage; + + /// Remote clipboard text pushed by the host (RustDesk-style sync). + const factory NdEvent.clipboard({ + required String text, + }) = NdEvent_Clipboard; +} + +@freezed +sealed class NdHostEvent with _$NdHostEvent { + const NdHostEvent._(); + + /// Registered with the server; the device is reachable at its host id. + const factory NdHostEvent.registered() = NdHostEvent_Registered; + + /// A viewer authenticated; the session is live (inject input, feed frames). + const factory NdHostEvent.viewerConnected({ + required bool e2E, + }) = NdHostEvent_ViewerConnected; + + /// The viewer left; the host stays registered for the next one. + const factory NdHostEvent.viewerLeft() = NdHostEvent_ViewerLeft; + + /// Pointer in the captured image's pixel space (buttons = bitmask). + const factory NdHostEvent.mouse({ + required int x, + required int y, + required int buttons, + required double wheel, + }) = NdHostEvent_Mouse; + + /// Key: DOM-style code + down/up. + const factory NdHostEvent.key({ + required String code, + required bool down, + }) = NdHostEvent_Key; + + /// The viewer asked for a fresh keyframe — request one from the encoder. + const factory NdHostEvent.keyframeRequested() = NdHostEvent_KeyframeRequested; + + /// The viewer changed quality targets. + const factory NdHostEvent.quality({ + required int fps, + required int bitrate, + required double scale, + }) = NdHostEvent_Quality; + + /// The server link dropped; auto-reconnecting. + const factory NdHostEvent.reconnecting() = NdHostEvent_Reconnecting; + + /// The host loop stopped for good (host_stop / fatal). + const factory NdHostEvent.ended({ + required String reason, + }) = NdHostEvent_Ended; +} + +/// A display the host advertised at accept time (index + pixel size). +class NdMonitor { + final int i; + final int w; + final int h; + + const NdMonitor({ + required this.i, + required this.w, + required this.h, + }); + + @override + int get hashCode => i.hashCode ^ w.hashCode ^ h.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NdMonitor && + runtimeType == other.runtimeType && + i == other.i && + w == other.w && + h == other.h; +} diff --git a/native/ui/lib/rust/api.freezed.dart b/native/ui/lib/rust/api.freezed.dart new file mode 100644 index 0000000..c0a3a09 --- /dev/null +++ b/native/ui/lib/rust/api.freezed.dart @@ -0,0 +1,3083 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'api.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$NdEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NdEventCopyWith<$Res> { + factory $NdEventCopyWith(NdEvent value, $Res Function(NdEvent) then) = + _$NdEventCopyWithImpl<$Res, NdEvent>; +} + +/// @nodoc +class _$NdEventCopyWithImpl<$Res, $Val extends NdEvent> + implements $NdEventCopyWith<$Res> { + _$NdEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$NdEvent_ConnectingImplCopyWith<$Res> { + factory _$$NdEvent_ConnectingImplCopyWith(_$NdEvent_ConnectingImpl value, + $Res Function(_$NdEvent_ConnectingImpl) then) = + __$$NdEvent_ConnectingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdEvent_ConnectingImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ConnectingImpl> + implements _$$NdEvent_ConnectingImplCopyWith<$Res> { + __$$NdEvent_ConnectingImplCopyWithImpl(_$NdEvent_ConnectingImpl _value, + $Res Function(_$NdEvent_ConnectingImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdEvent_ConnectingImpl extends NdEvent_Connecting { + const _$NdEvent_ConnectingImpl() : super._(); + + @override + String toString() { + return 'NdEvent.connecting()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$NdEvent_ConnectingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return connecting(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return connecting?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (connecting != null) { + return connecting(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return connecting(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return connecting?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (connecting != null) { + return connecting(this); + } + return orElse(); + } +} + +abstract class NdEvent_Connecting extends NdEvent { + const factory NdEvent_Connecting() = _$NdEvent_ConnectingImpl; + const NdEvent_Connecting._() : super._(); +} + +/// @nodoc +abstract class _$$NdEvent_ConnectedImplCopyWith<$Res> { + factory _$$NdEvent_ConnectedImplCopyWith(_$NdEvent_ConnectedImpl value, + $Res Function(_$NdEvent_ConnectedImpl) then) = + __$$NdEvent_ConnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({int w, int h, bool e2E, String hostName}); +} + +/// @nodoc +class __$$NdEvent_ConnectedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ConnectedImpl> + implements _$$NdEvent_ConnectedImplCopyWith<$Res> { + __$$NdEvent_ConnectedImplCopyWithImpl(_$NdEvent_ConnectedImpl _value, + $Res Function(_$NdEvent_ConnectedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? w = null, + Object? h = null, + Object? e2E = null, + Object? hostName = null, + }) { + return _then(_$NdEvent_ConnectedImpl( + w: null == w + ? _value.w + : w // ignore: cast_nullable_to_non_nullable + as int, + h: null == h + ? _value.h + : h // ignore: cast_nullable_to_non_nullable + as int, + e2E: null == e2E + ? _value.e2E + : e2E // ignore: cast_nullable_to_non_nullable + as bool, + hostName: null == hostName + ? _value.hostName + : hostName // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ConnectedImpl extends NdEvent_Connected { + const _$NdEvent_ConnectedImpl( + {required this.w, + required this.h, + required this.e2E, + required this.hostName}) + : super._(); + + @override + final int w; + @override + final int h; + @override + final bool e2E; + @override + final String hostName; + + @override + String toString() { + return 'NdEvent.connected(w: $w, h: $h, e2E: $e2E, hostName: $hostName)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ConnectedImpl && + (identical(other.w, w) || other.w == w) && + (identical(other.h, h) || other.h == h) && + (identical(other.e2E, e2E) || other.e2E == e2E) && + (identical(other.hostName, hostName) || + other.hostName == hostName)); + } + + @override + int get hashCode => Object.hash(runtimeType, w, h, e2E, hostName); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ConnectedImplCopyWith<_$NdEvent_ConnectedImpl> get copyWith => + __$$NdEvent_ConnectedImplCopyWithImpl<_$NdEvent_ConnectedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return connected(w, h, e2E, hostName); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return connected?.call(w, h, e2E, hostName); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (connected != null) { + return connected(w, h, e2E, hostName); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return connected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return connected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (connected != null) { + return connected(this); + } + return orElse(); + } +} + +abstract class NdEvent_Connected extends NdEvent { + const factory NdEvent_Connected( + {required final int w, + required final int h, + required final bool e2E, + required final String hostName}) = _$NdEvent_ConnectedImpl; + const NdEvent_Connected._() : super._(); + + int get w; + int get h; + bool get e2E; + String get hostName; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ConnectedImplCopyWith<_$NdEvent_ConnectedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_FrameImplCopyWith<$Res> { + factory _$$NdEvent_FrameImplCopyWith( + _$NdEvent_FrameImpl value, $Res Function(_$NdEvent_FrameImpl) then) = + __$$NdEvent_FrameImplCopyWithImpl<$Res>; + @useResult + $Res call({int width, int height, Uint8List rgba}); +} + +/// @nodoc +class __$$NdEvent_FrameImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_FrameImpl> + implements _$$NdEvent_FrameImplCopyWith<$Res> { + __$$NdEvent_FrameImplCopyWithImpl( + _$NdEvent_FrameImpl _value, $Res Function(_$NdEvent_FrameImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? width = null, + Object? height = null, + Object? rgba = null, + }) { + return _then(_$NdEvent_FrameImpl( + width: null == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as int, + height: null == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as int, + rgba: null == rgba + ? _value.rgba + : rgba // ignore: cast_nullable_to_non_nullable + as Uint8List, + )); + } +} + +/// @nodoc + +class _$NdEvent_FrameImpl extends NdEvent_Frame { + const _$NdEvent_FrameImpl( + {required this.width, required this.height, required this.rgba}) + : super._(); + + @override + final int width; + @override + final int height; + @override + final Uint8List rgba; + + @override + String toString() { + return 'NdEvent.frame(width: $width, height: $height, rgba: $rgba)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_FrameImpl && + (identical(other.width, width) || other.width == width) && + (identical(other.height, height) || other.height == height) && + const DeepCollectionEquality().equals(other.rgba, rgba)); + } + + @override + int get hashCode => Object.hash( + runtimeType, width, height, const DeepCollectionEquality().hash(rgba)); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_FrameImplCopyWith<_$NdEvent_FrameImpl> get copyWith => + __$$NdEvent_FrameImplCopyWithImpl<_$NdEvent_FrameImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return frame(width, height, rgba); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return frame?.call(width, height, rgba); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (frame != null) { + return frame(width, height, rgba); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return frame(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return frame?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (frame != null) { + return frame(this); + } + return orElse(); + } +} + +abstract class NdEvent_Frame extends NdEvent { + const factory NdEvent_Frame( + {required final int width, + required final int height, + required final Uint8List rgba}) = _$NdEvent_FrameImpl; + const NdEvent_Frame._() : super._(); + + int get width; + int get height; + Uint8List get rgba; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_FrameImplCopyWith<_$NdEvent_FrameImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_EndedImplCopyWith<$Res> { + factory _$$NdEvent_EndedImplCopyWith( + _$NdEvent_EndedImpl value, $Res Function(_$NdEvent_EndedImpl) then) = + __$$NdEvent_EndedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdEvent_EndedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_EndedImpl> + implements _$$NdEvent_EndedImplCopyWith<$Res> { + __$$NdEvent_EndedImplCopyWithImpl( + _$NdEvent_EndedImpl _value, $Res Function(_$NdEvent_EndedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdEvent_EndedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_EndedImpl extends NdEvent_Ended { + const _$NdEvent_EndedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdEvent.ended(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_EndedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_EndedImplCopyWith<_$NdEvent_EndedImpl> get copyWith => + __$$NdEvent_EndedImplCopyWithImpl<_$NdEvent_EndedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return ended(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return ended?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (ended != null) { + return ended(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return ended(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return ended?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (ended != null) { + return ended(this); + } + return orElse(); + } +} + +abstract class NdEvent_Ended extends NdEvent { + const factory NdEvent_Ended({required final String reason}) = + _$NdEvent_EndedImpl; + const NdEvent_Ended._() : super._(); + + String get reason; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_EndedImplCopyWith<_$NdEvent_EndedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_DeniedImplCopyWith<$Res> { + factory _$$NdEvent_DeniedImplCopyWith(_$NdEvent_DeniedImpl value, + $Res Function(_$NdEvent_DeniedImpl) then) = + __$$NdEvent_DeniedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdEvent_DeniedImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_DeniedImpl> + implements _$$NdEvent_DeniedImplCopyWith<$Res> { + __$$NdEvent_DeniedImplCopyWithImpl( + _$NdEvent_DeniedImpl _value, $Res Function(_$NdEvent_DeniedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdEvent_DeniedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_DeniedImpl extends NdEvent_Denied { + const _$NdEvent_DeniedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdEvent.denied(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_DeniedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_DeniedImplCopyWith<_$NdEvent_DeniedImpl> get copyWith => + __$$NdEvent_DeniedImplCopyWithImpl<_$NdEvent_DeniedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return denied(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return denied?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (denied != null) { + return denied(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return denied(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return denied?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (denied != null) { + return denied(this); + } + return orElse(); + } +} + +abstract class NdEvent_Denied extends NdEvent { + const factory NdEvent_Denied({required final String reason}) = + _$NdEvent_DeniedImpl; + const NdEvent_Denied._() : super._(); + + String get reason; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_DeniedImplCopyWith<_$NdEvent_DeniedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_ChatMessageImplCopyWith<$Res> { + factory _$$NdEvent_ChatMessageImplCopyWith(_$NdEvent_ChatMessageImpl value, + $Res Function(_$NdEvent_ChatMessageImpl) then) = + __$$NdEvent_ChatMessageImplCopyWithImpl<$Res>; + @useResult + $Res call({String from, String text}); +} + +/// @nodoc +class __$$NdEvent_ChatMessageImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ChatMessageImpl> + implements _$$NdEvent_ChatMessageImplCopyWith<$Res> { + __$$NdEvent_ChatMessageImplCopyWithImpl(_$NdEvent_ChatMessageImpl _value, + $Res Function(_$NdEvent_ChatMessageImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? from = null, + Object? text = null, + }) { + return _then(_$NdEvent_ChatMessageImpl( + from: null == from + ? _value.from + : from // ignore: cast_nullable_to_non_nullable + as String, + text: null == text + ? _value.text + : text // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ChatMessageImpl extends NdEvent_ChatMessage { + const _$NdEvent_ChatMessageImpl({required this.from, required this.text}) + : super._(); + + @override + final String from; + @override + final String text; + + @override + String toString() { + return 'NdEvent.chatMessage(from: $from, text: $text)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ChatMessageImpl && + (identical(other.from, from) || other.from == from) && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, from, text); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ChatMessageImplCopyWith<_$NdEvent_ChatMessageImpl> get copyWith => + __$$NdEvent_ChatMessageImplCopyWithImpl<_$NdEvent_ChatMessageImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return chatMessage(from, text); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return chatMessage?.call(from, text); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (chatMessage != null) { + return chatMessage(from, text); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return chatMessage(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return chatMessage?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (chatMessage != null) { + return chatMessage(this); + } + return orElse(); + } +} + +abstract class NdEvent_ChatMessage extends NdEvent { + const factory NdEvent_ChatMessage( + {required final String from, + required final String text}) = _$NdEvent_ChatMessageImpl; + const NdEvent_ChatMessage._() : super._(); + + String get from; + String get text; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ChatMessageImplCopyWith<_$NdEvent_ChatMessageImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdEvent_ClipboardImplCopyWith<$Res> { + factory _$$NdEvent_ClipboardImplCopyWith(_$NdEvent_ClipboardImpl value, + $Res Function(_$NdEvent_ClipboardImpl) then) = + __$$NdEvent_ClipboardImplCopyWithImpl<$Res>; + @useResult + $Res call({String text}); +} + +/// @nodoc +class __$$NdEvent_ClipboardImplCopyWithImpl<$Res> + extends _$NdEventCopyWithImpl<$Res, _$NdEvent_ClipboardImpl> + implements _$$NdEvent_ClipboardImplCopyWith<$Res> { + __$$NdEvent_ClipboardImplCopyWithImpl(_$NdEvent_ClipboardImpl _value, + $Res Function(_$NdEvent_ClipboardImpl) _then) + : super(_value, _then); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? text = null, + }) { + return _then(_$NdEvent_ClipboardImpl( + text: null == text + ? _value.text + : text // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdEvent_ClipboardImpl extends NdEvent_Clipboard { + const _$NdEvent_ClipboardImpl({required this.text}) : super._(); + + @override + final String text; + + @override + String toString() { + return 'NdEvent.clipboard(text: $text)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdEvent_ClipboardImpl && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, text); + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdEvent_ClipboardImplCopyWith<_$NdEvent_ClipboardImpl> get copyWith => + __$$NdEvent_ClipboardImplCopyWithImpl<_$NdEvent_ClipboardImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() connecting, + required TResult Function(int w, int h, bool e2E, String hostName) + connected, + required TResult Function(int width, int height, Uint8List rgba) frame, + required TResult Function(String reason) ended, + required TResult Function(String reason) denied, + required TResult Function(String from, String text) chatMessage, + required TResult Function(String text) clipboard, + }) { + return clipboard(text); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? connecting, + TResult? Function(int w, int h, bool e2E, String hostName)? connected, + TResult? Function(int width, int height, Uint8List rgba)? frame, + TResult? Function(String reason)? ended, + TResult? Function(String reason)? denied, + TResult? Function(String from, String text)? chatMessage, + TResult? Function(String text)? clipboard, + }) { + return clipboard?.call(text); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? connecting, + TResult Function(int w, int h, bool e2E, String hostName)? connected, + TResult Function(int width, int height, Uint8List rgba)? frame, + TResult Function(String reason)? ended, + TResult Function(String reason)? denied, + TResult Function(String from, String text)? chatMessage, + TResult Function(String text)? clipboard, + required TResult orElse(), + }) { + if (clipboard != null) { + return clipboard(text); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdEvent_Connecting value) connecting, + required TResult Function(NdEvent_Connected value) connected, + required TResult Function(NdEvent_Frame value) frame, + required TResult Function(NdEvent_Ended value) ended, + required TResult Function(NdEvent_Denied value) denied, + required TResult Function(NdEvent_ChatMessage value) chatMessage, + required TResult Function(NdEvent_Clipboard value) clipboard, + }) { + return clipboard(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdEvent_Connecting value)? connecting, + TResult? Function(NdEvent_Connected value)? connected, + TResult? Function(NdEvent_Frame value)? frame, + TResult? Function(NdEvent_Ended value)? ended, + TResult? Function(NdEvent_Denied value)? denied, + TResult? Function(NdEvent_ChatMessage value)? chatMessage, + TResult? Function(NdEvent_Clipboard value)? clipboard, + }) { + return clipboard?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdEvent_Connecting value)? connecting, + TResult Function(NdEvent_Connected value)? connected, + TResult Function(NdEvent_Frame value)? frame, + TResult Function(NdEvent_Ended value)? ended, + TResult Function(NdEvent_Denied value)? denied, + TResult Function(NdEvent_ChatMessage value)? chatMessage, + TResult Function(NdEvent_Clipboard value)? clipboard, + required TResult orElse(), + }) { + if (clipboard != null) { + return clipboard(this); + } + return orElse(); + } +} + +abstract class NdEvent_Clipboard extends NdEvent { + const factory NdEvent_Clipboard({required final String text}) = + _$NdEvent_ClipboardImpl; + const NdEvent_Clipboard._() : super._(); + + String get text; + + /// Create a copy of NdEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdEvent_ClipboardImplCopyWith<_$NdEvent_ClipboardImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$NdHostEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NdHostEventCopyWith<$Res> { + factory $NdHostEventCopyWith( + NdHostEvent value, $Res Function(NdHostEvent) then) = + _$NdHostEventCopyWithImpl<$Res, NdHostEvent>; +} + +/// @nodoc +class _$NdHostEventCopyWithImpl<$Res, $Val extends NdHostEvent> + implements $NdHostEventCopyWith<$Res> { + _$NdHostEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$NdHostEvent_RegisteredImplCopyWith<$Res> { + factory _$$NdHostEvent_RegisteredImplCopyWith( + _$NdHostEvent_RegisteredImpl value, + $Res Function(_$NdHostEvent_RegisteredImpl) then) = + __$$NdHostEvent_RegisteredImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_RegisteredImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_RegisteredImpl> + implements _$$NdHostEvent_RegisteredImplCopyWith<$Res> { + __$$NdHostEvent_RegisteredImplCopyWithImpl( + _$NdHostEvent_RegisteredImpl _value, + $Res Function(_$NdHostEvent_RegisteredImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_RegisteredImpl extends NdHostEvent_Registered { + const _$NdHostEvent_RegisteredImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.registered()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_RegisteredImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return registered(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return registered?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (registered != null) { + return registered(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return registered(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return registered?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (registered != null) { + return registered(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Registered extends NdHostEvent { + const factory NdHostEvent_Registered() = _$NdHostEvent_RegisteredImpl; + const NdHostEvent_Registered._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_ViewerConnectedImplCopyWith<$Res> { + factory _$$NdHostEvent_ViewerConnectedImplCopyWith( + _$NdHostEvent_ViewerConnectedImpl value, + $Res Function(_$NdHostEvent_ViewerConnectedImpl) then) = + __$$NdHostEvent_ViewerConnectedImplCopyWithImpl<$Res>; + @useResult + $Res call({bool e2E}); +} + +/// @nodoc +class __$$NdHostEvent_ViewerConnectedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ViewerConnectedImpl> + implements _$$NdHostEvent_ViewerConnectedImplCopyWith<$Res> { + __$$NdHostEvent_ViewerConnectedImplCopyWithImpl( + _$NdHostEvent_ViewerConnectedImpl _value, + $Res Function(_$NdHostEvent_ViewerConnectedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? e2E = null, + }) { + return _then(_$NdHostEvent_ViewerConnectedImpl( + e2E: null == e2E + ? _value.e2E + : e2E // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_ViewerConnectedImpl extends NdHostEvent_ViewerConnected { + const _$NdHostEvent_ViewerConnectedImpl({required this.e2E}) : super._(); + + @override + final bool e2E; + + @override + String toString() { + return 'NdHostEvent.viewerConnected(e2E: $e2E)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ViewerConnectedImpl && + (identical(other.e2E, e2E) || other.e2E == e2E)); + } + + @override + int get hashCode => Object.hash(runtimeType, e2E); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_ViewerConnectedImplCopyWith<_$NdHostEvent_ViewerConnectedImpl> + get copyWith => __$$NdHostEvent_ViewerConnectedImplCopyWithImpl< + _$NdHostEvent_ViewerConnectedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return viewerConnected(e2E); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return viewerConnected?.call(e2E); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (viewerConnected != null) { + return viewerConnected(e2E); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return viewerConnected(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return viewerConnected?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (viewerConnected != null) { + return viewerConnected(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_ViewerConnected extends NdHostEvent { + const factory NdHostEvent_ViewerConnected({required final bool e2E}) = + _$NdHostEvent_ViewerConnectedImpl; + const NdHostEvent_ViewerConnected._() : super._(); + + bool get e2E; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_ViewerConnectedImplCopyWith<_$NdHostEvent_ViewerConnectedImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_ViewerLeftImplCopyWith<$Res> { + factory _$$NdHostEvent_ViewerLeftImplCopyWith( + _$NdHostEvent_ViewerLeftImpl value, + $Res Function(_$NdHostEvent_ViewerLeftImpl) then) = + __$$NdHostEvent_ViewerLeftImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_ViewerLeftImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ViewerLeftImpl> + implements _$$NdHostEvent_ViewerLeftImplCopyWith<$Res> { + __$$NdHostEvent_ViewerLeftImplCopyWithImpl( + _$NdHostEvent_ViewerLeftImpl _value, + $Res Function(_$NdHostEvent_ViewerLeftImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_ViewerLeftImpl extends NdHostEvent_ViewerLeft { + const _$NdHostEvent_ViewerLeftImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.viewerLeft()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ViewerLeftImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return viewerLeft(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return viewerLeft?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (viewerLeft != null) { + return viewerLeft(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return viewerLeft(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return viewerLeft?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (viewerLeft != null) { + return viewerLeft(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_ViewerLeft extends NdHostEvent { + const factory NdHostEvent_ViewerLeft() = _$NdHostEvent_ViewerLeftImpl; + const NdHostEvent_ViewerLeft._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_MouseImplCopyWith<$Res> { + factory _$$NdHostEvent_MouseImplCopyWith(_$NdHostEvent_MouseImpl value, + $Res Function(_$NdHostEvent_MouseImpl) then) = + __$$NdHostEvent_MouseImplCopyWithImpl<$Res>; + @useResult + $Res call({int x, int y, int buttons, double wheel}); +} + +/// @nodoc +class __$$NdHostEvent_MouseImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_MouseImpl> + implements _$$NdHostEvent_MouseImplCopyWith<$Res> { + __$$NdHostEvent_MouseImplCopyWithImpl(_$NdHostEvent_MouseImpl _value, + $Res Function(_$NdHostEvent_MouseImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? x = null, + Object? y = null, + Object? buttons = null, + Object? wheel = null, + }) { + return _then(_$NdHostEvent_MouseImpl( + x: null == x + ? _value.x + : x // ignore: cast_nullable_to_non_nullable + as int, + y: null == y + ? _value.y + : y // ignore: cast_nullable_to_non_nullable + as int, + buttons: null == buttons + ? _value.buttons + : buttons // ignore: cast_nullable_to_non_nullable + as int, + wheel: null == wheel + ? _value.wheel + : wheel // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_MouseImpl extends NdHostEvent_Mouse { + const _$NdHostEvent_MouseImpl( + {required this.x, + required this.y, + required this.buttons, + required this.wheel}) + : super._(); + + @override + final int x; + @override + final int y; + @override + final int buttons; + @override + final double wheel; + + @override + String toString() { + return 'NdHostEvent.mouse(x: $x, y: $y, buttons: $buttons, wheel: $wheel)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_MouseImpl && + (identical(other.x, x) || other.x == x) && + (identical(other.y, y) || other.y == y) && + (identical(other.buttons, buttons) || other.buttons == buttons) && + (identical(other.wheel, wheel) || other.wheel == wheel)); + } + + @override + int get hashCode => Object.hash(runtimeType, x, y, buttons, wheel); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_MouseImplCopyWith<_$NdHostEvent_MouseImpl> get copyWith => + __$$NdHostEvent_MouseImplCopyWithImpl<_$NdHostEvent_MouseImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return mouse(x, y, buttons, wheel); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return mouse?.call(x, y, buttons, wheel); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (mouse != null) { + return mouse(x, y, buttons, wheel); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return mouse(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return mouse?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (mouse != null) { + return mouse(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Mouse extends NdHostEvent { + const factory NdHostEvent_Mouse( + {required final int x, + required final int y, + required final int buttons, + required final double wheel}) = _$NdHostEvent_MouseImpl; + const NdHostEvent_Mouse._() : super._(); + + int get x; + int get y; + int get buttons; + double get wheel; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_MouseImplCopyWith<_$NdHostEvent_MouseImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_KeyImplCopyWith<$Res> { + factory _$$NdHostEvent_KeyImplCopyWith(_$NdHostEvent_KeyImpl value, + $Res Function(_$NdHostEvent_KeyImpl) then) = + __$$NdHostEvent_KeyImplCopyWithImpl<$Res>; + @useResult + $Res call({String code, bool down}); +} + +/// @nodoc +class __$$NdHostEvent_KeyImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_KeyImpl> + implements _$$NdHostEvent_KeyImplCopyWith<$Res> { + __$$NdHostEvent_KeyImplCopyWithImpl( + _$NdHostEvent_KeyImpl _value, $Res Function(_$NdHostEvent_KeyImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? code = null, + Object? down = null, + }) { + return _then(_$NdHostEvent_KeyImpl( + code: null == code + ? _value.code + : code // ignore: cast_nullable_to_non_nullable + as String, + down: null == down + ? _value.down + : down // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_KeyImpl extends NdHostEvent_Key { + const _$NdHostEvent_KeyImpl({required this.code, required this.down}) + : super._(); + + @override + final String code; + @override + final bool down; + + @override + String toString() { + return 'NdHostEvent.key(code: $code, down: $down)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_KeyImpl && + (identical(other.code, code) || other.code == code) && + (identical(other.down, down) || other.down == down)); + } + + @override + int get hashCode => Object.hash(runtimeType, code, down); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_KeyImplCopyWith<_$NdHostEvent_KeyImpl> get copyWith => + __$$NdHostEvent_KeyImplCopyWithImpl<_$NdHostEvent_KeyImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return key(code, down); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return key?.call(code, down); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (key != null) { + return key(code, down); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return key(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return key?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (key != null) { + return key(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Key extends NdHostEvent { + const factory NdHostEvent_Key( + {required final String code, + required final bool down}) = _$NdHostEvent_KeyImpl; + const NdHostEvent_Key._() : super._(); + + String get code; + bool get down; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_KeyImplCopyWith<_$NdHostEvent_KeyImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_KeyframeRequestedImplCopyWith<$Res> { + factory _$$NdHostEvent_KeyframeRequestedImplCopyWith( + _$NdHostEvent_KeyframeRequestedImpl value, + $Res Function(_$NdHostEvent_KeyframeRequestedImpl) then) = + __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_KeyframeRequestedImpl> + implements _$$NdHostEvent_KeyframeRequestedImplCopyWith<$Res> { + __$$NdHostEvent_KeyframeRequestedImplCopyWithImpl( + _$NdHostEvent_KeyframeRequestedImpl _value, + $Res Function(_$NdHostEvent_KeyframeRequestedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_KeyframeRequestedImpl + extends NdHostEvent_KeyframeRequested { + const _$NdHostEvent_KeyframeRequestedImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.keyframeRequested()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_KeyframeRequestedImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return keyframeRequested(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return keyframeRequested?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (keyframeRequested != null) { + return keyframeRequested(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return keyframeRequested(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return keyframeRequested?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (keyframeRequested != null) { + return keyframeRequested(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_KeyframeRequested extends NdHostEvent { + const factory NdHostEvent_KeyframeRequested() = + _$NdHostEvent_KeyframeRequestedImpl; + const NdHostEvent_KeyframeRequested._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_QualityImplCopyWith<$Res> { + factory _$$NdHostEvent_QualityImplCopyWith(_$NdHostEvent_QualityImpl value, + $Res Function(_$NdHostEvent_QualityImpl) then) = + __$$NdHostEvent_QualityImplCopyWithImpl<$Res>; + @useResult + $Res call({int fps, int bitrate, double scale}); +} + +/// @nodoc +class __$$NdHostEvent_QualityImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_QualityImpl> + implements _$$NdHostEvent_QualityImplCopyWith<$Res> { + __$$NdHostEvent_QualityImplCopyWithImpl(_$NdHostEvent_QualityImpl _value, + $Res Function(_$NdHostEvent_QualityImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? fps = null, + Object? bitrate = null, + Object? scale = null, + }) { + return _then(_$NdHostEvent_QualityImpl( + fps: null == fps + ? _value.fps + : fps // ignore: cast_nullable_to_non_nullable + as int, + bitrate: null == bitrate + ? _value.bitrate + : bitrate // ignore: cast_nullable_to_non_nullable + as int, + scale: null == scale + ? _value.scale + : scale // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_QualityImpl extends NdHostEvent_Quality { + const _$NdHostEvent_QualityImpl( + {required this.fps, required this.bitrate, required this.scale}) + : super._(); + + @override + final int fps; + @override + final int bitrate; + @override + final double scale; + + @override + String toString() { + return 'NdHostEvent.quality(fps: $fps, bitrate: $bitrate, scale: $scale)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_QualityImpl && + (identical(other.fps, fps) || other.fps == fps) && + (identical(other.bitrate, bitrate) || other.bitrate == bitrate) && + (identical(other.scale, scale) || other.scale == scale)); + } + + @override + int get hashCode => Object.hash(runtimeType, fps, bitrate, scale); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_QualityImplCopyWith<_$NdHostEvent_QualityImpl> get copyWith => + __$$NdHostEvent_QualityImplCopyWithImpl<_$NdHostEvent_QualityImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return quality(fps, bitrate, scale); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return quality?.call(fps, bitrate, scale); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (quality != null) { + return quality(fps, bitrate, scale); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return quality(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return quality?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (quality != null) { + return quality(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Quality extends NdHostEvent { + const factory NdHostEvent_Quality( + {required final int fps, + required final int bitrate, + required final double scale}) = _$NdHostEvent_QualityImpl; + const NdHostEvent_Quality._() : super._(); + + int get fps; + int get bitrate; + double get scale; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_QualityImplCopyWith<_$NdHostEvent_QualityImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$NdHostEvent_ReconnectingImplCopyWith<$Res> { + factory _$$NdHostEvent_ReconnectingImplCopyWith( + _$NdHostEvent_ReconnectingImpl value, + $Res Function(_$NdHostEvent_ReconnectingImpl) then) = + __$$NdHostEvent_ReconnectingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NdHostEvent_ReconnectingImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_ReconnectingImpl> + implements _$$NdHostEvent_ReconnectingImplCopyWith<$Res> { + __$$NdHostEvent_ReconnectingImplCopyWithImpl( + _$NdHostEvent_ReconnectingImpl _value, + $Res Function(_$NdHostEvent_ReconnectingImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NdHostEvent_ReconnectingImpl extends NdHostEvent_Reconnecting { + const _$NdHostEvent_ReconnectingImpl() : super._(); + + @override + String toString() { + return 'NdHostEvent.reconnecting()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_ReconnectingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return reconnecting(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return reconnecting?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (reconnecting != null) { + return reconnecting(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return reconnecting(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return reconnecting?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (reconnecting != null) { + return reconnecting(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Reconnecting extends NdHostEvent { + const factory NdHostEvent_Reconnecting() = _$NdHostEvent_ReconnectingImpl; + const NdHostEvent_Reconnecting._() : super._(); +} + +/// @nodoc +abstract class _$$NdHostEvent_EndedImplCopyWith<$Res> { + factory _$$NdHostEvent_EndedImplCopyWith(_$NdHostEvent_EndedImpl value, + $Res Function(_$NdHostEvent_EndedImpl) then) = + __$$NdHostEvent_EndedImplCopyWithImpl<$Res>; + @useResult + $Res call({String reason}); +} + +/// @nodoc +class __$$NdHostEvent_EndedImplCopyWithImpl<$Res> + extends _$NdHostEventCopyWithImpl<$Res, _$NdHostEvent_EndedImpl> + implements _$$NdHostEvent_EndedImplCopyWith<$Res> { + __$$NdHostEvent_EndedImplCopyWithImpl(_$NdHostEvent_EndedImpl _value, + $Res Function(_$NdHostEvent_EndedImpl) _then) + : super(_value, _then); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? reason = null, + }) { + return _then(_$NdHostEvent_EndedImpl( + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$NdHostEvent_EndedImpl extends NdHostEvent_Ended { + const _$NdHostEvent_EndedImpl({required this.reason}) : super._(); + + @override + final String reason; + + @override + String toString() { + return 'NdHostEvent.ended(reason: $reason)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NdHostEvent_EndedImpl && + (identical(other.reason, reason) || other.reason == reason)); + } + + @override + int get hashCode => Object.hash(runtimeType, reason); + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$NdHostEvent_EndedImplCopyWith<_$NdHostEvent_EndedImpl> get copyWith => + __$$NdHostEvent_EndedImplCopyWithImpl<_$NdHostEvent_EndedImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() registered, + required TResult Function(bool e2E) viewerConnected, + required TResult Function() viewerLeft, + required TResult Function(int x, int y, int buttons, double wheel) mouse, + required TResult Function(String code, bool down) key, + required TResult Function() keyframeRequested, + required TResult Function(int fps, int bitrate, double scale) quality, + required TResult Function() reconnecting, + required TResult Function(String reason) ended, + }) { + return ended(reason); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? registered, + TResult? Function(bool e2E)? viewerConnected, + TResult? Function()? viewerLeft, + TResult? Function(int x, int y, int buttons, double wheel)? mouse, + TResult? Function(String code, bool down)? key, + TResult? Function()? keyframeRequested, + TResult? Function(int fps, int bitrate, double scale)? quality, + TResult? Function()? reconnecting, + TResult? Function(String reason)? ended, + }) { + return ended?.call(reason); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? registered, + TResult Function(bool e2E)? viewerConnected, + TResult Function()? viewerLeft, + TResult Function(int x, int y, int buttons, double wheel)? mouse, + TResult Function(String code, bool down)? key, + TResult Function()? keyframeRequested, + TResult Function(int fps, int bitrate, double scale)? quality, + TResult Function()? reconnecting, + TResult Function(String reason)? ended, + required TResult orElse(), + }) { + if (ended != null) { + return ended(reason); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(NdHostEvent_Registered value) registered, + required TResult Function(NdHostEvent_ViewerConnected value) + viewerConnected, + required TResult Function(NdHostEvent_ViewerLeft value) viewerLeft, + required TResult Function(NdHostEvent_Mouse value) mouse, + required TResult Function(NdHostEvent_Key value) key, + required TResult Function(NdHostEvent_KeyframeRequested value) + keyframeRequested, + required TResult Function(NdHostEvent_Quality value) quality, + required TResult Function(NdHostEvent_Reconnecting value) reconnecting, + required TResult Function(NdHostEvent_Ended value) ended, + }) { + return ended(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(NdHostEvent_Registered value)? registered, + TResult? Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult? Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult? Function(NdHostEvent_Mouse value)? mouse, + TResult? Function(NdHostEvent_Key value)? key, + TResult? Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult? Function(NdHostEvent_Quality value)? quality, + TResult? Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult? Function(NdHostEvent_Ended value)? ended, + }) { + return ended?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(NdHostEvent_Registered value)? registered, + TResult Function(NdHostEvent_ViewerConnected value)? viewerConnected, + TResult Function(NdHostEvent_ViewerLeft value)? viewerLeft, + TResult Function(NdHostEvent_Mouse value)? mouse, + TResult Function(NdHostEvent_Key value)? key, + TResult Function(NdHostEvent_KeyframeRequested value)? keyframeRequested, + TResult Function(NdHostEvent_Quality value)? quality, + TResult Function(NdHostEvent_Reconnecting value)? reconnecting, + TResult Function(NdHostEvent_Ended value)? ended, + required TResult orElse(), + }) { + if (ended != null) { + return ended(this); + } + return orElse(); + } +} + +abstract class NdHostEvent_Ended extends NdHostEvent { + const factory NdHostEvent_Ended({required final String reason}) = + _$NdHostEvent_EndedImpl; + const NdHostEvent_Ended._() : super._(); + + String get reason; + + /// Create a copy of NdHostEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$NdHostEvent_EndedImplCopyWith<_$NdHostEvent_EndedImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/native/ui/lib/rust/frb_generated.dart b/native/ui/lib/rust/frb_generated.dart new file mode 100644 index 0000000..8034f3b --- /dev/null +++ b/native/ui/lib/rust/frb_generated.dart @@ -0,0 +1,1190 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.13.0'; + + @override + int get rustContentHash => 389194750; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'netdesk_frb', + ioDirectory: '../frb/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiConnect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}); + + void crateApiCtrlAltDel({required BigInt handle}); + + void crateApiDisconnect({required BigInt handle}); + + Stream crateApiHostEvents({required BigInt handle}); + + String crateApiHostGenId(); + + String crateApiHostGenPassword(); + + void crateApiHostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}); + + Future crateApiHostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}); + + void crateApiHostStop({required BigInt handle}); + + void crateApiMonitorSwitch({required BigInt handle, required int i}); + + void crateApiSendChat({required BigInt handle, required String text}); + + void crateApiSendKey( + {required BigInt handle, required String code, required bool down}); + + void crateApiSendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}); + + int crateApiSessionCurrentMonitor({required BigInt handle}); + + Stream crateApiSessionEvents({required BigInt handle}); + + List crateApiSessionMonitors({required BigInt handle}); + + void crateApiSetClipboard({required BigInt handle, required String text}); + + void crateApiSetQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiConnect( + {required String wsUrl, + required String hostId, + required String password, + required String totp}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(wsUrl, serializer); + sse_encode_String(hostId, serializer); + sse_encode_String(password, serializer); + sse_encode_String(totp, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_64, + decodeErrorData: null, + ), + constMeta: kCrateApiConnectConstMeta, + argValues: [wsUrl, hostId, password, totp], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiConnectConstMeta => const TaskConstMeta( + debugName: "connect", + argNames: ["wsUrl", "hostId", "password", "totp"], + ); + + @override + void crateApiCtrlAltDel({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 2)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiCtrlAltDelConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCtrlAltDelConstMeta => const TaskConstMeta( + debugName: "ctrl_alt_del", + argNames: ["handle"], + ); + + @override + void crateApiDisconnect({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 3)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiDisconnectConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiDisconnectConstMeta => const TaskConstMeta( + debugName: "disconnect", + argNames: ["handle"], + ); + + @override + Stream crateApiHostEvents({required BigInt handle}) { + final sink = RustStreamSink(); + unawaited(handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_StreamSink_nd_host_event_Sse(sink, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostEventsConstMeta, + argValues: [handle, sink], + apiImpl: this, + ))); + return sink.stream; + } + + TaskConstMeta get kCrateApiHostEventsConstMeta => const TaskConstMeta( + debugName: "host_events", + argNames: ["handle", "sink"], + ); + + @override + String crateApiHostGenId() { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 5)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiHostGenIdConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostGenIdConstMeta => const TaskConstMeta( + debugName: "host_gen_id", + argNames: [], + ); + + @override + String crateApiHostGenPassword() { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiHostGenPasswordConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostGenPasswordConstMeta => const TaskConstMeta( + debugName: "host_gen_password", + argNames: [], + ); + + @override + void crateApiHostPushFrame( + {required BigInt handle, + required List data, + required bool keyframe}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_list_prim_u_8_loose(data, serializer); + sse_encode_bool(keyframe, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostPushFrameConstMeta, + argValues: [handle, data, keyframe], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostPushFrameConstMeta => const TaskConstMeta( + debugName: "host_push_frame", + argNames: ["handle", "data", "keyframe"], + ); + + @override + Future crateApiHostStart( + {required String wsUrl, + required String hostId, + required String name, + required String password, + required int width, + required int height}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(wsUrl, serializer); + sse_encode_String(hostId, serializer); + sse_encode_String(name, serializer); + sse_encode_String(password, serializer); + sse_encode_u_32(width, serializer); + sse_encode_u_32(height, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 8, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_64, + decodeErrorData: null, + ), + constMeta: kCrateApiHostStartConstMeta, + argValues: [wsUrl, hostId, name, password, width, height], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostStartConstMeta => const TaskConstMeta( + debugName: "host_start", + argNames: ["wsUrl", "hostId", "name", "password", "width", "height"], + ); + + @override + void crateApiHostStop({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHostStopConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiHostStopConstMeta => const TaskConstMeta( + debugName: "host_stop", + argNames: ["handle"], + ); + + @override + void crateApiMonitorSwitch({required BigInt handle, required int i}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_i_32(i, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiMonitorSwitchConstMeta, + argValues: [handle, i], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiMonitorSwitchConstMeta => const TaskConstMeta( + debugName: "monitor_switch", + argNames: ["handle", "i"], + ); + + @override + void crateApiSendChat({required BigInt handle, required String text}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(text, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendChatConstMeta, + argValues: [handle, text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendChatConstMeta => const TaskConstMeta( + debugName: "send_chat", + argNames: ["handle", "text"], + ); + + @override + void crateApiSendKey( + {required BigInt handle, required String code, required bool down}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(code, serializer); + sse_encode_bool(down, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendKeyConstMeta, + argValues: [handle, code, down], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendKeyConstMeta => const TaskConstMeta( + debugName: "send_key", + argNames: ["handle", "code", "down"], + ); + + @override + void crateApiSendMouse( + {required BigInt handle, + required int x, + required int y, + required int buttons, + required double wheel, + required double hwheel}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_i_32(x, serializer); + sse_encode_i_32(y, serializer); + sse_encode_u_32(buttons, serializer); + sse_encode_f_32(wheel, serializer); + sse_encode_f_32(hwheel, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSendMouseConstMeta, + argValues: [handle, x, y, buttons, wheel, hwheel], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSendMouseConstMeta => const TaskConstMeta( + debugName: "send_mouse", + argNames: ["handle", "x", "y", "buttons", "wheel", "hwheel"], + ); + + @override + int crateApiSessionCurrentMonitor({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_i_32, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionCurrentMonitorConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSessionCurrentMonitorConstMeta => + const TaskConstMeta( + debugName: "session_current_monitor", + argNames: ["handle"], + ); + + @override + Stream crateApiSessionEvents({required BigInt handle}) { + final sink = RustStreamSink(); + unawaited(handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_StreamSink_nd_event_Sse(sink, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 15, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionEventsConstMeta, + argValues: [handle, sink], + apiImpl: this, + ))); + return sink.stream; + } + + TaskConstMeta get kCrateApiSessionEventsConstMeta => const TaskConstMeta( + debugName: "session_events", + argNames: ["handle", "sink"], + ); + + @override + List crateApiSessionMonitors({required BigInt handle}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_nd_monitor, + decodeErrorData: null, + ), + constMeta: kCrateApiSessionMonitorsConstMeta, + argValues: [handle], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSessionMonitorsConstMeta => const TaskConstMeta( + debugName: "session_monitors", + argNames: ["handle"], + ); + + @override + void crateApiSetClipboard({required BigInt handle, required String text}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_String(text, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetClipboardConstMeta, + argValues: [handle, text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSetClipboardConstMeta => const TaskConstMeta( + debugName: "set_clipboard", + argNames: ["handle", "text"], + ); + + @override + void crateApiSetQuality( + {required BigInt handle, + required int fps, + required int bitrate, + required double scale}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(handle, serializer); + sse_encode_u_32(fps, serializer); + sse_encode_u_32(bitrate, serializer); + sse_encode_f_32(scale, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetQualityConstMeta, + argValues: [handle, fps, bitrate, scale], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSetQualityConstMeta => const TaskConstMeta( + debugName: "set_quality", + argNames: ["handle", "fps", "bitrate", "scale"], + ); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + double dco_decode_f_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as double; + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + List dco_decode_list_nd_monitor(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_nd_monitor).toList(); + } + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as List; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + NdEvent dco_decode_nd_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return const NdEvent_Connecting(); + case 1: + return NdEvent_Connected( + w: dco_decode_u_32(raw[1]), + h: dco_decode_u_32(raw[2]), + e2E: dco_decode_bool(raw[3]), + hostName: dco_decode_String(raw[4]), + ); + case 2: + return NdEvent_Frame( + width: dco_decode_u_32(raw[1]), + height: dco_decode_u_32(raw[2]), + rgba: dco_decode_list_prim_u_8_strict(raw[3]), + ); + case 3: + return NdEvent_Ended( + reason: dco_decode_String(raw[1]), + ); + case 4: + return NdEvent_Denied( + reason: dco_decode_String(raw[1]), + ); + case 5: + return NdEvent_ChatMessage( + from: dco_decode_String(raw[1]), + text: dco_decode_String(raw[2]), + ); + case 6: + return NdEvent_Clipboard( + text: dco_decode_String(raw[1]), + ); + default: + throw Exception("unreachable"); + } + } + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return const NdHostEvent_Registered(); + case 1: + return NdHostEvent_ViewerConnected( + e2E: dco_decode_bool(raw[1]), + ); + case 2: + return const NdHostEvent_ViewerLeft(); + case 3: + return NdHostEvent_Mouse( + x: dco_decode_i_32(raw[1]), + y: dco_decode_i_32(raw[2]), + buttons: dco_decode_u_32(raw[3]), + wheel: dco_decode_f_32(raw[4]), + ); + case 4: + return NdHostEvent_Key( + code: dco_decode_String(raw[1]), + down: dco_decode_bool(raw[2]), + ); + case 5: + return const NdHostEvent_KeyframeRequested(); + case 6: + return NdHostEvent_Quality( + fps: dco_decode_u_32(raw[1]), + bitrate: dco_decode_u_32(raw[2]), + scale: dco_decode_f_32(raw[3]), + ); + case 7: + return const NdHostEvent_Reconnecting(); + case 8: + return NdHostEvent_Ended( + reason: dco_decode_String(raw[1]), + ); + default: + throw Exception("unreachable"); + } + } + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return NdMonitor( + i: dco_decode_i_32(arr[0]), + w: dco_decode_u_32(arr[1]), + h: dco_decode_u_32(arr[2]), + ); + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + BigInt dco_decode_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + double sse_decode_f_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getFloat32(); + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_nd_monitor(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return const NdEvent_Connecting(); + case 1: + var var_w = sse_decode_u_32(deserializer); + var var_h = sse_decode_u_32(deserializer); + var var_e2E = sse_decode_bool(deserializer); + var var_hostName = sse_decode_String(deserializer); + return NdEvent_Connected( + w: var_w, h: var_h, e2E: var_e2E, hostName: var_hostName); + case 2: + var var_width = sse_decode_u_32(deserializer); + var var_height = sse_decode_u_32(deserializer); + var var_rgba = sse_decode_list_prim_u_8_strict(deserializer); + return NdEvent_Frame( + width: var_width, height: var_height, rgba: var_rgba); + case 3: + var var_reason = sse_decode_String(deserializer); + return NdEvent_Ended(reason: var_reason); + case 4: + var var_reason = sse_decode_String(deserializer); + return NdEvent_Denied(reason: var_reason); + case 5: + var var_from = sse_decode_String(deserializer); + var var_text = sse_decode_String(deserializer); + return NdEvent_ChatMessage(from: var_from, text: var_text); + case 6: + var var_text = sse_decode_String(deserializer); + return NdEvent_Clipboard(text: var_text); + default: + throw UnimplementedError(''); + } + } + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return const NdHostEvent_Registered(); + case 1: + var var_e2E = sse_decode_bool(deserializer); + return NdHostEvent_ViewerConnected(e2E: var_e2E); + case 2: + return const NdHostEvent_ViewerLeft(); + case 3: + var var_x = sse_decode_i_32(deserializer); + var var_y = sse_decode_i_32(deserializer); + var var_buttons = sse_decode_u_32(deserializer); + var var_wheel = sse_decode_f_32(deserializer); + return NdHostEvent_Mouse( + x: var_x, y: var_y, buttons: var_buttons, wheel: var_wheel); + case 4: + var var_code = sse_decode_String(deserializer); + var var_down = sse_decode_bool(deserializer); + return NdHostEvent_Key(code: var_code, down: var_down); + case 5: + return const NdHostEvent_KeyframeRequested(); + case 6: + var var_fps = sse_decode_u_32(deserializer); + var var_bitrate = sse_decode_u_32(deserializer); + var var_scale = sse_decode_f_32(deserializer); + return NdHostEvent_Quality( + fps: var_fps, bitrate: var_bitrate, scale: var_scale); + case 7: + return const NdHostEvent_Reconnecting(); + case 8: + var var_reason = sse_decode_String(deserializer); + return NdHostEvent_Ended(reason: var_reason); + default: + throw UnimplementedError(''); + } + } + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_i = sse_decode_i_32(deserializer); + var var_w = sse_decode_u_32(deserializer); + var var_h = sse_decode_u_32(deserializer); + return NdMonitor(i: var_i, w: var_w, h: var_h); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_nd_event, + decodeErrorData: sse_decode_AnyhowException, + )), + serializer); + } + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_nd_host_event, + decodeErrorData: sse_decode_AnyhowException, + )), + serializer); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_f_32(double self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putFloat32(self); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_nd_monitor(item, serializer); + } + } + + @protected + void sse_encode_list_prim_u_8_loose( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer + .putUint8List(self is Uint8List ? self : Uint8List.fromList(self)); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case NdEvent_Connecting(): + sse_encode_i_32(0, serializer); + case NdEvent_Connected( + w: final w, + h: final h, + e2E: final e2E, + hostName: final hostName + ): + sse_encode_i_32(1, serializer); + sse_encode_u_32(w, serializer); + sse_encode_u_32(h, serializer); + sse_encode_bool(e2E, serializer); + sse_encode_String(hostName, serializer); + case NdEvent_Frame( + width: final width, + height: final height, + rgba: final rgba + ): + sse_encode_i_32(2, serializer); + sse_encode_u_32(width, serializer); + sse_encode_u_32(height, serializer); + sse_encode_list_prim_u_8_strict(rgba, serializer); + case NdEvent_Ended(reason: final reason): + sse_encode_i_32(3, serializer); + sse_encode_String(reason, serializer); + case NdEvent_Denied(reason: final reason): + sse_encode_i_32(4, serializer); + sse_encode_String(reason, serializer); + case NdEvent_ChatMessage(from: final from, text: final text): + sse_encode_i_32(5, serializer); + sse_encode_String(from, serializer); + sse_encode_String(text, serializer); + case NdEvent_Clipboard(text: final text): + sse_encode_i_32(6, serializer); + sse_encode_String(text, serializer); + } + } + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case NdHostEvent_Registered(): + sse_encode_i_32(0, serializer); + case NdHostEvent_ViewerConnected(e2E: final e2E): + sse_encode_i_32(1, serializer); + sse_encode_bool(e2E, serializer); + case NdHostEvent_ViewerLeft(): + sse_encode_i_32(2, serializer); + case NdHostEvent_Mouse( + x: final x, + y: final y, + buttons: final buttons, + wheel: final wheel + ): + sse_encode_i_32(3, serializer); + sse_encode_i_32(x, serializer); + sse_encode_i_32(y, serializer); + sse_encode_u_32(buttons, serializer); + sse_encode_f_32(wheel, serializer); + case NdHostEvent_Key(code: final code, down: final down): + sse_encode_i_32(4, serializer); + sse_encode_String(code, serializer); + sse_encode_bool(down, serializer); + case NdHostEvent_KeyframeRequested(): + sse_encode_i_32(5, serializer); + case NdHostEvent_Quality( + fps: final fps, + bitrate: final bitrate, + scale: final scale + ): + sse_encode_i_32(6, serializer); + sse_encode_u_32(fps, serializer); + sse_encode_u_32(bitrate, serializer); + sse_encode_f_32(scale, serializer); + case NdHostEvent_Reconnecting(): + sse_encode_i_32(7, serializer); + case NdHostEvent_Ended(reason: final reason): + sse_encode_i_32(8, serializer); + sse_encode_String(reason, serializer); + } + } + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.i, serializer); + sse_encode_u_32(self.w, serializer); + sse_encode_u_32(self.h, serializer); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } +} diff --git a/native/ui/lib/rust/frb_generated.io.dart b/native/ui/lib/rust/frb_generated.io.dart new file mode 100644 index 0000000..626da37 --- /dev/null +++ b/native/ui/lib/rust/frb_generated.io.dart @@ -0,0 +1,196 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + double dco_decode_f_32(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_nd_monitor(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + NdEvent dco_decode_nd_event(dynamic raw); + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw); + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + double sse_decode_f_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer); + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer); + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/native/ui/lib/rust/frb_generated.web.dart b/native/ui/lib/rust/frb_generated.web.dart new file mode 100644 index 0000000..01a6d3b --- /dev/null +++ b/native/ui/lib/rust/frb_generated.web.dart @@ -0,0 +1,196 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_event_Sse(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_nd_host_event_Sse( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + double dco_decode_f_32(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_nd_monitor(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + NdEvent dco_decode_nd_event(dynamic raw); + + @protected + NdHostEvent dco_decode_nd_host_event(dynamic raw); + + @protected + NdMonitor dco_decode_nd_monitor(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_event_Sse( + SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_nd_host_event_Sse( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + double sse_decode_f_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_nd_monitor(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + NdEvent sse_decode_nd_event(SseDeserializer deserializer); + + @protected + NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer); + + @protected + NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_StreamSink_nd_host_event_Sse( + RustStreamSink self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_nd_monitor( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_nd_event(NdEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer); + + @protected + void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/native/ui/lib/session_screen.dart b/native/ui/lib/session_screen.dart new file mode 100644 index 0000000..4334fda --- /dev/null +++ b/native/ui/lib/session_screen.dart @@ -0,0 +1,1447 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'rust/api.dart' as nd; +import 'theme.dart'; + +/// Mouse-button bitmask values used by the Rust input layer. +const int _btnLeft = 1; +const int _btnRight = 2; + +enum _Phase { connecting, live, ended, denied } + +/// How finger touch maps to the remote cursor. +/// * [trackpad] — the screen is a relative trackpad: drag to nudge the cursor, +/// tap to click (precise, works at any zoom, like a laptop pad). +/// * [direct] — the screen is the remote surface: the cursor jumps to wherever +/// you touch, tap-to-click there (fast, like touching the PC). +/// A stylus/pen is ALWAYS treated as a precise direct pointer (with hover), +/// regardless of this mode. +enum _InputMode { trackpad, direct } + +/// The active remote-session screen. +/// +/// It subscribes to [nd.sessionEvents] for the session [handle] opened by the +/// connect screen, paints each decoded RGBA [nd.NdEvent_Frame] to fill the +/// display (fit + pinch-zoom + pan), and forwards touch and keyboard input back +/// through the FRB methods: +/// +/// * one-finger drag → move cursor ([nd.sendMouse]) +/// * tap → left click ([nd.sendMouse]) +/// * long-press → right click ([nd.sendMouse]) +/// * two-finger drag → scroll wheel ([nd.sendMouse]) +/// * pinch → zoom the view (local) +/// * hidden TextField → keystrokes ([nd.sendKey]) +/// +/// A floating toolbar exposes disconnect, keyboard, monitors, quality and +/// Ctrl+Alt+Del; an overflow sheet adds chat, clipboard push and reset-zoom. +class SessionScreen extends StatefulWidget { + const SessionScreen({ + super.key, + required this.partnerId, + required this.handle, + }); + + /// The Partner ID being controlled (display only). + final String partnerId; + + /// The session handle returned by [nd.connect]. + final BigInt handle; + + @override + State createState() => _SessionScreenState(); +} + +class _SessionScreenState extends State { + StreamSubscription? _sub; + + // ---- session state ------------------------------------------------------- + _Phase _phase = _Phase.connecting; + String? _error; + String _hostName = ''; + bool _e2e = false; + ui.Image? _image; + int _remoteW = 0; + int _remoteH = 0; + int _currentMon = 0; + bool _closed = false; + bool _showHint = false; + /// When false, the top bar + toolbar are hidden for a pure full-screen view. + bool _controlsVisible = true; + + /// Virtual cursor position, in remote pixel space (touchpad model). + Offset _cursor = Offset.zero; + + /// Touch input mode (trackpad vs direct). Persisted for the session. + _InputMode _mode = _InputMode.trackpad; + + /// Mouse buttons currently held (so pen/touch drags keep the button down + /// across move events). Bitmask of [_btnLeft]/[_btnRight]. + int _heldButtons = 0; + + /// Kind of the most recent pointer down — lets the touch gestures ignore a + /// stylus (which the Listener handles as a precise absolute pointer instead). + PointerDeviceKind _activeKind = PointerDeviceKind.touch; + bool get _penActive => + _activeKind == PointerDeviceKind.stylus || + _activeKind == PointerDeviceKind.invertedStylus; + + // ---- view transform (pinch-zoom + pan) ----------------------------------- + double _viewScale = 1.0; + Offset _viewOffset = Offset.zero; + double _gestureStartScale = 1.0; + Offset _gestureStartOffset = Offset.zero; + Offset _gestureStartFocal = Offset.zero; + Size _viewport = Size.zero; + static const double _minScale = 1.0; + static const double _maxScale = 6.0; + static const double _touchSensitivity = 2.2; + + // ---- keyboard ------------------------------------------------------------ + final FocusNode _bodyFocus = FocusNode(debugLabel: 'netdesk-body'); + final FocusNode _kbFocus = FocusNode(debugLabel: 'netdesk-keyboard'); + final TextEditingController _kbController = + TextEditingController(text: _kbBase); + static const String _kbBase = '​'; // zero-width placeholder + bool _keyboardVisible = false; + + static final Map _logicalMap = { + LogicalKeyboardKey.enter: 'Enter', + LogicalKeyboardKey.numpadEnter: 'Enter', + LogicalKeyboardKey.backspace: 'Backspace', + LogicalKeyboardKey.tab: 'Tab', + LogicalKeyboardKey.escape: 'Escape', + LogicalKeyboardKey.space: 'Space', + LogicalKeyboardKey.delete: 'Delete', + LogicalKeyboardKey.insert: 'Insert', + LogicalKeyboardKey.arrowLeft: 'ArrowLeft', + LogicalKeyboardKey.arrowRight: 'ArrowRight', + LogicalKeyboardKey.arrowUp: 'ArrowUp', + LogicalKeyboardKey.arrowDown: 'ArrowDown', + LogicalKeyboardKey.home: 'Home', + LogicalKeyboardKey.end: 'End', + LogicalKeyboardKey.pageUp: 'PageUp', + LogicalKeyboardKey.pageDown: 'PageDown', + LogicalKeyboardKey.capsLock: 'CapsLock', + LogicalKeyboardKey.controlLeft: 'ControlLeft', + LogicalKeyboardKey.controlRight: 'ControlRight', + LogicalKeyboardKey.shiftLeft: 'ShiftLeft', + LogicalKeyboardKey.shiftRight: 'ShiftRight', + LogicalKeyboardKey.altLeft: 'AltLeft', + LogicalKeyboardKey.altRight: 'AltRight', + LogicalKeyboardKey.metaLeft: 'MetaLeft', + LogicalKeyboardKey.metaRight: 'MetaRight', + LogicalKeyboardKey.f1: 'F1', + LogicalKeyboardKey.f2: 'F2', + LogicalKeyboardKey.f3: 'F3', + LogicalKeyboardKey.f4: 'F4', + LogicalKeyboardKey.f5: 'F5', + LogicalKeyboardKey.f6: 'F6', + LogicalKeyboardKey.f7: 'F7', + LogicalKeyboardKey.f8: 'F8', + LogicalKeyboardKey.f9: 'F9', + LogicalKeyboardKey.f10: 'F10', + LogicalKeyboardKey.f11: 'F11', + LogicalKeyboardKey.f12: 'F12', + }; + + // ========================================================================== + // lifecycle + // ========================================================================== + @override + void initState() { + super.initState(); + // Full-screen immersive session: hide the Android status + nav bars so the + // remote screen uses the whole tablet. They slide back in on an edge swipe. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + _sub = nd.sessionEvents(handle: widget.handle).listen( + _onEvent, + onError: (Object e) { + if (!mounted) return; + setState(() { + _phase = _Phase.ended; + _error = '$e'; + }); + }, + ); + } + + @override + void dispose() { + // Restore the normal (edge-to-edge) system bars for the rest of the app. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _sub?.cancel(); + if (!_closed) { + _closed = true; + nd.disconnect(handle: widget.handle); + } + _image?.dispose(); + _kbController.dispose(); + _kbFocus.dispose(); + _bodyFocus.dispose(); + super.dispose(); + } + + // ========================================================================== + // events + // ========================================================================== + void _onEvent(nd.NdEvent event) { + if (!mounted) return; + switch (event) { + case nd.NdEvent_Connecting(): + setState(() => _phase = _Phase.connecting); + case nd.NdEvent_Connected( + :final w, + :final h, + :final e2E, + :final hostName + ): + setState(() { + _remoteW = w; + _remoteH = h; + _e2e = e2E; + _hostName = hostName; + _cursor = Offset(w / 2, h / 2); + _phase = _Phase.live; + _showHint = true; + _currentMon = nd.sessionCurrentMonitor(handle: widget.handle); + }); + _scheduleHintHide(); + case nd.NdEvent_Frame(:final width, :final height, :final rgba): + _ingestFrame(width, height, rgba); + case nd.NdEvent_Ended(:final reason): + _closed = true; + setState(() { + _phase = _Phase.ended; + _error = reason; + }); + case nd.NdEvent_Denied(:final reason): + _closed = true; + setState(() { + _phase = _Phase.denied; + _error = reason; + }); + case nd.NdEvent_ChatMessage(:final from, :final text): + _toast('$from: $text', icon: Icons.chat_bubble_outline_rounded); + case nd.NdEvent_Clipboard(:final text): + Clipboard.setData(ClipboardData(text: text)); + _toast('Clipboard received from host', + icon: Icons.content_paste_rounded); + } + } + + void _ingestFrame(int w, int h, Uint8List rgba) { + ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888, (ui.Image img) { + if (!mounted) { + img.dispose(); + return; + } + final old = _image; + setState(() { + _image = img; + if (_remoteW == 0 || _remoteH == 0) { + _remoteW = w; + _remoteH = h; + _cursor = Offset(w / 2, h / 2); + } + if (_phase == _Phase.connecting) { + _phase = _Phase.live; + _showHint = true; + _scheduleHintHide(); + } + }); + old?.dispose(); + }); + } + + void _scheduleHintHide() { + Future.delayed(const Duration(seconds: 4), () { + if (mounted && _showHint) setState(() => _showHint = false); + }); + } + + // ========================================================================== + // input → FRB + // ========================================================================== + bool get _inputReady => _phase == _Phase.live && _remoteW > 0 && _remoteH > 0; + + void _moveCursorBy(Offset delta) { + if (!_inputReady) return; + final speed = _touchSensitivity / _viewScale; + final nx = (_cursor.dx + delta.dx * speed).clamp(0.0, _remoteW.toDouble()); + final ny = (_cursor.dy + delta.dy * speed).clamp(0.0, _remoteH.toDouble()); + setState(() => _cursor = Offset(nx, ny)); + nd.sendMouse( + handle: widget.handle, + x: nx.round(), + y: ny.round(), + buttons: 0, + wheel: 0, + hwheel: 0, + ); + } + + void _click(int button) { + if (!_inputReady) return; + final x = _cursor.dx.round(); + final y = _cursor.dy.round(); + nd.sendMouse( + handle: widget.handle, + x: x, + y: y, + buttons: button, + wheel: 0, + hwheel: 0); + nd.sendMouse( + handle: widget.handle, x: x, y: y, buttons: 0, wheel: 0, hwheel: 0); + HapticFeedback.selectionClick(); + } + + void _scroll(double dy) { + if (!_inputReady) return; + nd.sendMouse( + handle: widget.handle, + x: _cursor.dx.round(), + y: _cursor.dy.round(), + buttons: 0, + wheel: dy / 6.0, + hwheel: 0, + ); + } + + /// Map a local canvas position (viewport space) to remote pixel coords, + /// undoing the pinch-zoom/pan transform AND the BoxFit.contain letterbox. + Offset? _toRemote(Offset local) { + if (_viewport == Size.zero || _remoteW <= 0 || _remoteH <= 0) return null; + final child = (local - _viewOffset) / _viewScale; + final imgSize = Size(_remoteW.toDouble(), _remoteH.toDouble()); + final fitted = applyBoxFit(BoxFit.contain, imgSize, _viewport); + final dst = + Alignment.center.inscribe(fitted.destination, Offset.zero & _viewport); + if (dst.width <= 0 || dst.height <= 0) return null; + final rx = ((child.dx - dst.left) / dst.width * _remoteW) + .clamp(0.0, _remoteW.toDouble()); + final ry = ((child.dy - dst.top) / dst.height * _remoteH) + .clamp(0.0, _remoteH.toDouble()); + return Offset(rx, ry); + } + + /// Send an absolute cursor position (remote space) with the given buttons held. + void _sendAbsolute(Offset remote, int buttons) { + setState(() => _cursor = remote); + nd.sendMouse( + handle: widget.handle, + x: remote.dx.round(), + y: remote.dy.round(), + buttons: buttons, + wheel: 0, + hwheel: 0, + ); + } + + /// Direct-touch / stylus: move the cursor to where the finger/pen points. + void _moveTo(Offset local) { + if (!_inputReady) return; + final r = _toRemote(local); + if (r != null) _sendAbsolute(r, _heldButtons); + } + + // ---- stylus / pen (precise, absolute, hover-aware) ----------------------- + // Touch and mouse fall through to the GestureDetector (trackpad/direct); only + // a stylus is handled here, so the two paths never fight over one pointer. + bool _isPen(PointerDeviceKind k) => + k == PointerDeviceKind.stylus || k == PointerDeviceKind.invertedStylus; + + void _onPointerDown(PointerDownEvent e) { + _activeKind = e.kind; // record for the gesture handlers' pen guard + if (!_inputReady || !_isPen(e.kind)) return; + final r = _toRemote(e.localPosition); + if (r == null) return; + _heldButtons = _btnLeft; // pen touching = left button down + _sendAbsolute(r, _btnLeft); + } + + void _onPointerMove(PointerMoveEvent e) { + if (_isPen(e.kind)) _moveTo(e.localPosition); + } + + void _onPointerHover(PointerHoverEvent e) { + if (_isPen(e.kind)) { + _heldButtons = 0; + _moveTo(e.localPosition); + } + } + + void _onPointerUp(PointerUpEvent e) { + if (!_isPen(e.kind)) return; + _heldButtons = 0; + final r = _toRemote(e.localPosition); + if (r != null) _sendAbsolute(r, 0); + } + + void _onPointerSignal(PointerSignalEvent e) { + if (e is PointerScrollEvent) _scroll(-e.scrollDelta.dy); + } + + void _onScaleStart(ScaleStartDetails d) { + _gestureStartScale = _viewScale; + _gestureStartOffset = _viewOffset; + _gestureStartFocal = d.localFocalPoint; + } + + void _onScaleUpdate(ScaleUpdateDetails d) { + if (_penActive) return; // pen drags are handled by the Listener + if (d.pointerCount >= 2) { + // Pinch → zoom about the focal point; two-finger translation → pan. + final desired = + (_gestureStartScale * d.scale).clamp(_minScale, _maxScale); + final childPoint = + (_gestureStartFocal - _gestureStartOffset) / _gestureStartScale; + var newOffset = d.localFocalPoint - childPoint * desired; + setState(() { + _viewScale = desired; + _viewOffset = newOffset; + _clampOffset(); + }); + // At base scale there is nothing to pan, so a two-finger drag scrolls. + if (_viewScale <= 1.001 && (d.scale - 1).abs() < 0.02) { + final dy = d.focalPointDelta.dy; + if (dy.abs() > 0.3) _scroll(-dy); + } + } else { + // Single finger: trackpad = relative nudge; direct = jump to the finger. + if (_mode == _InputMode.direct) { + _moveTo(d.localFocalPoint); + } else { + _moveCursorBy(d.focalPointDelta); + } + } + } + + void _clampOffset() { + if (_viewport == Size.zero) return; + final minX = _viewport.width * (1 - _viewScale); + final minY = _viewport.height * (1 - _viewScale); + _viewOffset = Offset( + _viewOffset.dx.clamp(minX, 0.0), + _viewOffset.dy.clamp(minY, 0.0), + ); + } + + void _resetZoom() { + setState(() { + _viewScale = 1.0; + _viewOffset = Offset.zero; + }); + } + + // ========================================================================== + // keyboard + // ========================================================================== + void _sendCode(String code, {bool shift = false}) { + final h = widget.handle; + if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: true); + nd.sendKey(handle: h, code: code, down: true); + nd.sendKey(handle: h, code: code, down: false); + if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: false); + } + + void _typeChar(String ch) { + final spec = _codeForChar(ch); + if (spec == null) return; + _sendCode(spec.$1, shift: spec.$2); + } + + void _onKbChanged(String value) { + if (value == _kbBase) return; + if (!value.startsWith(_kbBase) || value.length < _kbBase.length) { + // The placeholder was eaten → a backspace. + _sendCode('Backspace'); + } else { + final inserted = value.substring(_kbBase.length); + for (final ch in inserted.characters) { + if (ch == '\n') { + _sendCode('Enter'); + } else { + _typeChar(ch); + } + } + } + _resetKb(); + } + + void _resetKb() { + _kbController.value = const TextEditingValue( + text: _kbBase, + selection: TextSelection.collapsed(offset: _kbBase.length), + ); + } + + /// Hardware / IME key events for non-character keys (arrows, F-keys, …). + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + final code = _logicalMap[event.logicalKey]; + if (code == null) return KeyEventResult.ignored; + if (event is KeyDownEvent || event is KeyRepeatEvent) { + nd.sendKey(handle: widget.handle, code: code, down: true); + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + nd.sendKey(handle: widget.handle, code: code, down: false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _toggleKeyboard() { + setState(() => _keyboardVisible = !_keyboardVisible); + if (_keyboardVisible) { + _resetKb(); + _kbFocus.requestFocus(); + } else { + _kbFocus.unfocus(); + _bodyFocus.requestFocus(); + } + } + + void _toggleMode() { + setState(() { + _mode = + _mode == _InputMode.trackpad ? _InputMode.direct : _InputMode.trackpad; + _showHint = true; + }); + _toast( + _mode == _InputMode.trackpad + ? 'Trackpad — drag to move, tap to click' + : 'Direct touch — tap where you want to click', + icon: _mode == _InputMode.trackpad + ? Icons.touch_app_rounded + : Icons.ads_click_rounded, + ); + _scheduleHintHide(); + } + + /// Maps one character to its DOM `code` + whether Shift is required. + (String, bool)? _codeForChar(String ch) { + if (ch.length != 1) return null; + final c = ch.codeUnitAt(0); + if (c >= 0x61 && c <= 0x7A) return ('Key${ch.toUpperCase()}', false); // a-z + if (c >= 0x41 && c <= 0x5A) return ('Key$ch', true); // A-Z + if (c >= 0x30 && c <= 0x39) return ('Digit$ch', false); // 0-9 + switch (ch) { + case ' ': + return ('Space', false); + case '!': + return ('Digit1', true); + case '@': + return ('Digit2', true); + case '#': + return ('Digit3', true); + case r'$': + return ('Digit4', true); + case '%': + return ('Digit5', true); + case '^': + return ('Digit6', true); + case '&': + return ('Digit7', true); + case '*': + return ('Digit8', true); + case '(': + return ('Digit9', true); + case ')': + return ('Digit0', true); + case '-': + return ('Minus', false); + case '_': + return ('Minus', true); + case '=': + return ('Equal', false); + case '+': + return ('Equal', true); + case '[': + return ('BracketLeft', false); + case '{': + return ('BracketLeft', true); + case ']': + return ('BracketRight', false); + case '}': + return ('BracketRight', true); + case '\\': + return ('Backslash', false); + case '|': + return ('Backslash', true); + case ';': + return ('Semicolon', false); + case ':': + return ('Semicolon', true); + case "'": + return ('Quote', false); + case '"': + return ('Quote', true); + case '`': + return ('Backquote', false); + case '~': + return ('Backquote', true); + case ',': + return ('Comma', false); + case '<': + return ('Comma', true); + case '.': + return ('Period', false); + case '>': + return ('Period', true); + case '/': + return ('Slash', false); + case '?': + return ('Slash', true); + } + return null; + } + + // ========================================================================== + // toolbar actions → FRB + // ========================================================================== + Future _disconnectAndPop() async { + if (!_closed) { + _closed = true; + nd.disconnect(handle: widget.handle); + } + if (mounted) Navigator.of(context).maybePop(); + } + + void _ctrlAltDel() { + nd.ctrlAltDel(handle: widget.handle); + _toast('Sent Ctrl + Alt + Del', icon: Icons.keyboard_command_key_rounded); + } + + Future _sendClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text ?? ''; + if (text.isEmpty) { + _toast('Local clipboard is empty'); + return; + } + nd.setClipboard(handle: widget.handle, text: text); + _toast('Clipboard sent to host', icon: Icons.content_paste_go_rounded); + } + + Future _showQuality() async { + const presets = <(String, String, IconData, int, int, double)>[ + ('Auto', 'Balanced 30 fps · 5 Mbps', Icons.auto_awesome_rounded, 30, 5000, + 1.0), + ('Smooth', 'High frame rate · 60 fps', Icons.speed_rounded, 60, 3500, + 0.8), + ('Sharp', 'High detail · 12 Mbps', Icons.hd_rounded, 24, 12000, 1.0), + ('Data saver', 'Low bandwidth · 720p', Icons.data_saver_on_rounded, 20, + 1500, 0.6), + ]; + await _sheet( + title: 'Quality', + children: [ + for (final p in presets) + _SheetTile( + icon: p.$3, + title: p.$1, + subtitle: p.$2, + onTap: () { + Navigator.of(context).pop(); + nd.setQuality( + handle: widget.handle, + fps: p.$4, + bitrate: p.$5, + scale: p.$6); + _toast('Quality: ${p.$1}'); + }, + ), + ], + ); + } + + Future _showMonitors() async { + // Ask the core for the displays the host actually reported — never a + // hardcoded count. The switch index is each monitor's own `i`. + final mons = nd.sessionMonitors(handle: widget.handle); + if (mons.length <= 1) { + _toast( + mons.isEmpty + ? 'The host reported no displays' + : 'The host has a single display', + icon: Icons.monitor_rounded, + ); + return; + } + await _sheet( + title: 'Displays', + children: [ + for (var idx = 0; idx < mons.length; idx++) + _SheetTile( + icon: Icons.monitor_rounded, + title: 'Monitor ${idx + 1}', + subtitle: _currentMon == mons[idx].i + ? '${mons[idx].w} × ${mons[idx].h} · Now viewing' + : '${mons[idx].w} × ${mons[idx].h}', + selected: _currentMon == mons[idx].i, + onTap: () { + Navigator.of(context).pop(); + if (_currentMon == mons[idx].i) return; // already on this one + nd.monitorSwitch(handle: widget.handle, i: mons[idx].i); + setState(() => _currentMon = mons[idx].i); + _toast('Switched to Monitor ${idx + 1}', + icon: Icons.monitor_rounded); + }, + ), + ], + ); + } + + Future _showMore() async { + await _sheet( + title: 'More', + children: [ + _SheetTile( + icon: Icons.chat_bubble_outline_rounded, + title: 'Chat', + subtitle: 'Send a message to the host', + onTap: () { + Navigator.of(context).pop(); + _openChat(); + }, + ), + _SheetTile( + icon: Icons.content_paste_go_rounded, + title: 'Send clipboard', + subtitle: 'Push this device\'s clipboard to the host', + onTap: () { + Navigator.of(context).pop(); + _sendClipboard(); + }, + ), + _SheetTile( + icon: Icons.center_focus_strong_rounded, + title: 'Reset zoom', + subtitle: 'Fit the remote screen again', + onTap: () { + Navigator.of(context).pop(); + _resetZoom(); + }, + ), + ], + ); + } + + Future _openChat() async { + final controller = TextEditingController(); + final text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: NetDeskColors.surface, + title: const Text('Send a message'), + content: TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration(hintText: 'Type a message…'), + onSubmitted: (v) => Navigator.of(ctx).pop(v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('Send'), + ), + ], + ), + ); + controller.dispose(); + final msg = text?.trim() ?? ''; + if (msg.isNotEmpty) { + nd.sendChat(handle: widget.handle, text: msg); + _toast('Message sent', icon: Icons.send_rounded); + } + } + + // ========================================================================== + // helpers + // ========================================================================== + void _toast(String message, {IconData? icon}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: NetDeskColors.surface, + content: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: NetDeskColors.accent), + const SizedBox(width: NetDeskSpacing.sm), + ], + Expanded(child: Text(message)), + ], + ), + ), + ); + } + + Future _sheet({ + required String title, + required List children, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: NetDeskColors.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(NetDeskRadius.xl)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + NetDeskSpacing.md, + 0, + NetDeskSpacing.md, + NetDeskSpacing.md, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.sm, vertical: NetDeskSpacing.xs), + child: Text( + title.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.4, + color: NetDeskColors.textMuted, + ), + ), + ), + ...children, + ], + ), + ), + ), + ); + } + + // ========================================================================== + // build + // ========================================================================== + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + _disconnectAndPop(); + }, + child: Scaffold( + backgroundColor: Colors.black, + resizeToAvoidBottomInset: false, + body: Focus( + focusNode: _bodyFocus, + autofocus: true, + onKeyEvent: _onKey, + child: Stack( + children: [ + Positioned.fill(child: _buildCanvas()), + _buildHiddenKeyboard(), + if (_phase == _Phase.live) ...[ + if (_controlsVisible) _buildTopBar(), + if (_showHint) _buildHint(), + if (_controlsVisible) _buildToolbar(), + if (!_controlsVisible) _buildShowControlsButton(), + ], + if (_phase != _Phase.live) _buildStatusOverlay(), + ], + ), + ), + ), + ); + } + + Widget _buildCanvas() { + return LayoutBuilder( + builder: (context, constraints) { + _viewport = constraints.biggest; + return Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerHover: _onPointerHover, + onPointerSignal: _onPointerSignal, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (d) { + if (_penActive) return; // the pen's tap is a Listener click + if (_mode == _InputMode.direct) _moveTo(d.localPosition); + _click(_btnLeft); + }, + onLongPressStart: (d) { + if (_penActive) return; + if (_mode == _InputMode.direct) _moveTo(d.localPosition); + _click(_btnRight); + }, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + child: ClipRect( + child: Transform.translate( + offset: _viewOffset, + child: Transform.scale( + scale: _viewScale, + alignment: Alignment.topLeft, + child: _image == null + ? const SizedBox.expand() + : CustomPaint( + size: _viewport, + painter: _RemotePainter( + image: _image!, + cursor: _cursor, + remoteW: _remoteW, + remoteH: _remoteH, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildTopBar() { + return Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: Container( + padding: EdgeInsets.fromLTRB( + NetDeskSpacing.md, + MediaQuery.of(context).padding.top + NetDeskSpacing.sm, + NetDeskSpacing.md, + NetDeskSpacing.md, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xCC000000), Color(0x00000000)], + ), + ), + child: Row( + children: [ + const _ConnectedDot(), + const SizedBox(width: NetDeskSpacing.sm), + Expanded( + child: Text( + _hostName.isEmpty ? widget.partnerId : _hostName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + ), + if (_e2e) + Row( + children: [ + Icon(Icons.lock_rounded, + size: 13, + color: NetDeskColors.accent.withValues(alpha: 0.9)), + const SizedBox(width: 4), + const Text( + 'E2E', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: NetDeskColors.accent, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildHint() { + return Positioned( + left: 0, + right: 0, + top: MediaQuery.of(context).padding.top + 48, + child: IgnorePointer( + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, vertical: NetDeskSpacing.sm), + decoration: BoxDecoration( + color: NetDeskColors.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + ), + child: Text( + _mode == _InputMode.trackpad + ? 'Trackpad · drag to move · tap to click · hold = right-click' + : 'Direct touch · tap the screen where you want to click', + style: const TextStyle( + fontSize: 12, color: NetDeskColors.textSecondary), + ), + ), + ), + ), + ); + } + + Widget _buildToolbar() { + return Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + NetDeskSpacing.md, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: NetDeskColors.surface.withValues(alpha: 0.96), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: NetDeskColors.border), + boxShadow: const [ + BoxShadow(color: Color(0x66000000), blurRadius: 24, spreadRadius: 2), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ToolButton( + icon: Icons.call_end_rounded, + tooltip: 'Disconnect', + color: NetDeskColors.danger, + onPressed: _disconnectAndPop, + ), + _ToolButton( + icon: _keyboardVisible + ? Icons.keyboard_hide_rounded + : Icons.keyboard_rounded, + tooltip: 'Keyboard', + active: _keyboardVisible, + onPressed: _toggleKeyboard, + ), + _ToolButton( + icon: _mode == _InputMode.trackpad + ? Icons.touch_app_rounded + : Icons.ads_click_rounded, + tooltip: _mode == _InputMode.trackpad + ? 'Trackpad mode' + : 'Direct touch', + active: _mode == _InputMode.direct, + onPressed: _toggleMode, + ), + _ToolButton( + icon: Icons.monitor_rounded, + tooltip: 'Monitors', + onPressed: _showMonitors, + ), + _ToolButton( + icon: Icons.tune_rounded, + tooltip: 'Quality', + onPressed: _showQuality, + ), + _ToolButton( + icon: Icons.keyboard_command_key_rounded, + tooltip: 'Ctrl + Alt + Del', + onPressed: _ctrlAltDel, + ), + _ToolButton( + icon: Icons.more_horiz_rounded, + tooltip: 'More', + onPressed: _showMore, + ), + _ToolButton( + icon: Icons.fullscreen_rounded, + tooltip: 'Full screen (hide controls)', + onPressed: _toggleControls, + ), + ], + ), + ), + ), + ); + } + + /// Hide/show the top bar + toolbar for a pure full-screen remote view. + void _toggleControls() { + setState(() => _controlsVisible = !_controlsVisible); + } + + /// A small, unobtrusive handle shown while controls are hidden, to bring the + /// toolbar back without interfering with the trackpad/touch area. + Widget _buildShowControlsButton() { + return Positioned( + top: MediaQuery.of(context).padding.top + 6, + right: 8, + child: SafeArea( + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white70), + tooltip: 'Show controls', + onPressed: _toggleControls, + ), + ), + ), + ); + } + + Widget _buildHiddenKeyboard() { + return Positioned( + left: 0, + bottom: 0, + width: 1, + height: 1, + child: Opacity( + opacity: 0, + child: TextField( + focusNode: _kbFocus, + controller: _kbController, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.done, + cursorColor: Colors.transparent, + style: const TextStyle(color: Colors.transparent, fontSize: 1), + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: _onKbChanged, + onSubmitted: (_) { + _sendCode('Enter'); + _resetKb(); + _kbFocus.requestFocus(); + }, + ), + ), + ); + } + + Widget _buildStatusOverlay() { + final connecting = _phase == _Phase.connecting; + final denied = _phase == _Phase.denied; + return Positioned.fill( + child: Container( + color: NetDeskColors.background.withValues(alpha: 0.96), + padding: const EdgeInsets.all(NetDeskSpacing.xl), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (connecting) ...[ + const SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + strokeWidth: 3, color: NetDeskColors.accent), + ), + const SizedBox(height: NetDeskSpacing.lg), + Text( + 'Connecting to ${widget.partnerId}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: NetDeskSpacing.xs), + const Text( + 'Negotiating a secure session…', + style: + TextStyle(fontSize: 13, color: NetDeskColors.textMuted), + ), + ] else ...[ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: (denied ? NetDeskColors.danger : NetDeskColors.accent) + .withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + denied + ? Icons.gpp_bad_rounded + : Icons.link_off_rounded, + size: 34, + color: + denied ? NetDeskColors.danger : NetDeskColors.textSecondary, + ), + ), + const SizedBox(height: NetDeskSpacing.lg), + Text( + denied ? 'Access denied' : 'Session ended', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: NetDeskSpacing.xs), + Text( + _error ?? 'The connection was closed.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, color: NetDeskColors.textSecondary), + ), + const SizedBox(height: NetDeskSpacing.xl), + SizedBox( + width: 220, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, size: 18), + label: const Text('Back'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// A small pulsing green dot indicating an active session. +class _ConnectedDot extends StatelessWidget { + const _ConnectedDot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 9, + height: 9, + decoration: const BoxDecoration( + color: NetDeskColors.accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: NetDeskColors.accentGlow, blurRadius: 8, spreadRadius: 2), + ], + ), + ); + } +} + +/// A round toolbar button. +class _ToolButton extends StatelessWidget { + const _ToolButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + this.color, + this.active = false, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + final Color? color; + final bool active; + + @override + Widget build(BuildContext context) { + final fg = color ?? + (active ? NetDeskColors.accent : NetDeskColors.textSecondary); + return Tooltip( + message: tooltip, + child: Material( + color: active + ? NetDeskColors.accentGlow + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(11), + child: Icon(icon, size: 22, color: fg), + ), + ), + ), + ); + } +} + +/// A row in a bottom-sheet menu. +class _SheetTile extends StatelessWidget { + const _SheetTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.selected = false, + this.trailing, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + /// Highlight this row as the current choice (e.g. the active monitor). + final bool selected; + + /// Override the trailing glyph; defaults to a chevron, or a check when + /// [selected]. Pass [Icons.check_circle_rounded] etc. explicitly if needed. + final IconData? trailing; + + @override + Widget build(BuildContext context) { + final accent = NetDeskColors.accent; + final trailingIcon = trailing ?? + (selected ? Icons.check_circle_rounded : Icons.chevron_right_rounded); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Material( + color: selected + ? Color.alphaBlend(accent.withValues(alpha: 0.14), + NetDeskColors.surfaceAlt) + : NetDeskColors.surfaceAlt, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NetDeskRadius.md), + child: Container( + decoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + border: Border.all(color: accent.withValues(alpha: 0.55)), + ) + : null, + padding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, vertical: 14), + child: Row( + children: [ + Icon(icon, color: accent, size: 22), + const SizedBox(width: NetDeskSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: NetDeskColors.textPrimary, + ), + ), + const SizedBox(height: 1), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, color: NetDeskColors.textMuted), + ), + ], + ), + ), + Icon(trailingIcon, + color: selected ? accent : NetDeskColors.textMuted, + size: 20), + ], + ), + ), + ), + ), + ); + } +} + +/// Paints the latest remote frame fit-to-box plus the virtual touchpad cursor. +class _RemotePainter extends CustomPainter { + _RemotePainter({ + required this.image, + required this.cursor, + required this.remoteW, + required this.remoteH, + }); + + final ui.Image image; + final Offset cursor; + final int remoteW; + final int remoteH; + + @override + void paint(Canvas canvas, Size size) { + final imgSize = Size(image.width.toDouble(), image.height.toDouble()); + final fitted = applyBoxFit(BoxFit.contain, imgSize, size); + final dst = Alignment.center.inscribe(fitted.destination, Offset.zero & size); + final src = Alignment.center.inscribe(fitted.source, Offset.zero & imgSize); + + canvas.drawImageRect( + image, + src, + dst, + Paint() + ..filterQuality = FilterQuality.medium + ..isAntiAlias = true, + ); + + if (remoteW > 0 && remoteH > 0) { + final cx = dst.left + (cursor.dx / remoteW) * dst.width; + final cy = dst.top + (cursor.dy / remoteH) * dst.height; + _drawCursor(canvas, Offset(cx, cy)); + } + } + + void _drawCursor(Canvas canvas, Offset p) { + // A classic arrow pointer with the tip exactly at p — white fill with a dark + // outline so it stays visible on any background (not a target/crosshair). + final path = Path() + ..moveTo(p.dx, p.dy) + ..lineTo(p.dx, p.dy + 17.5) + ..lineTo(p.dx + 4.4, p.dy + 13.4) + ..lineTo(p.dx + 7.4, p.dy + 20.0) + ..lineTo(p.dx + 10.2, p.dy + 18.7) + ..lineTo(p.dx + 7.3, p.dy + 12.2) + ..lineTo(p.dx + 12.8, p.dy + 12.2) + ..close(); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..strokeJoin = StrokeJoin.round + ..color = const Color(0xD0000000), + ); + canvas.drawPath(path, Paint()..color = Colors.white); + } + + @override + bool shouldRepaint(_RemotePainter old) => + old.image != image || + old.cursor != cursor || + old.remoteW != remoteW || + old.remoteH != remoteH; +} diff --git a/native/ui/lib/theme.dart b/native/ui/lib/theme.dart new file mode 100644 index 0000000..f1c411d --- /dev/null +++ b/native/ui/lib/theme.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// NetDesk design tokens + [ThemeData]. +/// +/// A single, dark, premium theme in the spirit of TeamViewer / RustDesk — +/// deep near-black surfaces, a confident green accent, generous spacing and +/// softly rounded cards. All colors live here as tokens so the rest of the +/// app never hardcodes a hex value. +class NetDeskColors { + NetDeskColors._(); + + /// App background — deep, near-black with a cool blue cast. + static const Color background = Color(0xFF0B0F15); + + /// Raised surface (cards, panels) — one step lighter than the background. + static const Color surface = Color(0xFF121821); + + /// A second elevation used for inputs / inset wells. + static const Color surfaceAlt = Color(0xFF0E141C); + + /// Hairline borders and dividers. + static const Color border = Color(0xFF1E2733); + + /// Primary brand accent — a vivid, trustworthy green. + static const Color accent = Color(0xFF22C55E); + + /// Darker accent for pressed / gradient ends. + static const Color accentDeep = Color(0xFF16A34A); + + /// Soft accent glow used sparingly behind the wordmark. + static const Color accentGlow = Color(0x3322C55E); + + /// High-emphasis text. + static const Color textPrimary = Color(0xFFF2F5F9); + + /// Medium-emphasis text (subtitles, labels). + static const Color textSecondary = Color(0xFF9AA7B8); + + /// Low-emphasis text (hints, captions). + static const Color textMuted = Color(0xFF5E6B7D); + + /// Destructive / disconnect actions. + static const Color danger = Color(0xFFEF4444); +} + +class NetDeskRadius { + NetDeskRadius._(); + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 22; +} + +class NetDeskSpacing { + NetDeskSpacing._(); + static const double xs = 6; + static const double sm = 10; + static const double md = 16; + static const double lg = 24; + static const double xl = 36; +} + +class NetDeskTheme { + NetDeskTheme._(); + + static ThemeData get dark { + const scheme = ColorScheme.dark( + primary: NetDeskColors.accent, + onPrimary: Color(0xFF04210F), + secondary: NetDeskColors.accentDeep, + onSecondary: Color(0xFF04210F), + surface: NetDeskColors.surface, + onSurface: NetDeskColors.textPrimary, + error: NetDeskColors.danger, + onError: Colors.white, + outline: NetDeskColors.border, + ); + + final base = ThemeData.dark(useMaterial3: true); + + return base.copyWith( + colorScheme: scheme, + scaffoldBackgroundColor: NetDeskColors.background, + canvasColor: NetDeskColors.background, + dividerColor: NetDeskColors.border, + splashFactory: InkSparkle.splashFactory, + + textTheme: base.textTheme + .apply( + bodyColor: NetDeskColors.textPrimary, + displayColor: NetDeskColors.textPrimary, + fontFamily: 'Roboto', + ) + .copyWith( + bodyMedium: const TextStyle( + color: NetDeskColors.textSecondary, + height: 1.4, + ), + labelLarge: const TextStyle( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + + appBarTheme: const AppBarTheme( + backgroundColor: NetDeskColors.surfaceAlt, + foregroundColor: NetDeskColors.textPrimary, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: NetDeskColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + + cardTheme: CardThemeData( + color: NetDeskColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + side: const BorderSide(color: NetDeskColors.border), + borderRadius: BorderRadius.circular(NetDeskRadius.xl), + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: NetDeskColors.surfaceAlt, + hintStyle: const TextStyle(color: NetDeskColors.textMuted), + labelStyle: const TextStyle(color: NetDeskColors.textSecondary), + contentPadding: const EdgeInsets.symmetric( + horizontal: NetDeskSpacing.md, + vertical: 18, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.accent, width: 1.6), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + borderSide: const BorderSide(color: NetDeskColors.danger), + ), + ), + + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: NetDeskColors.accent, + foregroundColor: const Color(0xFF04210F), + disabledBackgroundColor: NetDeskColors.border, + disabledForegroundColor: NetDeskColors.textMuted, + minimumSize: const Size.fromHeight(54), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.md), + ), + ), + ), + + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: NetDeskColors.textSecondary, + hoverColor: NetDeskColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NetDeskRadius.sm), + ), + ), + ), + + tooltipTheme: const TooltipThemeData( + decoration: BoxDecoration( + color: Color(0xFF1B2430), + borderRadius: BorderRadius.all(Radius.circular(NetDeskRadius.sm)), + ), + textStyle: TextStyle(color: NetDeskColors.textPrimary, fontSize: 12), + ), + + dividerTheme: const DividerThemeData( + color: NetDeskColors.border, + thickness: 1, + space: 1, + ), + ); + } +} diff --git a/native/ui/pubspec.lock b/native/ui/pubspec.lock new file mode 100644 index 0000000..799b8a4 --- /dev/null +++ b/native/ui/pubspec.lock @@ -0,0 +1,605 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf + url: "https://pub.dev" + source: hosted + version: "4.12.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: "direct main" + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: ec69331e2335ab4d456c0a9fc700892c2c85b72b9c0cb39df3ecfe8833d5d5c8 + url: "https://pub.dev" + source: hosted + version: "2.13.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/native/ui/pubspec.yaml b/native/ui/pubspec.yaml new file mode 100644 index 0000000..9e471b8 --- /dev/null +++ b/native/ui/pubspec.yaml @@ -0,0 +1,43 @@ +name: netdesk +description: NetDesk by Akronlogic — premium cross-platform remote desktop front-end. +publish_to: "none" +version: 0.3.2+7 + +environment: + # Dart 3.6 / Flutter 3.27 for the non-deprecated Color.withValues() API. + sdk: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" + +dependencies: + flutter: + sdk: flutter + # Cupertino icons are handy for the cross-platform (iOS/macOS) builds. + cupertino_icons: ^1.0.8 + + # --------------------------------------------------------------------------- + # PHASE 3 (Rust core binding). The UI binds to the Rust `netdesk-frb` crate + # (over `netdesk-core`) through flutter_rust_bridge. The typed Dart<->Rust FFI + # glue under lib/rust/ is produced by `flutter_rust_bridge_codegen generate` + # (config: ../frb/flutter_rust_bridge.yaml). Pinned to the installed codegen. + flutter_rust_bridge: 2.13.0 + ffi: ^2.1.0 + # FRB generates @freezed sealed classes for the Rust enums (e.g. NdEvent); + # freezed_annotation is the runtime side of that. + freezed_annotation: ^2.4.4 + # --------------------------------------------------------------------------- + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + # Code generators for the FRB Dart bindings: `freezed` builds the sealed + # classes, driven by `build_runner` (flutter pub run build_runner build). + freezed: ^2.5.7 + build_runner: ^2.4.13 + +flutter: + uses-material-design: true + # Brand marks (NetDesk by Akronlogic) used by the connect screen. + assets: + - assets/icon.png + - assets/icon-512.png diff --git a/native/ui/test/widget_test.dart b/native/ui/test/widget_test.dart new file mode 100644 index 0000000..c788b7a --- /dev/null +++ b/native/ui/test/widget_test.dart @@ -0,0 +1,22 @@ +// Smoke test for the NetDesk connect screen. +// +// Builds [NetDeskApp] directly (bypassing `main`, which would otherwise try to +// load the native Rust engine) and verifies the brand mark and primary action +// render. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:netdesk/main.dart'; + +void main() { + testWidgets('Connect screen shows the NetDesk brand and Connect button', + (WidgetTester tester) async { + await tester.pumpWidget(const NetDeskApp()); + + expect(find.text('NetDesk'), findsOneWidget); + expect(find.text('by Akronlogic'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.byType(FilledButton), findsWidgets); + }); +} diff --git a/native/viewer/Cargo.toml b/native/viewer/Cargo.toml new file mode 100644 index 0000000..25f3021 --- /dev/null +++ b/native/viewer/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "netdesk-viewer" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "NetDesk native viewer window — GPU-filtered (bilinear) remote-screen rendering with a full TeamViewer/RustDesk-style toolbar, built on eframe + egui (winit + glow). Developed by Akronlogic." + +[[bin]] +name = "netdesk-viewer" +path = "src/main.rs" + +[dependencies] +netdesk-core = { path = "../core" } +tokio = { workspace = true } +anyhow = { workspace = true } +# control-protocol payloads (session.info / sys.res) arrive as JSON strings from +# the core; parse them in the net thread into clean Rust before the UI sees them. +serde_json = { workspace = true } +# host-control module (host_ctl.rs) models the shared config.json with derive. +serde = { workspace = true } +# host-control module: blocking HTTPS (rustls) to the relay /api — account +# sign-in / sign-up / sign-out, device list, and /health ping. Enable the +# "json" feature for send_json / into_json (tls + gzip are on by default). +ureq = { version = "2.10", features = ["json"] } +# eframe bundles winit + the GPU backend (glow/OpenGL) + the event loop, and egui +# gives us a real toolbar/menus over the remote image. egui TextureOptions::LINEAR +# = bilinear GPU filtering = smooth + clear scaling of the decoded frame. +eframe = { version = "0.29", default-features = false, features = ["glow", "default_fonts"] } +egui = "0.29" +# OS clipboard read/write for RustDesk-style clipboard sync (egui can set but not +# read the clipboard; arboard does both). Already in the tree via eframe. +arboard = "3" +# decode the embedded NetDesk logo PNG into pixels (window icon + in-app logo +# texture) and encode the live frame to PNG for "Save screenshot". +image = { version = "0.25", default-features = false, features = ["png"] } +# native OS file-picker for the File transfer panel's "Upload…" button (pick a +# local file to send to the host). Sync FileDialog only, so no async runtime +# feature is needed; the Linux-backend features are irrelevant on Windows. +rfd = { version = "0.15", default-features = false } +# pure-Rust QR encoder (Nayuki) for in-app TOTP 2FA enrollment — render the +# host's otpauth:// secret as a QR the user scans with an authenticator app. +qrcodegen = "1.8" +# crisp, consistent icon set (web / copy / folder / lock / power / arrows / …) so +# every button shows a real icon instead of a missing-glyph box. Pure-Rust +# embedded font (Phosphor), no C build; matches egui 0.29. +egui-phosphor = "0.7" +# logging for the headless --host diagnostics +env_logger = "0.11" +# NOTE: remote audio playback ("Play remote computer sound") is implemented in +# src/audio.rs (cpal + Opus) but not yet wired in — the libopus build on this +# windows-gnu toolchain needs a reliable Opus source (audiopus_sys 0.1.8's build +# script mangles extended-length paths). Re-enable cpal + an Opus decoder here +# and `mod audio;` in main.rs once that is sorted. + +# Unified app: the native Rust host engine (capture+encode+SendInput) runs +# IN-PROCESS when the user clicks Start Service, so one NetDesk.exe both views +# and hosts — no Python. Windows-only (DXGI/MediaFoundation/SendInput live here). +[target.'cfg(windows)'.dependencies] +netdesk-host = { path = "../host" } +# Remote audio playback (src/audio.rs): Opus decode via the vendored libopus +# + cpal (WASAPI) output. +netdesk-opus = { path = "../opus" } +cpal = "0.15" diff --git a/native/viewer/build.rs b/native/viewer/build.rs new file mode 100644 index 0000000..81237a2 --- /dev/null +++ b/native/viewer/build.rs @@ -0,0 +1,74 @@ +//! Build script for the NetDesk native viewer. +//! +//! Embeds the NetDesk application icon (`assets/icon.ico`, multi-size) into the +//! Windows executable so Explorer, the taskbar and Alt-Tab all show the branded +//! green NetDesk mark. On the `x86_64-pc-windows-gnu` toolchain used for the +//! portable build, the resource is compiled with `windres` (shipped in the +//! MinGW bin dir that is on `PATH` during the workflow build) into a COFF object +//! which is then forced onto the final binary link line. No extra crates are +//! required — this keeps the build hermetic and offline-safe. +//! +//! If `windres` is unavailable the build still succeeds (a warning is emitted); +//! the runtime `ViewportBuilder::with_icon` call in `main.rs` still gives the +//! window + taskbar the icon, only the on-disk `.exe` file icon is skipped. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + // The .ico resource only applies to Windows PE binaries. + if target_os != "windows" { + return; + } + + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let ico = manifest.join("..").join("assets").join("icon.ico"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", ico.display()); + + if !ico.exists() { + println!( + "cargo:warning=NetDesk icon not found at {} — exe icon not embedded", + ico.display() + ); + return; + } + + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let rc_path = out.join("netdesk.rc"); + let obj_path = out.join("netdesk_icon.o"); + + // windres wants forward slashes inside the quoted path (backslashes are + // treated as escapes in the .rc grammar). Resource id 1 => the app's + // default icon group, which is what Explorer/Shell pick up. + let ico_fwd = ico.display().to_string().replace('\\', "/"); + if std::fs::write(&rc_path, format!("1 ICON \"{ico_fwd}\"\n")).is_err() { + println!("cargo:warning=could not write {} — exe icon not embedded", rc_path.display()); + return; + } + + match Command::new("windres") + .arg(&rc_path) + .args(["-O", "coff", "-o"]) + .arg(&obj_path) + .status() + { + Ok(status) if status.success() => { + // -bins: attach the resource object only to binary targets, and force + // it onto the link line (resources have no referenced symbols, so a + // plain static lib could be dropped — a direct link-arg cannot). + println!("cargo:rustc-link-arg-bins={}", obj_path.display()); + } + Ok(status) => { + println!( + "cargo:warning=windres exited with {status} — exe icon not embedded (window icon still set at runtime)" + ); + } + Err(e) => { + println!( + "cargo:warning=windres not found ({e}) — exe icon not embedded (window icon still set at runtime)" + ); + } + } +} diff --git a/native/viewer/src/audio.rs b/native/viewer/src/audio.rs new file mode 100644 index 0000000..d599ba7 --- /dev/null +++ b/native/viewer/src/audio.rs @@ -0,0 +1,94 @@ +//! Remote audio playback — "Play remote computer sound". +//! +//! The host streams its system audio as Opus (CHAN_AUDIO `0xa1`), one 20 ms +//! frame at 48 kHz stereo per packet — exactly what the web viewer decodes with +//! WebCodecs. Here we decode each Opus packet with libopus (via the vendored +//! `netdesk-opus` crate) and play the PCM through the default output device with +//! `cpal` (WASAPI on Windows). A short ring buffer bridges the decode side (net +//! thread) and the audio callback (cpal's own thread) and caps latency. +//! +//! Developed by Akronlogic. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use netdesk_opus::Decoder; + +const SR: u32 = 48_000; +const CH: usize = 2; + +/// Decodes the host's Opus audio and plays it. Keep the instance alive for the +/// whole session — dropping it stops the output stream. +pub struct AudioPlayer { + _stream: cpal::Stream, // keep alive: dropping stops playback + decoder: Decoder, + ring: Arc>>, +} + +impl AudioPlayer { + /// Open the default output device at 48 kHz stereo and start playing silence. + pub fn new() -> anyhow::Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| anyhow::anyhow!("no default audio output device"))?; + + let config = cpal::StreamConfig { + channels: CH as u16, + sample_rate: cpal::SampleRate(SR), + buffer_size: cpal::BufferSize::Default, + }; + + let ring: Arc>> = + Arc::new(Mutex::new(VecDeque::with_capacity(SR as usize))); + let ring_cb = ring.clone(); + + let stream = device.build_output_stream( + &config, + move |out: &mut [f32], _: &cpal::OutputCallbackInfo| match ring_cb.lock() { + Ok(mut r) => { + for s in out.iter_mut() { + *s = r.pop_front().unwrap_or(0.0); + } + } + Err(_) => { + for s in out.iter_mut() { + *s = 0.0; + } + } + }, + move |err| eprintln!("[audio] output stream error: {err}"), + None, + )?; + stream.play()?; + + let decoder = Decoder::new(SR, CH as u32)?; + Ok(Self { + _stream: stream, + decoder, + ring, + }) + } + + /// Decode one Opus packet and enqueue the resulting PCM for playback. + /// Silently drops malformed/undecodable packets (best-effort, like the web + /// viewer). + pub fn push_opus(&mut self, data: &[u8]) { + let pcm = match self.decoder.decode(data) { + Ok(p) => p, // interleaved i16, stereo + Err(_) => return, + }; + if let Ok(mut r) = self.ring.lock() { + for &s in &pcm { + r.push_back(s as f32 / 32768.0); + } + // Cap buffered audio to ~400 ms so playback can't drift far behind + // the video (drop the oldest samples on overrun). + let cap = SR as usize * CH * 2 / 5; + while r.len() > cap { + r.pop_front(); + } + } + } +} diff --git a/native/viewer/src/home.rs b/native/viewer/src/home.rs new file mode 100644 index 0000000..b8cb1a3 --- /dev/null +++ b/native/viewer/src/home.rs @@ -0,0 +1,2905 @@ +//! NetDesk HOME / HOST PANEL — the enterprise management console. +//! +//! This is the default view of the unified NetDesk app (the app acts as a HOST +//! here; it switches to the in-session VIEWER when the user connects out to a +//! partner). The panel mirrors `netdesk/host/netdesk_gui.html` pixel-for-pixel +//! in layout and palette, but is painted natively in `egui` with a bespoke +//! 3D / depth treatment that flat immediate-mode UI does not give for free: +//! +//! * Layered soft **drop shadows** behind every card and primary button +//! (`egui::epaint::Shadow`), growing on hover for a tactile **lift**. +//! * **Vertical gradient fills** painted as a rounded-rect vertex-colour +//! **mesh** (`rounded_vgrad`): cards are faintly lighter at the top; the +//! primary buttons carry the NetDesk green `#16a765 -> #22d084` ramp with a +//! soft outer **glow**; the big ID number gets a subtle green glow. +//! * A 1px **inner-highlight bevel** along the top edge of each card for a +//! raised, glassy feel. +//! * Raised, tactile **toggle switches** with a soft-shadowed knob and an +//! on-state green glow. +//! +//! Every control is wired to `crate::host_ctl` (the native port of the Python +//! agent's `Api`): real config.json read/write, live engine status, Start/Stop +//! (pause/resume — the Scheduled-Task auto-start is never touched), the +//! unattended password (PBKDF2-200k), 2FA, the five switches, account +//! sign-in/up and the device fleet, and the relay ping. Blocking work runs on +//! short-lived worker threads and reports back over an `mpsc` channel so the +//! egui frame never stalls. +//! +//! Developed by Akronlogic.com. + +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::time::{Duration, Instant}; + +use eframe::egui; +use egui::{ + epaint::Shadow, Align, Color32, FontId, Layout, Margin, Mesh, Pos2, Rect, RichText, Rounding, + Sense, Shape, Stroke, TextEdit, Ui, Vec2, +}; + +use crate::host_ctl::{self, Device, HostConfig, HostState}; +use egui_phosphor::regular as ph; + +// ============================ PALETTE (HOST-PANEL.md) ====================== +const BG_TOP: Color32 = Color32::from_rgb(11, 15, 21); // #0b0f15 +const BG_BOT: Color32 = Color32::from_rgb(6, 8, 12); // #06080c +const TOPBAR_BG: Color32 = Color32::from_rgb(10, 14, 19); // #0a0e13 +const SIDEBAR_BG: Color32 = Color32::from_rgb(13, 17, 23); // #0d1117 +const CARD_TOP: Color32 = Color32::from_rgb(28, 36, 47); // lighter top of card gradient +const CARD_BOT: Color32 = Color32::from_rgb(21, 28, 37); // darker bottom +const CARD_BORDER: Color32 = Color32::from_rgb(38, 48, 63); // #26303f +const CARD_BORDER_HI: Color32 = Color32::from_rgb(49, 63, 82); // hover border +const INPUT_BG: Color32 = Color32::from_rgb(15, 20, 27); // #0f141b +const INPUT_BORDER: Color32 = Color32::from_rgb(37, 47, 61); // #252f3d +const PANEL_INSET: Color32 = Color32::from_rgb(19, 25, 34); // #131922 side-info / chips + +// The accent greens are now runtime fns (brand accent from brand.json): see +// crate::c_accent() + its hi/lo variants in viewer main.rs. + +const FG: Color32 = Color32::from_rgb(230, 237, 245); // #e6edf5 +const FG_SOFT: Color32 = Color32::from_rgb(208, 216, 226); // #d0d8e2 +const MUTED: Color32 = Color32::from_rgb(139, 151, 168); // #8b97a8 +const FAINT: Color32 = Color32::from_rgb(100, 116, 139); // #64748b +const BLUE: Color32 = Color32::from_rgb(59, 158, 255); // #3b9eff + +const CRIMSON_BG: Color32 = Color32::from_rgb(50, 28, 34); // #321c22 +const CRIMSON_BD: Color32 = Color32::from_rgb(124, 49, 57); // #7c3139 +const CRIMSON_FG: Color32 = Color32::from_rgb(224, 90, 97); // #e05a61 +const GREY_BTN_TOP: Color32 = Color32::from_rgb(90, 100, 114); +const GREY_BTN_BOT: Color32 = Color32::from_rgb(72, 81, 94); + +// ======================= LAYOUT METRICS (netdesk_gui.html) ================= +// The original HTML renders at ~0.82 CSS zoom in its default ~1030x700 window +// (fitZoom keeps an effective 1080x850 canvas). These logical sizes reproduce +// the ORIGINAL'S RENDERED density at that window, so the whole Remote Control +// page fits 700px of height with no scrolling: +// topbar 42 + gutter 10 + bar 34 +10+ grid 389 +10+ start 44 +10+ +// toggles 86 +10+ foot 38 + gutter 8 = 691 ≤ 700. +const PAD: f32 = 13.0; // card inner padding (CSS 16px; trimmed to fit no-scroll) +const GAP: f32 = 8.0; // major row / column gap (CSS 14px; trimmed to fit no-scroll) +const FIELD_H: f32 = 31.0; // text input height (CSS 38px ≈ 31px rendered) +const HEAD_H: f32 = 22.0; // card-head row (small-caps title + right pill) +const BAR_H: f32 = 34.0; // connect-to-partner bar (CSS ~38px) +const BTN_H: f32 = 44.0; // Start / Stop row (CSS 44px) +const FOOT_H: f32 = 38.0; // footer buttons (CSS 40px) + +// ============================ ROUTING RETURN =============================== + +/// What the Home panel asks the hosting app to do after a frame. The only +/// cross-view action is "connect out to this partner" which flips the app into +/// the Connecting/Session route. +pub enum HomeAction { + None, + Connect { id: String }, +} + +// ============================ SIDEBAR NAV ================================== +#[derive(Clone, Copy, PartialEq, Eq)] +enum Nav { + Remote, + Computers, + Security, + Logs, +} + +// ============================ WORKER PLUMBING ============================== +#[derive(Clone, Copy, PartialEq, Eq)] +enum Busy { + Service, + Account, + Pw, + None, +} + +/// Result of a background op that may have mutated the config. +struct CfgResult { + cfg: Option, + toast: Option<(String, String)>, + modal: Option<(String, String)>, + busy: Busy, + reveal_pw: bool, +} + +enum Work { + Ping(std::result::Result), + Devices(std::result::Result, String>), + Cfg(CfgResult), +} + +// ============================ HOME STATE =================================== +pub struct HomeState { + cfg: HostConfig, + cfg_err: Option, + state: HostState, + last_snap: Instant, + + nav: Nav, + + // forms + partner_id: String, + pw_input: String, + pw_show: bool, + server_in: String, + reg_in: String, + name_in: String, + acc_email: String, + acc_pass: String, + allow_in: String, + /// Cached QR texture for in-app TOTP enrollment, keyed by the secret. + totp_qr: Option<(String, egui::TextureHandle)>, + id_spaced: bool, + + // relay ping + ping_ms: Option, + relay_offline: bool, + ping_fails: u32, + last_ping: Instant, + + // fleet + devices: Vec, + dev_err: Option, + dev_q: String, + dev_filter: u8, // 0 all, 1 online, 2 offline + last_dev: Instant, + + // logs + log_lines: Vec, + log_loaded: bool, + + // busy flags + busy_service: bool, + busy_account: bool, + busy_pw: bool, + + // transient UI + toasts: Vec<(String, String, Instant)>, + modal: Option<(String, String)>, + pub show_about: bool, + + // infra + tx: Sender, + rx: Receiver, + ctx: egui::Context, +} + +impl HomeState { + pub fn new(ctx: &egui::Context) -> Self { + let (cfg, cfg_err) = match host_ctl::read_config() { + Ok(c) => (c, None), + Err(e) => (HostConfig::default(), Some(e.to_string())), + }; + let state = host_ctl::snapshot(&cfg); + let (tx, rx) = channel(); + let server_in = if cfg.server.trim().is_empty() { + host_ctl::DEFAULT_SERVER.to_string() + } else { + cfg.server.clone() + }; + let mut s = Self { + server_in, + reg_in: cfg.reg_key.clone(), + name_in: cfg.name.clone(), + acc_email: String::new(), + acc_pass: String::new(), + allow_in: cfg.allow_ips.clone(), + totp_qr: None, + pw_input: cfg.pw_plain.clone(), + partner_id: String::new(), + pw_show: false, + id_spaced: true, + cfg, + cfg_err, + state, + last_snap: Instant::now(), + nav: Nav::Remote, + ping_ms: None, + relay_offline: false, + ping_fails: 0, + last_ping: Instant::now() - Duration::from_secs(30), + devices: Vec::new(), + dev_err: None, + dev_q: String::new(), + dev_filter: 0, + last_dev: Instant::now() - Duration::from_secs(30), + log_lines: Vec::new(), + log_loaded: false, + busy_service: false, + busy_account: false, + busy_pw: false, + toasts: Vec::new(), + modal: None, + show_about: false, + tx, + rx, + ctx: ctx.clone(), + }; + // kick off the first live reads + s.ping_now(); + if s.cfg.signed_in() { + s.devices_now(); + } + s + } + + // ---- background plumbing --------------------------------------------- + fn spawn(&self, f: F) + where + F: FnOnce() -> Work + Send + 'static, + { + let tx = self.tx.clone(); + let ctx = self.ctx.clone(); + std::thread::spawn(move || { + let w = f(); + let _ = tx.send(w); + ctx.request_repaint(); + }); + } + + fn resnapshot(&mut self) { + self.state = host_ctl::snapshot(&self.cfg); + self.last_snap = Instant::now(); + } + + /// Re-read engine status + re-ping (called when returning from a session). + pub fn refresh(&mut self) { + self.resnapshot(); + self.ping_now(); + } + + /// The configured relay server — used to seed an outbound partner connect. + pub fn server(&self) -> String { + if self.server_in.trim().is_empty() { + host_ctl::DEFAULT_SERVER.to_string() + } else { + self.server_in.trim().to_string() + } + } + + fn ping_now(&mut self) { + self.last_ping = Instant::now(); + let cfg = self.cfg.clone(); + self.spawn(move || Work::Ping(host_ctl::ping_relay(&cfg).map_err(|e| e.to_string()))); + } + + fn devices_now(&mut self) { + self.last_dev = Instant::now(); + let cfg = self.cfg.clone(); + self.spawn(move || Work::Devices(host_ctl::list_devices(&cfg).map_err(|e| e.to_string()))); + } + + fn toast(&mut self, title: impl Into, sub: impl Into) { + self.toasts.push(( + title.into(), + sub.into(), + Instant::now() + Duration::from_millis(2800), + )); + } + + /// Drain finished background work and apply it. + fn drain(&mut self) { + while let Ok(w) = self.rx.try_recv() { + match w { + Work::Ping(Ok(ms)) => { + self.ping_ms = Some(ms); + self.ping_fails = 0; + self.relay_offline = false; + } + Work::Ping(Err(_)) => { + self.ping_fails += 1; + if self.ping_fails >= 2 { + self.relay_offline = true; + } + } + Work::Devices(Ok(d)) => { + // Feed the relay's online flag for THIS PC into host_ctl so + // engine_status() can report Online from the server's view + // (detection c) without any extra network call. + host_ctl::note_server_devices(&self.cfg.id, &d); + self.devices = d; + self.dev_err = None; + } + Work::Devices(Err(e)) => self.dev_err = Some(e), + Work::Cfg(c) => { + if let Some(cfg) = c.cfg { + self.cfg = cfg; + self.resnapshot(); + if c.reveal_pw { + self.pw_input = self.cfg.pw_plain.clone(); + self.pw_show = true; + } + } + if let Some(t) = c.toast { + self.toast(t.0, t.1); + } + if let Some(m) = c.modal { + self.modal = Some(m); + } + match c.busy { + Busy::Service => self.busy_service = false, + Busy::Account => self.busy_account = false, + Busy::Pw => self.busy_pw = false, + Busy::None => {} + } + } + } + } + } + + /// Timed refreshers: snapshot ~1s, ping ~12s, devices ~20s. + fn tick(&mut self) { + if self.last_snap.elapsed() > Duration::from_secs(1) { + self.resnapshot(); + } + if self.last_ping.elapsed() > Duration::from_secs(12) { + self.ping_now(); + } + if self.cfg.signed_in() && self.last_dev.elapsed() > Duration::from_secs(20) { + self.devices_now(); + } + } + + // ---- actions (all wired to host_ctl) --------------------------------- + fn do_start(&mut self) { + if self.busy_service { + return; + } + self.busy_service = true; + let mut cfg = self.cfg.clone(); + cfg.server = self.server_in.trim().to_string(); + cfg.name = self.name_in.trim().to_string(); + cfg.reg_key = self.reg_in.trim().to_string(); + self.spawn(move || { + let res = host_ctl::write_config(&cfg).and_then(|_| host_ctl::start_service(&cfg)); + match res { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Engine starting…".into(), "Connecting to the relay".into())), + modal: None, + busy: Busy::Service, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: None, + modal: Some(("Start service".into(), e.to_string())), + busy: Busy::Service, + reveal_pw: false, + }), + } + }); + } + + fn do_stop(&mut self) { + if self.busy_service { + return; + } + self.busy_service = true; + let cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::stop_service(&cfg) { + Ok(_) => Work::Cfg(CfgResult { + cfg: None, + toast: Some(("Engine stopped".into(), "Auto-start stays on".into())), + modal: None, + busy: Busy::Service, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Stop".into(), e.to_string())), + busy: Busy::Service, + reveal_pw: false, + }), + }); + } + + fn do_toggle(&mut self, name: &'static str, on: bool) { + let slow = matches!( + name, + "service_installed" | "service" | "start_with_windows" | "startup" + ); + if slow { + if self.busy_service { + return; + } + self.busy_service = true; + } + let mut cfg = self.cfg.clone(); + let busy = if slow { Busy::Service } else { Busy::None }; + self.spawn(move || match host_ctl::set_toggle(&mut cfg, name, on) { + Ok(msg) => { + let toast = if msg.is_empty() { + Some(( + format!("{} {}", pretty_toggle(name), if on { "on" } else { "off" }), + String::new(), + )) + } else { + Some((pretty_toggle(name).to_string(), msg)) + }; + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast, + modal: None, + busy, + reveal_pw: false, + }) + } + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some((pretty_toggle(name).to_string(), e.to_string())), + busy, + reveal_pw: false, + }), + }); + } + + fn do_pw_set(&mut self) { + let v = self.pw_input.clone(); + if v.is_empty() || self.busy_pw { + return; + } + self.busy_pw = true; + let mut cfg = self.cfg.clone(); + self.spawn( + move || match host_ctl::set_unattended_password(&mut cfg, &v) { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Password saved".into(), "Applies to the next connection".into())), + modal: None, + busy: Busy::Pw, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Password".into(), e.to_string())), + busy: Busy::Pw, + reveal_pw: false, + }), + }, + ); + } + + fn do_pw_gen(&mut self) { + if self.busy_pw { + return; + } + self.busy_pw = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::generate_password(&mut cfg) { + Ok(pw) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: None, + modal: Some(( + "New password".into(), + format!( + "Your new unattended password:\n\n {pw}\n\nIt applies to the next incoming connection — no restart needed." + ), + )), + busy: Busy::Pw, + reveal_pw: true, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("Generate".into(), e.to_string())), + busy: Busy::Pw, + reveal_pw: false, + }), + }); + } + + fn do_2fa(&mut self) { + let turn_on = !self.state.totp; + let mut cfg = self.cfg.clone(); + self.spawn(move || match host_ctl::toggle_2fa(&mut cfg, turn_on) { + Ok(tf) => { + let modal = if tf.on { + Some(( + "2FA enabled".into(), + format!( + "Add this secret to your authenticator app:\n\n {}\n\nA 6-digit code is now required at every connect.", + tf.secret.clone().unwrap_or_default() + ), + )) + } else { + None + }; + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: if tf.on { + None + } else { + Some(("2FA disabled".into(), String::new())) + }, + modal, + busy: Busy::None, + reveal_pw: false, + }) + } + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("2FA".into(), e.to_string())), + busy: Busy::None, + reveal_pw: false, + }), + }); + } + + /// Texture for the TOTP enrollment QR, rebuilt only when the secret changes. + fn totp_qr_texture( + &mut self, + ctx: &egui::Context, + secret: &str, + uri: &str, + ) -> Option { + if let Some((s, tex)) = &self.totp_qr { + if s == secret { + return Some(tex.clone()); + } + } + let img = qr_color_image(uri)?; + let tex = ctx.load_texture("totp_qr", img, egui::TextureOptions::NEAREST); + self.totp_qr = Some((secret.to_string(), tex.clone())); + Some(tex) + } + + fn do_login(&mut self, signup: bool) { + if self.busy_account { + return; + } + let email = self.acc_email.trim().to_string(); + let pass = std::mem::take(&mut self.acc_pass); + if email.is_empty() || pass.is_empty() { + self.toast("Account", "Enter an email and password"); + return; + } + self.busy_account = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || { + let res = if signup { + host_ctl::account_signup(&mut cfg, &email, &pass) + } else { + host_ctl::account_login(&mut cfg, &email, &pass) + }; + match res { + Ok(acc) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(( + if signup { "Account created" } else { "Signed in" }.into(), + acc.email, + )), + modal: None, + busy: Busy::Account, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(( + if signup { "Create account" } else { "Sign in" }.into(), + e.to_string(), + )), + busy: Busy::Account, + reveal_pw: false, + }), + } + }); + } + + fn do_logout(&mut self) { + if self.busy_account { + return; + } + self.busy_account = true; + let mut cfg = self.cfg.clone(); + self.spawn(move || { + let _ = host_ctl::account_logout(&mut cfg); + Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(("Signed out".into(), String::new())), + modal: None, + busy: Busy::Account, + reveal_pw: false, + }) + }); + self.devices.clear(); + } + + fn do_save_allow(&mut self) { + let mut cfg = self.cfg.clone(); + cfg.allow_ips = self.allow_in.trim().to_string(); + let n = cfg + .allow_ips + .split(',') + .filter(|s| !s.trim().is_empty()) + .count(); + self.spawn(move || match host_ctl::write_config(&cfg) { + Ok(_) => Work::Cfg(CfgResult { + cfg: Some(cfg), + toast: Some(( + "Allowlist saved".into(), + if n == 0 { + "cleared — all IPs allowed".into() + } else { + format!("{n} entr{}", if n > 1 { "ies" } else { "y" }) + }, + )), + modal: None, + busy: Busy::None, + reveal_pw: false, + }), + Err(e) => Work::Cfg(CfgResult { + cfg: None, + toast: None, + modal: Some(("IP allowlist".into(), e.to_string())), + busy: Busy::None, + reveal_pw: false, + }), + }); + } + + fn do_copy_id(&mut self) { + match host_ctl::copy_id(&self.cfg) { + Ok(_) => self.toast("ID copied", fmt_id(&self.cfg.id, true)), + Err(e) => self.toast("Copy ID", e.to_string()), + } + } + + fn do_open_web(&mut self) { + if let Err(e) = host_ctl::open_web_viewer(&self.cfg) { + self.toast("Open Web Viewer", e.to_string()); + } + } + + fn do_open_logs(&mut self) { + if let Err(e) = host_ctl::open_logs_folder(&self.cfg) { + self.toast("Logs folder", e.to_string()); + } + } + + fn load_logs(&mut self) { + self.log_loaded = true; + self.log_lines.clear(); + if let Some(dir) = self.cfg.path.parent() { + // the engine writes a rolling log next to the config + for cand in ["netdesk_host.log", "netdesk.log", "host.log"] { + let p = dir.join(cand); + if let Ok(text) = std::fs::read_to_string(&p) { + self.log_lines = text + .lines() + .rev() + .take(300) + .map(|s| s.to_string()) + .collect(); + self.log_lines.reverse(); + return; + } + } + } + } + + // =========================== TOP-LEVEL UI ============================== + pub fn ui(&mut self, ctx: &egui::Context, logo: Option) -> HomeAction { + self.drain(); + self.tick(); + self.toasts.retain(|(_, _, until)| Instant::now() < *until); + + // surface a config-load failure once (defaults are in use) + if let Some(e) = self.cfg_err.take() { + self.modal = Some(( + "Configuration".into(), + format!("Could not read config.json:\n\n{e}\n\nUsing defaults for this session."), + )); + } + + let mut action = HomeAction::None; + + self.top_bar(ctx, logo); + self.sidebar(ctx, logo); + + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(BG_TOP)) + .show(ctx, |ui| { + // page backdrop: soft vertical gradient + faint grid for depth + let full = ui.max_rect(); + ui.painter() + .add(rounded_vgrad(full, 0.0, BG_TOP, BG_BOT)); + paint_grid(ui.painter(), full); + + // The ScrollArea is a SAFETY NET for tiny windows only: at the + // default window the whole Remote Control page fits with no + // scrolling (see the LAYOUT METRICS budget at the top). + let out = egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Consistent page gutters (~18px): cards never touch the + // window edge at any width. Every row below budgets its + // children from this ui's available_width(), so nothing + // can overflow horizontally. + egui::Frame::none() + .inner_margin(Margin { + left: 18.0, + right: 18.0, + top: 10.0, + bottom: 8.0, + }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + // all vertical gaps on this page are explicit + ui.spacing_mut().item_spacing.y = 0.0; + action = self.connect_bar(ui); + ui.add_space(GAP); + match self.nav { + Nav::Remote => { + if let Some(a) = self.view_remote(ui) { + action = a; + } + } + Nav::Computers => { + if let Some(a) = self.view_computers(ui) { + action = a; + } + } + Nav::Security => self.view_security(ui), + Nav::Logs => self.view_logs(ui), + } + }); + }); + // NETDESK_PROBE=1 prints the page-vs-viewport height whenever + // it changes — used to verify the no-scroll fit in self-tests. + if std::env::var_os("NETDESK_PROBE").is_some() { + let key = egui::Id::new("nd_probe_fit"); + let cur = ( + out.content_size.y.round() as i32, + out.inner_rect.height().round() as i32, + ); + let last: Option<(i32, i32)> = ctx.data(|d| d.get_temp(key)); + if last != Some(cur) { + ctx.data_mut(|d| d.insert_temp(key, cur)); + eprintln!( + "[nd-probe] page_h={} viewport_h={} fits={} screen={:.0}x{:.0}", + cur.0, + cur.1, + cur.0 <= cur.1, + ctx.screen_rect().width(), + ctx.screen_rect().height() + ); + } + } + }); + + self.overlays(ctx, logo); + action + } + + // =========================== TOP BAR =================================== + fn top_bar(&mut self, ctx: &egui::Context, logo: Option) { + egui::TopBottomPanel::top("nd_topbar") + .exact_height(42.0) + .frame( + egui::Frame::none() + .fill(TOPBAR_BG) + .inner_margin(Margin::symmetric(14.0, 0.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + // hairline under the bar + let r = ui.max_rect(); + ui.painter().hline( + r.left()..=r.right(), + r.bottom() - 0.5, + Stroke::new(1.0_f32, Color32::from_rgb(29, 38, 51)), + ); + ui.horizontal_centered(|ui| { + logo_mark(ui, logo, 20.0); + ui.add_space(8.0); + ui.label(RichText::new(crate::brand_name()).size(15.0).strong().color(FG)); + + // Width budget for the rest of the bar: the relay chip is + // never clipped; the tagline is the first thing to go when + // the window gets narrow, then the chip drops its TLS text. + let rest = ui.available_width(); + let full_chip_w = self.relay_chip_width(ui, false); + let tagline = "— Enterprise Remote Desktop & Unattended Access"; + let tag_w = ui + .painter() + .layout_no_wrap(tagline.to_string(), FontId::proportional(11.5), FAINT) + .size() + .x; + let show_tag = rest >= tag_w + full_chip_w + 40.0; + if show_tag { + ui.label(RichText::new(tagline).size(11.5).color(FAINT)); + } + let compact_chip = !show_tag && rest < full_chip_w + 16.0; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + self.relay_chip(ui, compact_chip); + }); + }); + }); + } + + /// The relay telemetry segments. `compact` drops the "Relay:" prefix and + /// the TLS suffix so the chip shrinks to "host · ping" on narrow windows. + fn relay_segs(&self, compact: bool) -> (Color32, Vec<(String, Color32, FontId)>) { + let mut host = relay_host(&self.state.server); + // a pathologically long custom hostname must never outgrow the chip + if host.chars().count() > 28 { + host = host.chars().take(27).collect::() + "…"; + } + let (dot_col, ping_txt, ping_col) = if self.relay_offline { + (CRIMSON_FG, "offline".to_string(), CRIMSON_FG) + } else if let Some(ms) = self.ping_ms { + (crate::c_accent(), format!("{ms} ms"), crate::c_accent()) + } else { + (FAINT, "— ms".to_string(), FAINT) + }; + let sep = Color32::from_rgb(51, 65, 85); + let prop = FontId::proportional(11.0); + let mono = FontId::monospace(11.0); + let segs: Vec<(String, Color32, FontId)> = if compact { + vec![ + (host, FG_SOFT, mono.clone()), + ("·".to_string(), sep, prop), + (ping_txt, ping_col, mono), + ] + } else { + vec![ + ("Relay:".to_string(), MUTED, prop.clone()), + (host, FG_SOFT, mono.clone()), + ("·".to_string(), sep, prop.clone()), + (ping_txt, ping_col, mono), + ("·".to_string(), sep, prop.clone()), + ("TLS 1.3 · AES-256-GCM".to_string(), MUTED, prop), + ] + }; + (dot_col, segs) + } + + /// Measured total width of the chip (dot + segments + padding) — used for + /// the top-bar width budget before anything is laid out. + fn relay_chip_width(&self, ui: &Ui, compact: bool) -> f32 { + let (_, segs) = self.relay_segs(compact); + let gap = 6.0; + let text_w: f32 = segs + .into_iter() + .map(|(t, c, f)| ui.painter().layout_no_wrap(t, f, c).size().x + gap) + .sum(); + 13.0 + text_w + 24.0 + } + + /// The live relay telemetry chip. Custom-painted left-to-right at explicit + /// x positions so segment order is correct regardless of the enclosing + /// layout direction (it sits in a right-to-left top-bar region). + fn relay_chip(&self, ui: &mut Ui, compact: bool) { + let (dot_col, segs) = self.relay_segs(compact); + let gap = 6.0; + let dot_w = 13.0; + let padx = 12.0; + let galleys: Vec<_> = segs + .into_iter() + .map(|(t, c, f)| ui.painter().layout_no_wrap(t, f, c)) + .collect(); + let text_w: f32 = galleys.iter().map(|g| g.size().x + gap).sum(); + let size = Vec2::new(dot_w + text_w + padx * 2.0, 23.0); + let (rect, _r) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), Color32::from_rgb(19, 25, 34)); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, Color32::from_rgb(34, 44, 58))); + let mut x = rect.left() + padx; + p.circle_filled(Pos2::new(x + 3.0, rect.center().y), 3.0, dot_col); + x += dot_w; + for g in galleys { + let gw = g.size().x; + let gy = rect.center().y - g.size().y / 2.0; + p.galley(Pos2::new(x, gy), g, FG); + x += gw + gap; + } + } + + // =========================== SIDEBAR =================================== + fn sidebar(&mut self, ctx: &egui::Context, logo: Option) { + // Below ~980px of window width the sidebar collapses to a 56px icon + // rail (icons + tooltips) so the content keeps its room — the standard + // enterprise responsive shell. + if ctx.screen_rect().width() < 980.0 { + self.sidebar_rail(ctx, logo); + return; + } + egui::SidePanel::left("nd_sidebar") + .exact_width(236.0) + .resizable(false) + .frame( + egui::Frame::none() + .fill(SIDEBAR_BG) + .inner_margin(Margin::same(14.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter().vline( + r.right() + 13.0, + r.top() - 14.0..=r.bottom() + 14.0, + Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52)), + ); + + // brand block + ui.horizontal(|ui| { + logo_mark(ui, logo, 40.0); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::brand_name()).size(21.0).strong().color(crate::c_accent())); + ui.label( + RichText::new(format!("v{}", netdesk_core::VERSION)) + .size(11.5) + .color(Color32::from_rgb(110, 123, 139)), + ); + }); + ui.label( + RichText::new("Zero-Trust Remote Agent") + .size(10.5) + .color(MUTED), + ); + }); + }); + ui.add_space(10.0); + ui.painter().hline( + ui.max_rect().left()..=ui.max_rect().right(), + ui.cursor().top(), + Stroke::new(1.0_f32, Color32::from_rgb(28, 36, 48)), + ); + ui.add_space(6.0); + + ui.label( + RichText::new("WORKSPACE") + .size(10.0) + .strong() + .color(Color32::from_rgb(88, 101, 119)), + ); + ui.add_space(4.0); + + let two_fa = if self.state.totp { "2FA On" } else { "2FA Off" }; + let n_dev = self.devices.len(); + if nav_item(ui, "Remote Control", NavBadge::Em("Host"), self.nav == Nav::Remote) { + self.nav = Nav::Remote; + } + if nav_item( + ui, + "My Computers", + NavBadge::Grey(n_dev.to_string()), + self.nav == Nav::Computers, + ) { + self.nav = Nav::Computers; + } + if nav_item( + ui, + "Security & 2FA", + if self.state.totp { + NavBadge::Em(two_fa) + } else { + NavBadge::Red(two_fa) + }, + self.nav == Nav::Security, + ) { + self.nav = Nav::Security; + } + if nav_item( + ui, + "Session Logs", + NavBadge::Grey(if self.log_lines.is_empty() { + "·".to_string() + } else { + self.log_lines.len().to_string() + }), + self.nav == Nav::Logs, + ) { + self.nav = Nav::Logs; + if !self.log_loaded { + self.load_logs(); + } + } + + // footer pinned to the bottom + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + ui.add_space(2.0); + // Akronlogic credit (REQUIRED, always visible) + ui.horizontal(|ui| { + ui.label(RichText::new("Developed by ").size(10.5).color(FAINT)); + if ui + .add( + egui::Label::new( + RichText::new("Akronlogic.com").size(10.5).strong().color(crate::c_accent()), + ) + .sense(Sense::click()), + ) + .on_hover_text("https://akronlogic.com") + .clicked() + { + let _ = open_url("https://akronlogic.com"); + } + }); + if ui + .add(egui::Label::new(RichText::new("About NetDesk").size(10.5).color(MUTED)).sense(Sense::click())) + .clicked() + { + self.show_about = true; + } + ui.add_space(8.0); + + // signed-in chip + if self.cfg.signed_in() { + let mail = self.state.account.clone(); + side_account_chip(ui, &mail); + ui.add_space(8.0); + } + + // local endpoint / daemon status + chip_inset(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Local Endpoint").size(11.0).color(MUTED)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(if self.state.name.is_empty() { + "—" + } else { + &self.state.name + }) + .size(11.0) + .monospace() + .color(crate::c_accent()), + ); + }); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Daemon Status").size(11.0).color(MUTED)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (txt, col) = if self.state.online { + ("● Online", crate::c_accent_hi()) + } else { + ("● Stopped", CRIMSON_FG) + }; + ui.label(RichText::new(txt).size(11.0).strong().color(col)); + }); + }); + }); + }); + }); + } + + /// The collapsed 56px icon rail used on narrow windows: nav icons with + /// tooltips, the daemon status dot and the About entry (which keeps the + /// Akronlogic credit one click away). + fn sidebar_rail(&mut self, ctx: &egui::Context, logo: Option) { + egui::SidePanel::left("nd_sidebar") + .exact_width(56.0) + .resizable(false) + .frame( + egui::Frame::none() + .fill(SIDEBAR_BG) + .inner_margin(Margin::symmetric(8.0, 10.0)), + ) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter().vline( + r.right() + 7.0, + r.top() - 10.0..=r.bottom() + 10.0, + Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52)), + ); + ui.vertical_centered(|ui| { + logo_mark(ui, logo, 30.0); + ui.add_space(12.0); + let two_fa = if self.state.totp { "2FA On" } else { "2FA Off" }; + let items: [(&str, &str, Nav); 4] = [ + ("🖥", "Remote Control", Nav::Remote), + ("💻", "My Computers", Nav::Computers), + ("🛡", "Security & 2FA", Nav::Security), + ("🗒", "Session Logs", Nav::Logs), + ]; + for (glyph, label, nav) in items { + let tip = if nav == Nav::Security { + format!("{label} — {two_fa}") + } else { + label.to_string() + }; + if rail_item(ui, glyph, self.nav == nav, &tip) { + if nav == Nav::Logs && !self.log_loaded { + self.load_logs(); + } + self.nav = nav; + } + ui.add_space(6.0); + } + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.add_space(2.0); + if rail_item(ui, "ⓘ", false, "About NetDesk — Developed by Akronlogic.com") { + self.show_about = true; + } + ui.add_space(6.0); + // daemon status dot (tooltip carries the full text) + let (col, tip) = if self.state.online { + (crate::c_accent_hi(), "Daemon: Online") + } else { + (CRIMSON_FG, "Daemon: Stopped") + }; + let (rect, resp) = + ui.allocate_exact_size(Vec2::splat(14.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, col); + resp.on_hover_text(tip); + }); + }); + }); + } + + // =========================== CONNECT BAR =============================== + fn connect_bar(&mut self, ui: &mut Ui) -> HomeAction { + let mut action = HomeAction::None; + let avail = ui.available_width(); + let sp = 10.0; + + // Natural (measured) widths of the trailing items. The field flexes to + // fill the remaining width; the chip and the caption keep natural size + // and drop out one by one before anything could clip. + let installed = is_installed(); + let pill_label = if installed { "Installed" } else { "Portable" }; + let pill_w = ui + .painter() + .layout_no_wrap(pill_label.to_string(), FontId::proportional(11.0), Color32::WHITE) + .size() + .x + + 20.0; + let cap_txt = if installed { + "installed on this PC" + } else { + "settings next to the app" + }; + let cap_w = ui + .painter() + .layout_no_wrap(cap_txt.to_string(), FontId::proportional(11.5), FAINT) + .size() + .x; + let mut bar_w = avail; + let show_pill = avail >= 430.0; + if show_pill { + bar_w -= pill_w + sp; + } + let show_cap = show_pill && bar_w - (cap_w + sp) >= 300.0; + if show_cap { + bar_w -= cap_w + sp; + } + bar_w = bar_w.min(620.0).max(220.0); + + // One EXACT-height band (BAR_H): the bar's box is painted on a fixed + // rect and everything inside is centered in it, so the Connect button + // can never paint outside the row or get covered by the card below. + exact_row(ui, BAR_H, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = sp; + ui.allocate_ui_with_layout( + Vec2::new(bar_w, BAR_H), + Layout::left_to_right(Align::Center), + |ui| { + ui.set_min_size(Vec2::new(bar_w, BAR_H)); + let rect = Rect::from_min_size(ui.max_rect().min, Vec2::new(bar_w, BAR_H)); + ui.painter().rect_filled(rect, Rounding::same(9.0), INPUT_BG); + ui.painter().rect_stroke( + rect, + Rounding::same(9.0), + Stroke::new(1.0_f32, Color32::from_rgb(38, 50, 66)), + ); + ui.add_space(12.0); + ui.spacing_mut().item_spacing.x = 8.0; + let inner = bar_w - 12.0 - 4.0; // inner budget (side paddings) + let label_txt = "CONNECT TO PARTNER:"; + let label_w = ui + .painter() + .layout_no_wrap(label_txt.to_string(), FontId::proportional(11.0), crate::c_accent()) + .size() + .x; + let btn_w = 104.0; + let show_label = inner >= label_w + btn_w + 150.0; + if show_label { + ui.label(RichText::new(label_txt).size(11.0).strong().color(crate::c_accent())); + } + let used = if show_label { label_w + 8.0 } else { 0.0 }; + let field_w = (inner - used - btn_w - 8.0).max(60.0); + let hint = if field_w < 250.0 { + "Partner ID" + } else { + "Enter remote Partner ID (e.g. 842 190 441)" + }; + let te = ui.add( + TextEdit::singleline(&mut self.partner_id) + .frame(false) + .hint_text(hint) + .desired_width(field_w) + .font(FontId::monospace(13.5)) + .text_color(FG) + .vertical_align(Align::Center), + ); + let go = grad_button( + ui, + &format!("Connect {}", ph::ARROW_RIGHT), + Vec2::new(btn_w, BAR_H - 8.0), + true, + true, + ); + let enter = te.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if go.clicked() || enter { + let digits: String = + self.partner_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 6 { + action = HomeAction::Connect { id: digits }; + } else { + self.toast("Connect to Partner", "Enter the 9-digit Partner ID"); + } + } + }, + ); + if show_pill { + pill_tag( + ui, + pill_label, + if installed { PillKind::Em } else { PillKind::Grey }, + ); + } + if show_cap { + ui.label( + RichText::new(cap_txt) + .size(11.5) + .color(Color32::from_rgb(109, 120, 136)), + ); + } + }); + action + } + + // =========================== REMOTE VIEW =============================== + fn view_remote(&mut self, ui: &mut Ui) -> Option { + // TWO COLUMNS ARE THE NORM — the original shows two ~50/50 columns at + // its default window (grid2: minmax(0,1fr) minmax(0,1fr)). Stack to a + // single column only when the content area is truly narrow (phone-ish + // width); at the default 1030x700 window the content is ~758px wide. + let avail = ui.available_width(); + let two_col = avail >= 620.0; + let mut action = None; + let gap = GAP; + + if two_col { + let col_l = ((avail - gap) * 0.5).floor(); + let col_r = (avail - gap - col_l).floor(); + // Column equalizer (the HTML's `flex: 1` bottom cards): remember + // last frame's grid height and stretch each column's bottom card + // to it. The content is static, so this settles in one frame. + let gid = egui::Id::new("nd_grid_h"); + let grid_h: f32 = ui.data(|d| d.get_temp(gid)).unwrap_or(0.0); + let mut l_h = 0.0_f32; + let mut r_h = 0.0_f32; + ui.horizontal_top(|ui| { + // the gap IS the item spacing, so the two columns + gap always + // sum to exactly the available width — no hidden extra spacing + ui.spacing_mut().item_spacing.x = gap; + let rl = ui.allocate_ui_with_layout( + Vec2::new(col_l, 0.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(col_l); + ui.spacing_mut().item_spacing.y = 0.0; + let top = ui.cursor().top(); + self.card_id(ui); + ui.add_space(gap); + let used = ui.cursor().top() - top; + self.card_password(ui, (grid_h - used - 2.0 * PAD).max(0.0)); + }, + ); + l_h = rl.response.rect.height(); + let rr = ui.allocate_ui_with_layout( + Vec2::new(col_r, 0.0), + Layout::top_down(Align::Min), + |ui| { + ui.set_width(col_r); + ui.spacing_mut().item_spacing.y = 0.0; + let top = ui.cursor().top(); + self.card_connection(ui); + ui.add_space(gap); + let used = ui.cursor().top() - top; + if let Some(a) = + self.card_account(ui, (grid_h - used - 2.0 * PAD).max(0.0)) + { + action = Some(a); + } + }, + ); + r_h = rr.response.rect.height(); + }); + ui.data_mut(|d| d.insert_temp(gid, l_h.max(r_h))); + } else { + self.card_id(ui); + ui.add_space(gap); + self.card_password(ui, 0.0); + ui.add_space(gap); + self.card_connection(ui); + ui.add_space(gap); + if let Some(a) = self.card_account(ui, 0.0) { + action = Some(a); + } + } + + ui.add_space(gap); + // Start / Stop — two flexible halves; the gap is the item spacing so + // the row sums to exactly the available width + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + let running = self.state.online; + let start_label = if running { + format!("{} Service Running", ph::POWER) + } else { + format!("{} Start Service", ph::POWER) + }; + if grad_button(ui, &start_label, Vec2::new(bw, BTN_H), true, false).clicked() + && !running + { + self.do_start(); + } + if grad_button_danger(ui, &format!("{} Stop", ph::STOP), Vec2::new(bw, BTN_H), false) + .clicked() + { + self.do_stop(); + } + }); + + ui.add_space(gap); + self.card_toggles(ui); + + ui.add_space(gap); + // footer buttons — same flexible halves + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + if foot_button(ui, &format!("{} Open Web Viewer", ph::GLOBE), bw).clicked() { + self.do_open_web(); + } + if foot_button(ui, &format!("{} Logs Folder", ph::FOLDER_OPEN), bw).clicked() { + self.do_open_logs(); + } + }); + + action + } + + fn card_id(&mut self, ui: &mut Ui) { + card(ui, "card_id", 0.0, |ui| { + // the Copy-ID button keeps its natural size on the right; the big + // ID gets the rest and scales its font down in narrow cards + let id_max = (ui.available_width() - 112.0).max(120.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + section_title(ui, "THIS COMPUTER'S ID"); + if ui + .add( + egui::Label::new( + RichText::new(" Format ") + .size(10.0) + .color(FAINT), + ) + .sense(Sense::click()), + ) + .on_hover_text("Toggle spaced format") + .clicked() + { + self.id_spaced = !self.id_spaced; + } + }); + ui.add_space(3.0); + big_id(ui, &fmt_id(&self.cfg.id, self.id_spaced), id_max); + }); + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + if copy_button(ui, "Copy ID").clicked() { + self.do_copy_id(); + } + }); + }); + ui.add_space(9.0); + exact_row(ui, 25.0, Layout::left_to_right(Align::Center), |ui| { + status_pill(ui, self.state.online, self.state.in_session); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + e2ee_tag(ui); + }); + }); + }); + } + + /// `min_h`: stretch the card content to this height (the column equalizer) + /// with the throttle/2FA footer pinned to the card bottom like the HTML. + fn card_password(&mut self, ui: &mut Ui, min_h: f32) { + card(ui, "card_pw", min_h, |ui| { + let top = ui.cursor().top(); + card_head(ui, "UNATTENDED PASSWORD", |ui| { + pill_tag(ui, "PBKDF2 · 200k", PillKind::Em); + }); + ui.add_space(8.0); + // ONE FIXED-HEIGHT ROW (the giant-password-box fix): laid out + // right-to-left inside an exact FIELD_H band — the eye / regen / + // Set / 2FA controls keep their natural size at the right edge and + // the password field takes exactly the remaining width. A bare + // with_layout here would grab all the height under the card. + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + let (txt, kind) = if self.state.totp { + ("2FA: ON", PillKind::Em) + } else { + ("2FA: off", PillKind::Crimson) + }; + if pill_button(ui, txt, kind).clicked() { + self.do_2fa(); + } + if ui + .add(egui::Label::new(RichText::new("Set").size(13.0).strong().color(BLUE)).sense(Sense::click())) + .clicked() + { + self.do_pw_set(); + } + if icon_button(ui, ph::ARROWS_CLOCKWISE).on_hover_text("Generate a strong password").clicked() { + self.do_pw_gen(); + } + if icon_button(ui, if self.pw_show { ph::EYE_SLASH } else { ph::EYE }) + .on_hover_text("Show / hide") + .clicked() + { + self.pw_show = !self.pw_show; + } + let field_w = (ui.available_width() - 9.0).max(80.0); + let hint = if field_w < 215.0 { + "no password yet" + } else { + "no password yet — generate one" + }; + nd_input(ui, &mut self.pw_input, hint, !self.pw_show, true, field_w); + }); + ui.add_space(8.0); + ui.label( + RichText::new( + "Anyone who knows this ID and password can control this PC. Set a strong password; turn on 2FA for an authenticator code at connect.", + ) + .size(11.5) + .color(Color32::from_rgb(115, 128, 145)), + ); + // pin the footer to the card bottom (the HTML's flex column) + let foot_h = 26.0; + let used = ui.cursor().top() - top; + ui.add_space((min_h - used - foot_h).max(8.0)); + card_divider(ui); + ui.add_space(8.0); + exact_row(ui, 18.0, Layout::left_to_right(Align::Center), |ui| { + dot(ui, crate::c_accent(), 6.0); + ui.label(RichText::new("Brute-force throttle:").size(11.5).color(MUTED)); + ui.label(RichText::new("0.4s / attempt").size(11.5).strong().color(FG_SOFT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Configure Authenticator →").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.do_2fa(); + } + }); + }); + }); + } + + fn card_connection(&mut self, ui: &mut Ui) { + card(ui, "card_conn", 0.0, |ui| { + card_head(ui, "CONNECTION", |ui| { + let label = match self.ping_ms { + Some(ms) => format!("Ping Relay ({ms} ms)"), + None => "Ping Relay".to_string(), + }; + if pill_button(ui, &label, PillKind::EmOutline).clicked() { + self.ping_now(); + self.toast("Pinging relay…", ""); + } + }); + ui.add_space(6.0); + field_label(ui, "Server Address", None); + nd_input(ui, &mut self.server_in, "wss://netdesk.space/ws", false, true, ui.available_width()); + ui.add_space(6.0); + field_label(ui, "Registration Key", Some("(optional)")); + nd_input(ui, &mut self.reg_in, "Leave blank for open relay", false, true, ui.available_width()); + ui.add_space(6.0); + field_label(ui, "Computer Name", None); + nd_input(ui, &mut self.name_in, "", false, false, ui.available_width()); + }); + } + + fn card_account(&mut self, ui: &mut Ui, min_h: f32) -> Option { + let mut action = None; + card(ui, "card_acct", min_h, |ui| { + card_head(ui, "NETDESK ACCOUNT", |ui| { + if self.cfg.signed_in() { + pill_tag(ui, "Fleet Synced", PillKind::Em); + } + }); + ui.add_space(7.0); + + if self.cfg.signed_in() { + exact_row(ui, 16.0, Layout::left_to_right(Align::Center), |ui| { + ui.label(RichText::new("✓ Signed in as ").size(13.0).color(crate::c_accent())); + ui.label(RichText::new(&self.state.account).size(13.0).strong().color(crate::c_accent())); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Sign out").size(12.5).color(BLUE)).sense(Sense::click())) + .clicked() + { + self.do_logout(); + } + }); + }); + ui.add_space(5.0); + ui.label( + RichText::new("This PC shows under “My Computers” on the website.") + .size(11.5) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(6.0); + card_divider(ui); + ui.add_space(6.0); + exact_row(ui, 14.0, Layout::left_to_right(Align::Center), |ui| { + ui.label( + RichText::new("LINKED DEVICES") + .size(10.0) + .strong() + .color(MUTED), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("View all").size(11.0).color(BLUE)).sense(Sense::click())) + .clicked() + { + self.nav = Nav::Computers; + } + }); + }); + ui.add_space(5.0); + if self.devices.is_empty() { + ui.label( + RichText::new("No computers linked yet — this PC appears here once the engine registers.") + .size(11.5) + .color(FAINT), + ); + } else { + // up to 3 compact device tiles per row (the HTML fleet-grid) + let devs: Vec = self.devices.iter().take(3).cloned().collect(); + let n = devs.len().max(1) as f32; + let sp = 8.0; + let w = ((ui.available_width() - sp * (n - 1.0)) / n).floor(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = sp; + for d in devs { + if fleet_card(ui, &d.name, &d.id, d.online, d.busy, w).clicked() + && d.online + { + action = Some(HomeAction::Connect { id: d.id.clone() }); + } + } + }); + } + } else { + nd_input(ui, &mut self.acc_email, "email", false, false, ui.available_width()); + ui.add_space(6.0); + nd_input(ui, &mut self.acc_pass, "password", true, false, ui.available_width()); + ui.add_space(8.0); + let gap = 10.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + let bw = ((ui.available_width() - gap) / 2.0).floor(); + if grad_button(ui, "Sign In", Vec2::new(bw, 34.0), true, false).clicked() { + self.do_login(false); + } + if grad_button(ui, "Create Account", Vec2::new(bw, 34.0), false, false).clicked() + { + self.do_login(true); + } + }); + } + }); + action + } + + fn card_toggles(&mut self, ui: &mut Ui) { + let items = [ + ("service_installed", "Service\nInstalled", self.state.service_installed), + ("startup", "Start with\nWindows", self.state.startup), + ("privacy", "Privacy\nMode", self.state.privacy), + ("lock_on_end", "Lock on\ndisconnect", self.state.lock_on_end), + ("close_to_tray", "Close to\ntray", self.state.close_to_tray), + ]; + card(ui, "card_toggles", 0.0, |ui| { + // Responsive cells: at least ~96px each (spacing included in the + // budget); the row wraps to extra rows when the card is narrow + // instead of clipping on the right. + let sp = 8.0; + let total = ui.available_width(); + let per_row = (((total + sp) / (96.0 + sp)).floor() as usize).clamp(1, items.len()); + let cell = ((total - sp * (per_row as f32 - 1.0)) / per_row as f32).floor(); + for (ri, row) in items.chunks(per_row).enumerate() { + if ri > 0 { + ui.add_space(10.0); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = sp; + for (name, label, on) in row.iter().copied() { + ui.allocate_ui_with_layout( + Vec2::new(cell, 58.0), + Layout::top_down(Align::Center), + |ui| { + ui.set_width(cell); + ui.spacing_mut().item_spacing.y = 0.0; + if toggle_switch(ui, on).clicked() { + self.do_toggle(name, !on); + } + ui.add_space(4.0); + for line in label.split('\n') { + ui.label(RichText::new(line).size(11.5).color(Color32::from_rgb(154, 166, 181))); + } + }, + ); + } + }); + } + }); + } + + // =========================== MY COMPUTERS ============================== + fn view_computers(&mut self, ui: &mut Ui) -> Option { + let mut action = None; + card(ui, "view_devs", 0.0, |ui| { + card_head(ui, "MY COMPUTERS", |ui| { + if ui + .add(egui::Label::new(RichText::new("↻ Refresh").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.devices_now(); + } + }); + ui.add_space(8.0); + // filter pills keep natural size on the right; the search field + // flexes to exactly the remaining width — in an exact-height band + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + for (idx, lbl) in [(2u8, "Offline"), (1, "Online"), (0, "All")] { + if pill_button( + ui, + lbl, + if self.dev_filter == idx { + PillKind::EmOutline + } else { + PillKind::Grey + }, + ) + .clicked() + { + self.dev_filter = idx; + } + } + let w = (ui.available_width() - 9.0).max(80.0); + nd_input(ui, &mut self.dev_q, "Search by name or ID…", false, false, w); + }); + ui.add_space(10.0); + + if !self.cfg.signed_in() { + ui.label( + RichText::new("Sign in to your NetDesk account to see the computers linked to it.") + .size(12.0) + .color(FAINT), + ); + return; + } + if let Some(e) = &self.dev_err { + ui.label(RichText::new(format!("Could not load devices: {e}")).size(12.0).color(CRIMSON_FG)); + } + let q = self.dev_q.to_lowercase(); + let devs: Vec = self + .devices + .iter() + .filter(|d| match self.dev_filter { + 1 => d.online, + 2 => !d.online, + _ => true, + }) + .filter(|d| { + q.is_empty() + || d.name.to_lowercase().contains(&q) + || d.id.contains(&q) + }) + .cloned() + .collect(); + if devs.is_empty() { + ui.label( + RichText::new(if self.devices.is_empty() { + "No computers linked yet — this PC appears here once the engine registers." + } else { + "No computers match this filter." + }) + .size(12.0) + .color(FAINT), + ); + } + for d in devs { + if list_row(ui, &d.name, &d.id, d.online, d.busy).clicked() && d.online { + action = Some(HomeAction::Connect { id: d.id.clone() }); + } + ui.add_space(8.0); + } + }); + action + } + + // =========================== SECURITY ================================== + fn view_security(&mut self, ui: &mut Ui) { + card(ui, "sec_2fa", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "TWO-FACTOR AUTHENTICATION (TOTP)"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (t, k) = if self.state.totp { + ("2FA: ON", PillKind::Em) + } else { + ("2FA: off", PillKind::Crimson) + }; + pill_tag(ui, t, k); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Adds a 6-digit authenticator code required at every connect — even with the correct password.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + let label = if self.state.totp { "Disable 2FA" } else { "Enable 2FA" }; + if grad_button(ui, label, Vec2::new(220.0, 38.0), !self.state.totp, false).clicked() { + self.do_2fa(); + } + // In-app QR enrollment: while 2FA is on, show a scannable QR of the + // host's otpauth:// secret so the user can add it to an authenticator + // app without typing the base32 string. + if self.state.totp && !self.cfg.totp.is_empty() { + let secret = self.cfg.totp.clone(); + let uri = totp_otpauth_uri(&secret, "this PC"); + ui.add_space(12.0); + ui.label( + RichText::new("Scan with your authenticator app (Google Authenticator, Authy, 1Password…):") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(8.0); + if let Some(tex) = self.totp_qr_texture(ui.ctx(), &secret, &uri) { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + Vec2::new(188.0, 188.0), + ))); + } + ui.add_space(6.0); + ui.label( + RichText::new(format!("Secret: {secret}")) + .size(11.0) + .monospace() + .color(Color32::from_rgb(150, 160, 175)), + ); + } + }); + ui.add_space(14.0); + card(ui, "sec_allow", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "VIEWER IP ALLOWLIST"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let on = !self.allow_in.trim().is_empty(); + pill_tag( + ui, + if on { "on — restricted" } else { "off — everyone with the password" }, + if on { PillKind::Em } else { PillKind::Grey }, + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Only these IPs / CIDR ranges may connect (e.g. 203.0.113.7, 10.0.0.0/8). Empty = any IP that knows the password.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + // the Save button keeps natural size on the right; the field flexes + // — in an exact-height band so the row can never balloon + exact_row(ui, FIELD_H, Layout::right_to_left(Align::Center), |ui| { + if grad_button(ui, "Save Allowlist", Vec2::new(138.0, FIELD_H), true, false).clicked() { + self.do_save_allow(); + } + let w = (ui.available_width() - 9.0).max(80.0); + nd_input(ui, &mut self.allow_in, "empty — all IPs allowed", false, true, w); + }); + }); + ui.add_space(14.0); + card(ui, "sec_svc", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "BOOT-TIME SERVICE"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + pill_tag( + ui, + if self.state.service_installed { "installed" } else { "not installed" }, + if self.state.service_installed { PillKind::Em } else { PillKind::Grey }, + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new("Installs a SYSTEM service so this PC is reachable at the logon screen and across reboots. Requires one UAC approval.") + .size(12.0) + .color(Color32::from_rgb(115, 128, 145)), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + if grad_button(ui, "Install Service", Vec2::new(190.0, 38.0), true, false).clicked() { + self.do_toggle("service_installed", true); + } + ui.add_space(10.0); + if grad_button(ui, "Remove Service", Vec2::new(160.0, 38.0), false, false).clicked() { + self.do_toggle("service_installed", false); + } + }); + if self.busy_service { + ui.add_space(8.0); + ui.label(RichText::new("Working… (accept the UAC prompt if it appears)").size(11.5).color(MUTED)); + } + }); + } + + // =========================== LOGS ====================================== + fn view_logs(&mut self, ui: &mut Ui) { + card(ui, "view_logs", 0.0, |ui| { + ui.horizontal(|ui| { + section_title(ui, "SESSION AUDIT LOG"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(egui::Label::new(RichText::new("Open folder").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.do_open_logs(); + } + if ui + .add(egui::Label::new(RichText::new("↻ Refresh").size(11.5).color(crate::c_accent())).sense(Sense::click())) + .clicked() + { + self.load_logs(); + } + }); + }); + ui.add_space(10.0); + let frame = egui::Frame::none() + .fill(Color32::from_rgb(10, 14, 19)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 39, 52))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(12.0)); + frame.show(ui, |ui| { + ui.set_min_height(260.0); + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + // the page ui zeroes item_spacing.y; log lines need a gap + ui.spacing_mut().item_spacing.y = 4.0; + if self.log_lines.is_empty() { + ui.label( + RichText::new(format!( + "No log file found next to the config yet.\nConfig: {}", + self.cfg.path.display() + )) + .size(11.5) + .monospace() + .color(FAINT), + ); + } else { + for line in &self.log_lines { + ui.label(RichText::new(line).size(11.5).monospace().color(Color32::from_rgb(159, 176, 196))); + } + } + }); + }); + }); + } + + // =========================== OVERLAYS ================================== + fn overlays(&mut self, ctx: &egui::Context, logo: Option) { + // toasts (bottom-right) + if !self.toasts.is_empty() { + egui::Area::new(egui::Id::new("nd_toasts")) + .anchor(egui::Align2::RIGHT_BOTTOM, Vec2::new(-16.0, -16.0)) + .interactable(false) + .show(ctx, |ui| { + ui.with_layout(Layout::bottom_up(Align::Max), |ui| { + for (title, sub, _) in self.toasts.iter().rev() { + toast_card(ui, title, sub); + ui.add_space(8.0); + } + }); + }); + } + + // modal dialog + if let Some((title, body)) = self.modal.clone() { + let mut open = true; + egui::Window::new(RichText::new(title).color(crate::c_accent()).strong()) + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + egui::Frame::window(&ctx.style()) + .fill(Color32::from_rgb(22, 29, 39)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(42, 54, 72))) + .rounding(Rounding::same(12.0)) + .shadow(Shadow { + offset: Vec2::new(0.0, 24.0), + blur: 70.0, + spread: 0.0, + color: Color32::from_black_alpha(180), + }), + ) + .show(ctx, |ui| { + ui.set_max_width(460.0_f32.min(ctx.screen_rect().width() - 48.0)); + ui.add_space(4.0); + ui.label(RichText::new(body).size(13.0).color(Color32::from_rgb(203, 213, 225))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if grad_button(ui, "Close", Vec2::new(90.0, 32.0), true, false).clicked() { + self.modal = None; + } + }); + }); + if !open { + self.modal = None; + } + } + + // About dialog (REQUIRED Akronlogic credit) + if self.show_about { + let mut open = true; + egui::Window::new("About NetDesk") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + egui::Frame::window(&ctx.style()) + .fill(Color32::from_rgb(22, 29, 39)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(42, 54, 72))) + .rounding(Rounding::same(12.0)), + ) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(6.0); + logo_mark(ui, logo, 64.0); + ui.add_space(8.0); + ui.label(RichText::new(crate::brand_name()).size(26.0).strong().color(FG)); + ui.label(RichText::new(format!("Enterprise Remote Desktop · v{}", netdesk_core::VERSION)).size(12.0).color(MUTED)); + ui.add_space(8.0); + ui.label(RichText::new("Developed by Akronlogic.com").size(14.0).strong().color(crate::c_accent())); + ui.add_space(4.0); + if ui.add(egui::Label::new(RichText::new("akronlogic.com").size(12.0).color(BLUE)).sense(Sense::click())).clicked() { + let _ = open_url("https://akronlogic.com"); + } + ui.add_space(6.0); + }); + }); + if !open { + self.show_about = false; + } + } + } +} + +// ============================ REUSABLE 3D WIDGETS ========================== + +/// A soft-shadowed, gradient-filled, bevelled card whose content is laid out by +/// `add`. The drop shadow grows on hover (one-frame-lagged lift). The gradient +/// + border + top highlight are painted BEHIND the content via a reserved shape +/// slot, so they never wash over text. +/// A row of EXACTLY `h` logical px: the child ui is clamped to that band, so +/// `Align::Center` centers within it. This is the fix for the giant-password- +/// box bug: a bare `ui.with_layout(right_to_left(..))` in a top-down ui takes +/// ALL the remaining height (to the bottom of the ScrollArea canvas) and +/// centers its widgets in the middle of it, ballooning the card. +fn exact_row(ui: &mut Ui, h: f32, layout: Layout, add: impl FnOnce(&mut Ui) -> R) -> R { + let w = ui.available_width(); + ui.allocate_ui_with_layout(Vec2::new(w, h), layout, |ui| { + ui.set_min_size(Vec2::new(w, h)); + add(ui) + }) + .inner +} + +/// The standard card head: small-caps title left, optional controls right — +/// one fixed-height band like the HTML's `.card-head`. +fn card_head(ui: &mut Ui, title: &str, right: impl FnOnce(&mut Ui)) { + exact_row(ui, HEAD_H, Layout::left_to_right(Align::Center), |ui| { + section_title(ui, title); + ui.with_layout(Layout::right_to_left(Align::Center), right); + }); +} + +fn card(ui: &mut Ui, id_src: &str, min_h: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let id = egui::Id::new(("nd_card", id_src)); + // one-frame-lagged hover, kept in egui memory (avoids borrowing self) + let hovered = ui.data(|d| d.get_temp::(id)).unwrap_or(false); + + // reserve the slot the depth stack will be painted into (behind content) + let bg = ui.painter().add(Shape::Noop); + + let frame = egui::Frame::none() + .inner_margin(Margin::same(PAD)) + .rounding(Rounding::same(12.0)); + let res = frame.show(ui, |ui| { + // every card fills its column exactly — uniform widths, no overflow + ui.set_width(ui.available_width()); + // geometry is managed with explicit add_space inside cards + ui.spacing_mut().item_spacing.y = 0.0; + if min_h > 0.0 { + // stretch to the column height (the HTML's `flex: 1` card) + ui.set_min_height(min_h); + } + add(ui) + }); + let rect = res.response.rect; + + let shadow = if hovered { + Shadow { + offset: Vec2::new(0.0, 12.0), + blur: 34.0, + spread: -2.0, + color: Color32::from_black_alpha(150), + } + } else { + Shadow { + offset: Vec2::new(0.0, 7.0), + blur: 22.0, + spread: -3.0, + color: Color32::from_black_alpha(120), + } + }; + + let border = if hovered { CARD_BORDER_HI } else { CARD_BORDER }; + let shapes = vec![ + Shape::from(shadow.as_shape(rect, Rounding::same(12.0))), + rounded_vgrad(rect, 12.0, CARD_TOP, CARD_BOT), + Shape::rect_stroke(rect, Rounding::same(12.0), Stroke::new(1.0_f32, border)), + // top inner-highlight bevel + Shape::line_segment( + [ + Pos2::new(rect.left() + 12.0, rect.top() + 1.2), + Pos2::new(rect.right() - 12.0, rect.top() + 1.2), + ], + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)), + ), + ]; + ui.painter().set(bg, Shape::Vec(shapes)); + + let now = ui.rect_contains_pointer(rect); + ui.data_mut(|d| d.insert_temp(id, now)); + res.inner +} + +/// Primary (green gradient + glow) or secondary (grey) pill button, size-fixed +/// so it can paint its depth before the label. `compact` tightens the label. +// Red "danger" gradient for destructive actions (Stop service). Harmonizes with +// the CRIMSON_* status palette. +const DANGER_HI: Color32 = Color32::from_rgb(232, 93, 100); // #e85d64 +const DANGER_LO: Color32 = Color32::from_rgb(179, 44, 52); // #b32c34 +const DANGER_BD: Color32 = Color32::from_rgb(243, 120, 126); // #f3787e + +fn grad_button(ui: &mut Ui, label: &str, size: Vec2, primary: bool, compact: bool) -> egui::Response { + if primary { + grad_button_col(ui, label, size, crate::c_accent_hi(), crate::c_accent_lo(), Color32::from_rgb(53, 230, 153), + Color32::WHITE, Some(Color32::from_rgb(25, 200, 126)), compact) + } else { + grad_button_col(ui, label, size, GREY_BTN_TOP, GREY_BTN_BOT, Color32::from_rgb(100, 112, 128), + Color32::from_rgb(220, 228, 238), None, compact) + } +} + +/// Red variant for destructive actions (e.g. Stop service). +fn grad_button_danger(ui: &mut Ui, label: &str, size: Vec2, compact: bool) -> egui::Response { + grad_button_col(ui, label, size, DANGER_HI, DANGER_LO, DANGER_BD, + Color32::WHITE, Some(Color32::from_rgb(214, 69, 76)), compact) +} + +#[allow(clippy::too_many_arguments)] +fn grad_button_col( + ui: &mut Ui, + label: &str, + size: Vec2, + top: Color32, + bot: Color32, + border: Color32, + txt_col: Color32, + glow: Option, + compact: bool, +) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered(); + let rounding = if size.y >= 44.0 { 9.0 } else { 8.0 }; + let p = ui.painter(); + + // outer glow (primary / danger) + if let Some(gc) = glow { + let glow = Shadow { + offset: Vec2::ZERO, + blur: if hovered { 26.0 } else { 20.0 }, + spread: 1.0, + color: Color32::from_rgba_unmultiplied(gc.r(), gc.g(), gc.b(), if hovered { 120 } else { 90 }), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(rounding)))); + } + // drop shadow + let drop = Shadow { + offset: Vec2::new(0.0, 3.0), + blur: 10.0, + spread: -1.0, + color: Color32::from_black_alpha(110), + }; + p.add(Shape::from(drop.as_shape(rect, Rounding::same(rounding)))); + + // gradient fill + let (top, bot) = if hovered { + (lighten(top, 0.08), lighten(bot, 0.08)) + } else { + (top, bot) + }; + p.add(rounded_vgrad(rect, rounding, top, bot)); + // border + top bevel + p.add(Shape::rect_stroke(rect, Rounding::same(rounding), Stroke::new(1.0_f32, border))); + p.add(Shape::line_segment( + [ + Pos2::new(rect.left() + rounding, rect.top() + 1.0), + Pos2::new(rect.right() - rounding, rect.top() + 1.0), + ], + Stroke::new(1.2_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 56)), + )); + + // press offset + let dy = if resp.is_pointer_button_down_on() { 1.0 } else { 0.0 }; + let fsize = if compact { 12.5 } else if size.y >= 44.0 { 14.5 } else { 13.5 }; + ui.painter().text( + rect.center() + Vec2::new(0.0, dy), + egui::Align2::CENTER_CENTER, + label, + FontId::proportional(fsize), + txt_col, + ); + resp +} + +/// Low-profile outlined footer button (dark fill, green hairline + glow). +fn foot_button(ui: &mut Ui, label: &str, w: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, FOOT_H), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + let glow = Shadow { + offset: Vec2::ZERO, + blur: if hovered { 14.0 } else { 9.0 }, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, if hovered { 60 } else { 30 }), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(8.0)))); + let fill = if hovered { + Color32::from_rgb(28, 38, 50) + } else { + Color32::from_rgb(22, 29, 38) + }; + p.add(Shape::rect_filled(rect, Rounding::same(8.0), fill)); + let border = if hovered { crate::c_accent() } else { Color32::from_rgb(31, 122, 83) }; + p.add(Shape::rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, border))); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::proportional(13.5), + if hovered { Color32::WHITE } else { FG_SOFT }, + ); + resp +} + +/// A raised, tactile toggle switch (48x26) with on-state green glow + shadowed +/// knob. Returns the click response. +fn toggle_switch(ui: &mut Ui, on: bool) -> egui::Response { + let size = Vec2::new(48.0, 25.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let p = ui.painter(); + let r = rect.height() / 2.0; + + if on { + // glow + let glow = Shadow { + offset: Vec2::ZERO, + blur: 12.0, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, 110), + }; + p.add(Shape::from(glow.as_shape(rect, Rounding::same(r)))); + p.add(rounded_vgrad(rect, r, Color32::from_rgb(36, 210, 134), Color32::from_rgb(24, 184, 114))); + p.add(Shape::rect_stroke(rect, Rounding::same(r), Stroke::new(1.0_f32, Color32::from_rgb(57, 229, 154)))); + } else { + p.add(rounded_vgrad(rect, r, Color32::from_rgb(54, 63, 76), Color32::from_rgb(44, 52, 64))); + p.add(Shape::rect_stroke(rect, Rounding::same(r), Stroke::new(1.0_f32, Color32::from_rgb(71, 83, 100)))); + } + + // knob + let knob_r = 9.5; + let cx = if on { + rect.right() - r + } else { + rect.left() + r + }; + let center = Pos2::new(cx, rect.center().y); + // knob shadow + p.add(Shape::from( + Shadow { + offset: Vec2::new(0.0, 1.5), + blur: 4.0, + spread: 0.0, + color: Color32::from_black_alpha(120), + } + .as_shape(Rect::from_center_size(center, Vec2::splat(knob_r * 2.0)), Rounding::same(knob_r)), + )); + p.circle_filled(center, knob_r, if on { Color32::WHITE } else { Color32::from_rgb(139, 150, 166) }); + resp +} + +enum PillKind { + Em, + EmOutline, + Crimson, + Grey, +} + +/// A non-interactive status tag pill. +fn pill_tag(ui: &mut Ui, text: &str, kind: PillKind) { + pill_draw(ui, text, kind, false); +} + +/// A clickable pill button (same look, click sense). +fn pill_button(ui: &mut Ui, text: &str, kind: PillKind) -> egui::Response { + pill_draw(ui, text, kind, true) +} + +fn pill_draw(ui: &mut Ui, text: &str, kind: PillKind, clickable: bool) -> egui::Response { + let galley = ui.painter().layout_no_wrap( + text.to_string(), + FontId::proportional(11.0), + Color32::WHITE, + ); + let pad = Vec2::new(10.0, 4.5); + let size = galley.size() + pad * 2.0; + let sense = if clickable { Sense::click() } else { Sense::hover() }; + let (rect, resp) = ui.allocate_exact_size(size, sense); + let hovered = clickable && resp.hovered(); + let (fill, border, fg) = match kind { + PillKind::Em => ( + Color32::from_rgba_unmultiplied(25, 200, 126, 30), + Color32::from_rgba_unmultiplied(25, 200, 126, 120), + crate::c_accent_hi(), + ), + PillKind::EmOutline => ( + Color32::from_rgba_unmultiplied(25, 200, 126, if hovered { 45 } else { 26 }), + Color32::from_rgba_unmultiplied(25, 200, 126, 90), + crate::c_accent_hi(), + ), + PillKind::Crimson => (CRIMSON_BG, CRIMSON_BD, CRIMSON_FG), + PillKind::Grey => ( + if hovered { Color32::from_rgb(30, 41, 59) } else { Color32::from_rgb(22, 29, 40) }, + Color32::from_rgb(38, 50, 65), + Color32::from_rgb(148, 163, 184), + ), + }; + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), fill); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, border)); + let pos = Pos2::new(rect.left() + pad.x, rect.center().y - galley.size().y / 2.0); + p.galley(pos, galley, fg); + resp +} + +fn status_pill(ui: &mut Ui, online: bool, in_session: bool) { + let (txt, fg, bg, bd) = if in_session { + ( + "In Session", + Color32::from_rgb(217, 185, 106), + Color32::from_rgb(51, 48, 28), + Color32::from_rgb(124, 101, 53), + ) + } else if online { + ( + "Online", + crate::c_accent_hi(), + Color32::from_rgba_unmultiplied(25, 200, 126, 30), + Color32::from_rgba_unmultiplied(25, 200, 126, 115), + ) + } else { + ("Stopped", CRIMSON_FG, CRIMSON_BG, CRIMSON_BD) + }; + let galley = ui.painter().layout_no_wrap(txt.to_string(), FontId::proportional(12.5), fg); + let pad = Vec2::new(13.0, 5.0); + let size = Vec2::new(galley.size().x + pad.x * 2.0 + 14.0, galley.size().y + pad.y * 2.0); + let (rect, _resp) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(999.0), bg); + p.rect_stroke(rect, Rounding::same(999.0), Stroke::new(1.0_f32, bd)); + p.circle_filled(Pos2::new(rect.left() + pad.x + 4.0, rect.center().y), 4.0, fg); + p.galley( + Pos2::new(rect.left() + pad.x + 14.0, rect.center().y - galley.size().y / 2.0), + galley, + fg, + ); +} + +/// The big glowing monospace ID number. The font scales down (34 → 16) when +/// the card is narrow so the number never clips. +fn big_id(ui: &mut Ui, text: &str, max_w: f32) { + let mut size = 34.0; + let probe = ui + .painter() + .layout_no_wrap(text.to_string(), FontId::monospace(size), Color32::WHITE); + if probe.size().x > max_w && probe.size().x > 0.0 { + size = (size * max_w / probe.size().x).clamp(16.0, 34.0); + } + let font = FontId::monospace(size); + let galley = + ui.painter() + .layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE); + let (rect, _resp) = + ui.allocate_exact_size(galley.size() + Vec2::new(2.0, 6.0), Sense::hover()); + let pos = Pos2::new(rect.left(), rect.top() + 3.0); + let painter = ui.painter(); + // A subtle dark drop shadow gives depth without any green tint (the old green + // glow smeared below the digits and read as a stray green line). + painter.text( + pos + Vec2::new(0.0, 2.0), + egui::Align2::LEFT_TOP, + text, + font.clone(), + Color32::from_black_alpha(90), + ); + painter.text(pos, egui::Align2::LEFT_TOP, text, font, Color32::WHITE); +} + +fn copy_button(ui: &mut Ui, label: &str) -> egui::Response { + let galley = ui.painter().layout_no_wrap(label.to_string(), FontId::proportional(13.0), FG); + let size = Vec2::new(galley.size().x + 40.0, 34.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(9.0), if hovered { Color32::from_rgb(44, 54, 70) } else { Color32::from_rgb(35, 43, 56) }); + p.rect_stroke(rect, Rounding::same(9.0), Stroke::new(1.0_f32, if hovered { Color32::from_rgb(75, 91, 112) } else { Color32::from_rgb(56, 69, 88) })); + // copy glyph + p.text(Pos2::new(rect.left() + 13.0, rect.center().y), egui::Align2::CENTER_CENTER, ph::COPY, FontId::proportional(14.0), MUTED); + p.galley(Pos2::new(rect.left() + 24.0, rect.center().y - galley.size().y / 2.0), galley, FG); + resp +} + +fn e2ee_tag(ui: &mut Ui) { + let galley = ui.painter().layout_no_wrap(format!("{} E2EE Ready", ph::LOCK), FontId::proportional(11.5), MUTED); + let size = galley.size() + Vec2::new(18.0, 6.0); + let (rect, _r) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(6.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke(rect, Rounding::same(6.0), Stroke::new(1.0_f32, Color32::from_rgb(36, 46, 61))); + p.galley(Pos2::new(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, Color32::from_rgb(148, 163, 184)); +} + +fn icon_button(ui: &mut Ui, glyph: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(FIELD_H, FIELD_H), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(7.0), if hovered { Color32::from_rgb(48, 59, 76) } else { Color32::from_rgb(38, 47, 61) }); + p.rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.0_f32, if hovered { Color32::from_rgb(75, 91, 112) } else { Color32::from_rgb(56, 69, 86) })); + p.text(rect.center(), egui::Align2::CENTER_CENTER, glyph, FontId::proportional(15.0), if hovered { Color32::WHITE } else { Color32::from_rgb(208, 216, 226) }); + resp +} + +/// Dark rounded text input matching the host GUI (#0f141b, green focus ring). +/// `width` is the TOTAL control width INCLUDING the frame padding, so passing +/// `ui.available_width()` always fits exactly and can never overflow the card. +/// The control is EXACTLY `FIELD_H` tall — the box is painted on an allocated +/// fixed rect and the single-line TextEdit is centered inside it, so the field +/// can never balloon vertically whatever layout it sits in. +fn nd_input(ui: &mut Ui, buf: &mut String, hint: &str, password: bool, mono: bool, width: f32) -> egui::Response { + let w = width.max(40.0); + ui.allocate_ui_with_layout( + Vec2::new(w, FIELD_H), + Layout::left_to_right(Align::Center), + |ui| { + ui.set_min_size(Vec2::new(w, FIELD_H)); + let rect = Rect::from_min_size(ui.max_rect().min, Vec2::new(w, FIELD_H)); + ui.painter().rect_filled(rect, Rounding::same(7.0), INPUT_BG); + ui.painter() + .rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.0_f32, INPUT_BORDER)); + ui.add_space(8.0); + let mut te = TextEdit::singleline(buf) + .frame(false) + .hint_text(hint) + .desired_width(w - 18.0) + .text_color(FG) + .vertical_align(Align::Center); + if password { + te = te.password(true); + } + if mono { + te = te.font(FontId::monospace(13.0)); + } + let r = ui.add(te); + if r.has_focus() { + ui.painter() + .rect_stroke(rect, Rounding::same(7.0), Stroke::new(1.5_f32, crate::c_accent())); + } + r + }, + ) + .inner +} + +fn field_label(ui: &mut Ui, label: &str, opt: Option<&str>) { + exact_row(ui, 15.0, Layout::left_to_right(Align::Center), |ui| { + ui.label(RichText::new(label).size(11.5).color(Color32::from_rgb(154, 166, 181))); + if let Some(o) = opt { + ui.label(RichText::new(o).size(11.0).color(Color32::from_rgb(99, 112, 129))); + } + }); + ui.add_space(2.0); +} + +/// True when NetDesk runs from its installed location (the installer writes +/// `uninstall.exe` next to NetDesk.exe); false for the portable copy. Cached on +/// first call so the pill reflects how this build was launched. +fn is_installed() -> bool { + use std::sync::OnceLock; + static INSTALLED: OnceLock = OnceLock::new(); + *INSTALLED.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("uninstall.exe").exists())) + .unwrap_or(false) + }) +} + +fn section_title(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).size(11.0).strong().color(MUTED)); +} + +fn card_divider(ui: &mut Ui) { + let r = ui.max_rect(); + let y = ui.cursor().top(); + ui.painter().hline(r.left()..=r.right(), y, Stroke::new(1.0_f32, Color32::from_rgb(34, 43, 56))); +} + +fn dot(ui: &mut Ui, color: Color32, r: f32) { + let (rect, _resp) = ui.allocate_exact_size(Vec2::splat(r * 2.0 + 2.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), r, color); +} + +enum NavBadge { + Em(&'static str), + Grey(String), + Red(&'static str), +} + +fn nav_item(ui: &mut Ui, label: &str, badge: NavBadge, active: bool) -> bool { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 36.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if active { + p.add(rounded_vgrad( + rect, + 8.0, + Color32::from_rgba_unmultiplied(25, 200, 126, 40), + Color32::from_rgba_unmultiplied(25, 200, 126, 8), + )); + p.rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 82))); + } else if hovered { + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgba_unmultiplied(255, 255, 255, 10)); + } + let fg = if active { + Color32::WHITE + } else if hovered { + Color32::from_rgb(226, 232, 240) + } else { + Color32::from_rgb(148, 163, 184) + }; + // small monitor/window glyph (filled tile + top strip) — not a checkbox + let icol = if active { crate::c_accent() } else { Color32::from_rgb(110, 123, 139) }; + let ic = Rect::from_min_size(Pos2::new(rect.left() + 11.0, rect.center().y - 7.0), Vec2::new(15.0, 13.0)); + p.rect_filled(ic, Rounding::same(3.0), icol.linear_multiply(0.22)); + p.rect_stroke(ic, Rounding::same(3.0), Stroke::new(1.3_f32, icol)); + p.hline(ic.left() + 2.5..=ic.right() - 2.5, ic.top() + 4.0, Stroke::new(1.0_f32, icol)); + p.text( + Pos2::new(rect.left() + 36.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + FontId::proportional(13.0), + fg, + ); + // badge + let (btxt, bfill, bborder, bfg) = match badge { + NavBadge::Em(t) => ( + t.to_string(), + Color32::from_rgba_unmultiplied(25, 200, 126, 38), + Color32::from_rgba_unmultiplied(25, 200, 126, 0), + crate::c_accent_hi(), + ), + NavBadge::Grey(t) => (t, Color32::from_rgb(30, 41, 59), Color32::from_rgb(30, 41, 59), Color32::from_rgb(148, 163, 184)), + NavBadge::Red(t) => (t.to_string(), CRIMSON_BG, CRIMSON_BD, CRIMSON_FG), + }; + let bg = ui.painter().layout_no_wrap(btxt, FontId::proportional(10.0), bfg); + let bsz = bg.size() + Vec2::new(14.0, 5.0); + let brect = Rect::from_min_size(Pos2::new(rect.right() - bsz.x - 8.0, rect.center().y - bsz.y / 2.0), bsz); + let p = ui.painter(); + p.rect_filled(brect, Rounding::same(6.0), bfill); + if bborder != bfill { + p.rect_stroke(brect, Rounding::same(6.0), Stroke::new(1.0_f32, bborder)); + } + p.galley(Pos2::new(brect.left() + 7.0, brect.center().y - bg.size().y / 2.0), bg, bfg); + resp.clicked() +} + +/// A 40x36 icon button for the collapsed sidebar rail: same active/hover +/// treatment as `nav_item`, label carried by the tooltip. +fn rail_item(ui: &mut Ui, glyph: &str, active: bool, tip: &str) -> bool { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(40.0, 36.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if active { + p.add(rounded_vgrad( + rect, + 8.0, + Color32::from_rgba_unmultiplied(25, 200, 126, 40), + Color32::from_rgba_unmultiplied(25, 200, 126, 8), + )); + p.rect_stroke( + rect, + Rounding::same(8.0), + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 82)), + ); + } else if hovered { + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgba_unmultiplied(255, 255, 255, 10)); + } + let col = if active { + crate::c_accent() + } else if hovered { + Color32::from_rgb(226, 232, 240) + } else { + Color32::from_rgb(148, 163, 184) + }; + p.text(rect.center(), egui::Align2::CENTER_CENTER, glyph, FontId::proportional(16.0), col); + resp.on_hover_text(tip).clicked() +} + +fn fleet_card(ui: &mut Ui, name: &str, id: &str, online: bool, busy: bool, w: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 38.0), Sense::click()); + let hovered = resp.hovered() && online; + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(7.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke( + rect, + Rounding::same(7.0), + Stroke::new(1.0_f32, if hovered { Color32::from_rgba_unmultiplied(25, 200, 126, 128) } else { Color32::from_rgb(37, 48, 64) }), + ); + p.text(Pos2::new(rect.left() + 12.0, rect.center().y - 7.0), egui::Align2::LEFT_CENTER, name, FontId::proportional(12.0), FG_SOFT); + let sub = if online { if busy { "In session" } else { "Online" } } else { "Offline" }; + p.text(Pos2::new(rect.left() + 12.0, rect.center().y + 8.0), egui::Align2::LEFT_CENTER, sub, FontId::proportional(10.0), if online { crate::c_accent() } else { FAINT }); + p.circle_filled(Pos2::new(rect.right() - 14.0, rect.center().y), 4.0, if online { crate::c_accent() } else { Color32::from_rgb(100, 116, 139) }); + let _ = id; + resp +} + +fn list_row(ui: &mut Ui, name: &str, id: &str, online: bool, busy: bool) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 48.0), Sense::click()); + let p = ui.painter(); + p.rect_filled(rect, Rounding::same(8.0), Color32::from_rgb(17, 22, 29)); + p.rect_stroke(rect, Rounding::same(8.0), Stroke::new(1.0_f32, Color32::from_rgb(37, 48, 64))); + p.circle_filled(Pos2::new(rect.left() + 16.0, rect.center().y), 5.0, if online { crate::c_accent() } else { Color32::from_rgb(100, 116, 139) }); + p.text(Pos2::new(rect.left() + 30.0, rect.center().y - 8.0), egui::Align2::LEFT_CENTER, name, FontId::proportional(13.0), FG_SOFT); + let sub = if online { if busy { "In session" } else { "Online" } } else { "Offline" }; + p.text(Pos2::new(rect.left() + 30.0, rect.center().y + 9.0), egui::Align2::LEFT_CENTER, sub, FontId::proportional(10.5), if online { crate::c_accent() } else { FAINT }); + p.text(Pos2::new(rect.right() - 16.0, rect.center().y), egui::Align2::RIGHT_CENTER, &fmt_id(id, true), FontId::monospace(12.0), FAINT); + resp +} + +fn side_account_chip(ui: &mut Ui, email: &str) { + chip_inset(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _r) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 14.0, Color32::from_rgba_unmultiplied(25, 200, 126, 51)); + ui.painter().circle_stroke(rect.center(), 14.0, Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(25, 200, 126, 110))); + let initials: String = email.chars().take(2).collect::().to_uppercase(); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, initials, FontId::proportional(11.0), crate::c_accent()); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.label(RichText::new(email).size(11.0).color(Color32::from_rgb(226, 232, 240))); + ui.label(RichText::new("✓ Cloud Synced").size(10.0).color(crate::c_accent())); + }); + }); + }); +} + +fn toast_card(ui: &mut Ui, title: &str, sub: &str) { + let frame = egui::Frame::none() + .fill(Color32::from_rgb(19, 25, 34)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(44, 56, 73))) + .rounding(Rounding::same(9.0)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .shadow(Shadow { + offset: Vec2::new(0.0, 10.0), + blur: 30.0, + spread: 0.0, + color: Color32::from_black_alpha(130), + }); + frame.show(ui, |ui| { + ui.set_min_width(220.0); + // green accent bar + let r = ui.max_rect(); + ui.painter().rect_filled( + Rect::from_min_size(Pos2::new(r.left() - 14.0, r.top() - 10.0), Vec2::new(3.0, r.height() + 20.0)), + Rounding::ZERO, + crate::c_accent(), + ); + ui.label(RichText::new(title).size(12.5).strong().color(Color32::WHITE)); + if !sub.is_empty() { + ui.label(RichText::new(sub).size(11.0).color(Color32::from_rgb(148, 163, 184))); + } + }); +} + +fn chip_inset(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + egui::Frame::none() + .fill(PANEL_INSET) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 44, 58))) + .rounding(Rounding::same(9.0)) + .inner_margin(Margin::symmetric(11.0, 10.0)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add(ui) + }) + .inner +} + +fn logo_mark(ui: &mut Ui, logo: Option, sz: f32) { + let (rect, _resp) = ui.allocate_exact_size(Vec2::splat(sz), Sense::hover()); + let rounding = Rounding::same(sz * 0.26); + // glow + gradient tile + let p = ui.painter(); + p.add(Shape::from( + Shadow { + offset: Vec2::new(0.0, 3.0), + blur: 14.0, + spread: 0.0, + color: Color32::from_rgba_unmultiplied(25, 200, 126, 90), + } + .as_shape(rect, rounding), + )); + p.add(rounded_vgrad(rect, sz * 0.26, Color32::from_rgb(37, 211, 134), Color32::from_rgb(22, 173, 105))); + if let Some(tex) = logo { + // draw the embedded logo glyph centred within the tile + let inner = rect.shrink(sz * 0.16); + ui.painter().image( + tex, + inner, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + } else { + // fallback: a simple white mountain glyph + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "▲", FontId::proportional(sz * 0.5), Color32::WHITE); + } +} + +fn paint_grid(painter: &egui::Painter, rect: Rect) { + let step = 28.0; + let col = Color32::from_rgba_unmultiplied(255, 255, 255, 4); + let mut x = rect.left(); + while x < rect.right() { + painter.vline(x, rect.top()..=rect.bottom(), Stroke::new(1.0_f32, col)); + x += step; + } + let mut y = rect.top(); + while y < rect.bottom() { + painter.hline(rect.left()..=rect.right(), y, Stroke::new(1.0_f32, col)); + y += step; + } +} + +// ============================ LOW-LEVEL HELPERS ============================ + +/// A filled rounded-rectangle with a smooth vertical colour gradient, built as +/// a per-vertex-coloured triangle fan. This is the core of the "3D" depth look. +fn rounded_vgrad(rect: Rect, radius: f32, top: Color32, bot: Color32) -> Shape { + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return Shape::Noop; + } + let r = radius.min(rect.width() * 0.5).min(rect.height() * 0.5).max(0.0); + let col = |y: f32| -> Color32 { + let t = ((y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0); + lerp_color(top, bot, t) + }; + let mut mesh = Mesh::default(); + let center = rect.center(); + mesh.colored_vertex(center, col(center.y)); + + let seg = 6usize; + let corners = [ + (Pos2::new(rect.left() + r, rect.top() + r), 180.0f32, 270.0f32), + (Pos2::new(rect.right() - r, rect.top() + r), 270.0, 360.0), + (Pos2::new(rect.right() - r, rect.bottom() - r), 0.0, 90.0), + (Pos2::new(rect.left() + r, rect.bottom() - r), 90.0, 180.0), + ]; + let mut pts: Vec = Vec::with_capacity((seg + 1) * 4); + for (c, a0, a1) in corners { + for i in 0..=seg { + let a = (a0 + (a1 - a0) * (i as f32 / seg as f32)).to_radians(); + pts.push(Pos2::new(c.x + r * a.cos(), c.y + r * a.sin())); + } + } + for p in &pts { + mesh.colored_vertex(*p, col(p.y)); + } + let n = pts.len() as u32; + for i in 0..n { + let a = 1 + i; + let b = 1 + (i + 1) % n; + mesh.add_triangle(0, a, b); + } + Shape::mesh(mesh) +} + +fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8 +} + +fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { + Color32::from_rgba_unmultiplied( + lerp_u8(a.r(), b.r(), t), + lerp_u8(a.g(), b.g(), t), + lerp_u8(a.b(), b.b(), t), + lerp_u8(a.a(), b.a(), t), + ) +} + +fn lighten(c: Color32, amt: f32) -> Color32 { + lerp_color(c, Color32::WHITE, amt) +} + +/// Format a 9-digit host ID with spaces: 123456789 -> "123 456 789". +fn fmt_id(id: &str, spaced: bool) -> String { + let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect(); + if !spaced || digits.len() != 9 { + return if digits.is_empty() { id.to_string() } else { digits }; + } + format!("{} {} {}", &digits[0..3], &digits[3..6], &digits[6..9]) +} + +/// Extract the relay host from a ws(s):// URL for the top-bar chip. +fn relay_host(server: &str) -> String { + let s = server + .trim() + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .trim_start_matches("https://") + .trim_start_matches("http://"); + s.split('/').next().unwrap_or("—").to_string() +} + +fn pretty_toggle(name: &str) -> &'static str { + match name { + "service_installed" | "service" => "Service Installed", + "start_with_windows" | "startup" => "Start with Windows", + "privacy" => "Privacy Mode", + "lock_on_end" | "lock" => "Lock on disconnect", + "close_to_tray" => "Close to tray", + _ => "Setting", + } +} + +#[cfg(windows)] +fn open_url(url: &str) -> std::io::Result<()> { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map(|_| ()) +} +#[cfg(not(windows))] +fn open_url(url: &str) -> std::io::Result<()> { + std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ()) +} + + +/// Build the standard otpauth:// URI for a base32 TOTP secret, so an +/// authenticator app (or our QR) enrolls it with SHA1 / 6 digits / 30s — +/// matching the host engine's RFC-6238 verifier (core::crypto::totp_verify). +fn totp_otpauth_uri(secret_b32: &str, account: &str) -> String { + fn enc(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{:02X}", b), + }) + .collect() + } + let label = enc(&format!("NetDesk:{account}")); + format!( + "otpauth://totp/{label}?secret={secret_b32}&issuer=NetDesk&algorithm=SHA1&digits=6&period=30" + ) +} + +/// Render `uri` to a QR as an egui ColorImage (black modules on white, a 4-module +/// quiet zone, 4px/module). None if the text is too long for a QR. +fn qr_color_image(uri: &str) -> Option { + let qr = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?; + let n = qr.size(); // modules per side (i32) + let quiet = 4i32; + let scale = 4usize; + let dim = ((n + 2 * quiet) as usize) * scale; + let mut px = vec![255u8; dim * dim * 4]; // white RGBA + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + for dy in 0..scale { + for dx in 0..scale { + let py = ((y + quiet) as usize) * scale + dy; + let pxi = ((x + quiet) as usize) * scale + dx; + let idx = (py * dim + pxi) * 4; + px[idx] = 0; + px[idx + 1] = 0; + px[idx + 2] = 0; + px[idx + 3] = 255; + } + } + } + } + } + Some(egui::ColorImage::from_rgba_unmultiplied([dim, dim], &px)) +} + +#[cfg(test)] +mod totp_qr_tests { + use super::*; + + #[test] + fn otpauth_uri_and_qr_generate() { + let uri = totp_otpauth_uri("JBSWY3DPEHPK3PXP", "this PC"); + assert!( + uri.starts_with("otpauth://totp/NetDesk%3Athis%20PC?secret=JBSWY3DPEHPK3PXP"), + "uri={uri}" + ); + let img = qr_color_image(&uri).expect("qr encodes"); + assert!(img.size[0] > 20 && img.size[0] == img.size[1], "square qr, got {:?}", img.size); + let black = img.pixels.iter().filter(|p| p.r() == 0).count(); + assert!(black > 0 && black < img.pixels.len(), "qr must mix black + white modules"); + println!("TOTP_QR_OK size={} black_px={}", img.size[0], black); + } +} diff --git a/native/viewer/src/host_ctl.rs b/native/viewer/src/host_ctl.rs new file mode 100644 index 0000000..6a9ea45 --- /dev/null +++ b/native/viewer/src/host_ctl.rs @@ -0,0 +1,1959 @@ +//! NetDesk host-control backend — a native Rust port of the Python desktop +//! agent's `Api` class (`netdesk/host/netdesk_gui.py`). +//! +//! This module gives the (stage-2) Rust host panel everything it needs to show +//! THIS PC's NetDesk identity and drive the local engine/service, WITHOUT any +//! browser or Python: +//! * read / write the shared `config.json` (same file the engine uses, so the +//! native panel and the existing engine share one identity), +//! * report engine status (online / in-session) from `gui_status.json`, +//! * start / stop the engine process (the auto-start Scheduled Task is left +//! intact — Stop only pauses the running engine), +//! * set / generate the unattended password (PBKDF2-200k verifier identical +//! to what the host expects, via `netdesk_core::crypto`), +//! * toggle 2FA and the privacy / lock-on-end / startup / tray / service +//! switches (writing config + doing the matching OS action), +//! * sign in / sign up / sign out against the relay `/api`, list this +//! account's devices, and ping the relay. +//! +//! Everything returns `Result` with a clear error; nothing panics. All OS- and +//! network-specific work is isolated in small helpers and `#[cfg]`-gated so the +//! module also compiles on non-Windows targets. +//! +//! Developed by Akronlogic (https://akronlogic.com). +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// The built-in relay, used when `config.json` has no `server` (mirrors the +/// engine's `BUILTIN_SERVER` / `DEFAULT_SERVER`). +pub const DEFAULT_SERVER: &str = "wss://netdesk.space/ws"; + +/// Name of the host engine executable we spawn for Start, and kill for Stop. +#[cfg(windows)] +const ENGINE_EXE: &str = "NetDeskHost.exe"; +#[cfg(not(windows))] +const ENGINE_EXE: &str = "netdesk-host"; + +/// `CreateProcess` flag to launch the engine with no console window. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +// =========================================================================== +// Error type — a clear, non-panicking error for every public call. +// =========================================================================== + +#[derive(Debug, Clone)] +pub enum CtlError { + /// Bad user input (e.g. a malformed email or a too-short password). + Invalid(String), + /// Local filesystem / config read-write failure. + Io(String), + /// Could not reach the relay (connection/TLS/timeout). + Http(String), + /// The relay answered with an error payload (`{"error": "..."}`). + Server(String), + /// An OS action failed (spawning the engine, registry, clipboard, …). + Os(String), +} + +impl std::fmt::Display for CtlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CtlError::Invalid(m) => write!(f, "{m}"), + CtlError::Io(m) => write!(f, "{m}"), + CtlError::Http(m) => write!(f, "{m}"), + CtlError::Server(m) => write!(f, "{m}"), + CtlError::Os(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for CtlError {} + +impl From for CtlError { + fn from(e: std::io::Error) -> Self { + CtlError::Io(e.to_string()) + } +} +impl From for CtlError { + fn from(e: serde_json::Error) -> Self { + CtlError::Io(format!("json: {e}")) + } +} + +pub type Result = std::result::Result; + +// =========================================================================== +// Config model — mirrors the engine's `config.json` (netdesk_host.py `Config`). +// =========================================================================== + +fn d_true() -> bool { + true +} +fn d_iters() -> u32 { + 200_000 +} + +/// The four per-session permission switches stored under `perms`. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Perms { + #[serde(default = "d_true")] + pub input: bool, + #[serde(default = "d_true")] + pub clipboard: bool, + #[serde(default = "d_true")] + pub files: bool, + #[serde(default = "d_true")] + pub audio: bool, +} +impl Default for Perms { + fn default() -> Self { + Self { + input: true, + clipboard: true, + files: true, + audio: true, + } + } +} + +/// The shared host configuration (`config.json`). Unknown keys are preserved in +/// `extra` so a round-trip never drops a field the engine wrote. `path` is where +/// this instance was loaded from / will be saved to (never serialized). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct HostConfig { + #[serde(default)] + pub id: String, + #[serde(default)] + pub salt: String, + #[serde(default = "d_iters")] + pub iters: u32, + #[serde(default)] + pub verifier: String, + #[serde(default)] + pub server: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub perms: Perms, + #[serde(default)] + pub lock_on_end: bool, + #[serde(default)] + pub privacy_on_session: bool, + #[serde(default)] + pub allow_display_power: bool, + #[serde(default)] + pub server_locked_by_user: bool, + #[serde(default)] + pub account_token: String, + #[serde(default)] + pub account_email: String, + #[serde(default)] + pub allow_ips: String, + /// Approve-before-connect: hold a password-authenticated viewer until the + /// person at this PC clicks Accept (20s timeout denies). Off by default — + /// unattended hosts stay reachable. + #[serde(default)] + pub approve_click: bool, + #[serde(default)] + pub pw_plain: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reg_key: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub totp: String, + #[serde(default = "d_true")] + pub close_to_tray: bool, + /// Any other keys present in the file (preserved verbatim on save). + #[serde(flatten)] + pub extra: Map, + /// Where this config lives on disk. Not part of the JSON. + #[serde(skip)] + pub path: PathBuf, +} + +impl Default for HostConfig { + fn default() -> Self { + Self { + id: String::new(), + salt: String::new(), + iters: 200_000, + verifier: String::new(), + server: String::new(), + name: String::new(), + perms: Perms::default(), + lock_on_end: false, + privacy_on_session: false, + allow_display_power: false, + server_locked_by_user: false, + account_token: String::new(), + account_email: String::new(), + allow_ips: String::new(), + approve_click: false, + pw_plain: String::new(), + reg_key: String::new(), + totp: String::new(), + close_to_tray: true, + extra: Map::new(), + path: PathBuf::new(), + } + } +} + +impl HostConfig { + /// Load from the resolved shared location (see [`config_path`]). + pub fn load() -> Result { + read_config() + } + + /// Persist back to `self.path`. + pub fn save(&self) -> Result<()> { + write_config(self) + } + + /// `https://host[:port]` base for the relay's HTTP API, derived from + /// `server` exactly like the Python `_api_base` (ws→http, drop trailing + /// `/ws`). e.g. `wss://netdesk.space/ws` → `https://netdesk.space`. + pub fn api_base(&self) -> String { + let mut url = if self.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + self.server.trim().to_string() + }; + url = url.replace("wss://", "https://").replace("ws://", "http://"); + if let Some(idx) = url.rfind("/ws") { + url.truncate(idx); + } + url.trim_end_matches('/').to_string() + } + + /// `true` once this account has a saved relay token. + pub fn signed_in(&self) -> bool { + !self.account_token.is_empty() + } +} + +// =========================================================================== +// Output types for the UI. +// =========================================================================== + +/// Live engine state: online when a fresh (< 12 s) `gui_status.json` heartbeat +/// is found in any engine dir, OR a `--engine` process is running, OR the relay +/// recently reported this id online (see [`engine_status`]). `state` is the raw +/// engine state string (`online` / `session` / `connecting` / `error…` / +/// `running` / `stopped`). +#[derive(Serialize, Clone, Debug)] +pub struct EngineStatus { + pub online: bool, + pub in_session: bool, + pub state: String, +} + +/// One of this account's computers, from `GET /api/devices`. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct Device { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub online: bool, + #[serde(default)] + pub busy: bool, + #[serde(default)] + pub ver: String, + #[serde(default)] + pub mac: String, + #[serde(default)] + pub last_seen: i64, +} + +/// Result of a successful sign-in / sign-up. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Account { + pub email: String, + pub msg: String, +} + +/// Result of toggling 2FA. When enabling, `secret` is the new base32 secret and +/// `secret_file` is the `totp_secret.txt` written next to the config. +#[derive(Serialize, Clone, Debug, Default)] +pub struct TwoFa { + pub on: bool, + pub secret: Option, + pub secret_file: Option, +} + +/// A ready-to-render snapshot of this PC's identity + state (mirrors the Python +/// `Api.get_state`), assembled from the config and the live checks. +#[derive(Serialize, Clone, Debug)] +pub struct HostState { + pub id: String, + pub name: String, + pub server: String, + pub account: String, + pub password: String, + pub state: String, + pub online: bool, + pub in_session: bool, + pub totp: bool, + pub privacy: bool, + pub lock_on_end: bool, + pub close_to_tray: bool, + pub service_installed: bool, + pub startup: bool, + pub config_path: String, +} + +// =========================================================================== +// Config path resolution + read/write. +// =========================================================================== + +/// Resolve the shared `config.json` path, mirroring the engine's +/// `default_config_dir()` so the native panel and the engine agree: +/// 1. `$NETDESK_CONFIG` (explicit override, handy for dev), +/// 2. `config.json` next to the current executable (portable layout), +/// 3. the per-user location (`%APPDATA%\NetDesk\config.json`, or +/// `~/.netdesk/config.json`) if it already exists, +/// 4. otherwise create in the portable location when that dir is writable, +/// 5. else the per-user location. +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("NETDESK_CONFIG") { + if !p.trim().is_empty() { + return PathBuf::from(p); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let c = dir.join("config.json"); + if c.is_file() { + return c; + } + } + } + if let Some(c) = per_user_config() { + if c.is_file() { + return c; + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + if dir_writable(dir) { + return dir.join("config.json"); + } + } + } + per_user_config().unwrap_or_else(|| PathBuf::from("config.json")) +} + +#[cfg(windows)] +fn per_user_config() -> Option { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("NetDesk").join("config.json")) +} +#[cfg(not(windows))] +fn per_user_config() -> Option { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".netdesk").join("config.json")) +} + +/// Read the shared config from [`config_path`]. +pub fn read_config() -> Result { + read_config_at(config_path()) +} + +/// Read (or create) the config at a specific path. Missing identity fields are +/// generated and persisted exactly like the engine's `Config.__init__`. +pub fn read_config_at(path: PathBuf) -> Result { + let existed = path.is_file(); + let mut cfg = if existed { + match std::fs::read_to_string(&path) { + Ok(s) => parse_config(&s), + Err(_) => HostConfig::default(), + } + } else { + HostConfig::default() + }; + cfg.path = path; + + let mut mutated = !existed; + if cfg.id.trim().is_empty() { + cfg.id = netdesk_core::crypto::gen_host_id(); + mutated = true; + } + if cfg.verifier.trim().is_empty() { + let pw = netdesk_core::crypto::gen_password(8); + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(&pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw; + mutated = true; + } + if cfg.iters == 0 { + cfg.iters = 200_000; + } + if cfg.server.trim().is_empty() { + cfg.server = DEFAULT_SERVER.to_string(); + } + if cfg.name.trim().is_empty() { + cfg.name = computer_name(); + } + if mutated { + write_config(&cfg)?; + } + Ok(cfg) +} + +/// Apply a machine-wide managed policy (GPO-style) over a loaded config. Any key +/// present in `%ProgramData%\NetDesk\policy.json` OVERRIDES the user's value and +/// is reported as LOCKED (the UI greys the field). The overlay is in-memory only +/// — never written back to config.json — so removing the policy reverts to the +/// user's own settings. Returns the set of locked top-level keys. +#[cfg(windows)] +pub fn apply_policy(cfg: &mut HostConfig) -> std::collections::HashSet { + apply_policy_from(cfg, programdata_dir().join("policy.json")) +} + +/// Core of [`apply_policy`], taking an explicit policy path (so it's testable). +pub fn apply_policy_from( + cfg: &mut HostConfig, + path: PathBuf, +) -> std::collections::HashSet { + let mut locked = std::collections::HashSet::new(); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return locked, // no policy -> nothing locked + }; + let obj = match serde_json::from_str::(&text) { + Ok(Value::Object(m)) => m, + _ => return locked, + }; + for (k, val) in &obj { + match k.as_str() { + "server" => { + if let Some(s) = val.as_str() { + cfg.server = s.to_string(); + locked.insert(k.clone()); + } + } + "reg_key" => { + if let Some(s) = val.as_str() { + cfg.reg_key = s.to_string(); + locked.insert(k.clone()); + } + } + "allow_ips" => { + if let Some(s) = val.as_str() { + cfg.allow_ips = s.to_string(); + locked.insert(k.clone()); + } + } + "totp" => { + if let Some(s) = val.as_str() { + cfg.totp = s.to_string(); + locked.insert(k.clone()); + } + } + "lock_on_end" => { + if let Some(b) = val.as_bool() { + cfg.lock_on_end = b; + locked.insert(k.clone()); + } + } + "approve_click" => { + if let Some(b) = val.as_bool() { + cfg.approve_click = b; + locked.insert(k.clone()); + } + } + "perms" => { + if let Some(p) = val.as_object() { + if let Some(b) = p.get("input").and_then(|x| x.as_bool()) { + cfg.perms.input = b; + } + if let Some(b) = p.get("clipboard").and_then(|x| x.as_bool()) { + cfg.perms.clipboard = b; + } + if let Some(b) = p.get("files").and_then(|x| x.as_bool()) { + cfg.perms.files = b; + } + if let Some(b) = p.get("audio").and_then(|x| x.as_bool()) { + cfg.perms.audio = b; + } + locked.insert(k.clone()); + } + } + _ => {} + } + } + locked +} + +/// Parse config JSON. Tries the typed form first; on any shape mismatch falls +/// back to a tolerant field-by-field load so a single odd value never discards +/// the device identity. +fn parse_config(s: &str) -> HostConfig { + if let Ok(c) = serde_json::from_str::(s) { + return c; + } + match serde_json::from_str::(s) { + Ok(Value::Object(m)) => HostConfig::from_raw(m), + _ => HostConfig::default(), + } +} + +/// Write the config atomically (tmp file + rename), mirroring `Config.save`. +pub fn write_config(cfg: &HostConfig) -> Result<()> { + let path = &cfg.path; + if path.as_os_str().is_empty() { + return Err(CtlError::Io("config path is empty".into())); + } + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).ok(); + } + } + let json = serde_json::to_string_pretty(cfg)?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json.as_bytes())?; + // std::fs::rename overwrites the destination on Windows and Unix. + std::fs::rename(&tmp, path)?; + Ok(()) +} + +impl HostConfig { + /// Tolerant loader used when the strict typed parse fails. + fn from_raw(mut m: Map) -> HostConfig { + let mut c = HostConfig::default(); + if let Some(v) = m.remove("id") { + c.id = str_of(&v); + } + if let Some(v) = m.remove("salt") { + c.salt = str_of(&v); + } + if let Some(v) = m.remove("iters") { + c.iters = u32_of(&v, 200_000); + } + if let Some(v) = m.remove("verifier") { + c.verifier = str_of(&v); + } + if let Some(v) = m.remove("server") { + c.server = str_of(&v); + } + if let Some(v) = m.remove("name") { + c.name = str_of(&v); + } + if let Some(Value::Object(p)) = m.remove("perms") { + c.perms = Perms { + input: p.get("input").map(bool_of).unwrap_or(true), + clipboard: p.get("clipboard").map(bool_of).unwrap_or(true), + files: p.get("files").map(bool_of).unwrap_or(true), + audio: p.get("audio").map(bool_of).unwrap_or(true), + }; + } + if let Some(v) = m.remove("lock_on_end") { + c.lock_on_end = bool_of(&v); + } + if let Some(v) = m.remove("privacy_on_session") { + c.privacy_on_session = bool_of(&v); + } + if let Some(v) = m.remove("allow_display_power") { + c.allow_display_power = bool_of(&v); + } + if let Some(v) = m.remove("server_locked_by_user") { + c.server_locked_by_user = bool_of(&v); + } + if let Some(v) = m.remove("account_token") { + c.account_token = str_of(&v); + } + if let Some(v) = m.remove("account_email") { + c.account_email = str_of(&v); + } + if let Some(v) = m.remove("allow_ips") { + c.allow_ips = str_of(&v); + } + if let Some(v) = m.remove("pw_plain") { + c.pw_plain = str_of(&v); + } + if let Some(v) = m.remove("reg_key") { + c.reg_key = str_of(&v); + } + if let Some(v) = m.remove("totp") { + c.totp = str_of(&v); + } + if let Some(v) = m.remove("close_to_tray") { + c.close_to_tray = bool_of(&v); + } + c.extra = m; + c + } +} + +// =========================================================================== +// Engine status + start / stop. +// =========================================================================== + +/// Live engine status for the panel. The engine is reported ONLINE when ANY of +/// these hold (fixing the panel showing "Stopped" while the real engine runs): +/// (a) a fresh (< 12 s) `gui_status.json` heartbeat — read from EVERY folder +/// the engine/broker might write it to (the panel's config dir, the +/// engine exe's folder, `%APPDATA%\NetDesk`, the ProgramData service +/// mirror), trusting the freshest. The old code looked only in the +/// panel's own config dir, so a portable engine heartbeating next to +/// `NetDeskHost.exe` was never seen; +/// (b) a `NetDeskHost --engine` process is running locally; +/// (c) the relay recently reported THIS id online — fed from the panel's +/// `/api/devices` poll (see [`note_server_devices`]) and refreshed in the +/// background, so this call never blocks on the network. +pub fn engine_status(cfg: &HostConfig) -> EngineStatus { + let mut es = engine_status_local(cfg); + if !es.online && server_says_online(&cfg.id) { + es.online = true; + if es.state == "stopped" { + es.state = "online".to_string(); + } + } + // Offline on local signals -> learn the relay's view for next time, without + // ever blocking this (UI-thread) call. + if !es.online { + maybe_refresh_server_online(cfg); + } + es +} + +/// Local-only liveness (status files + process check). This is the basis of +/// [`engine_status`] AND the guard used by [`start_service`], so a stale server +/// flag or a remote holder can never block starting the LOCAL engine. +fn engine_status_local(cfg: &HostConfig) -> EngineStatus { + // Unified app: the in-process native Rust engine is authoritative when it is + // running (no heartbeat file / separate process to detect). + #[cfg(windows)] + if let Some(ns) = native_engine_status() { + let state = if ns.viewer_connected { + "session" + } else if ns.registered { + "online" + } else { + "running" + }; + return EngineStatus { + online: true, + in_session: ns.viewer_connected, + state: state.to_string(), + }; + } + + let mut state = String::from("stopped"); + let mut in_session = false; + let mut fresh = false; + + if let Some((_, st, insess)) = freshest_status(cfg) { + fresh = true; + state = st; + in_session = insess; + } + + // A stale duplicate engine writes "already connected elsewhere" between the + // real engine's heartbeats; with a fresh local heartbeat that means the ID + // IS served here -> online (mirrors _get_state_inner's anti-flicker). + if fresh && state.contains("already connected") { + state = "online".to_string(); + } + + let mut online = fresh && state != "stopped"; + if !online && engine_process_running() { + online = true; + if state == "stopped" { + state = "running".to_string(); + } + } + if state == "session" { + in_session = true; + } + + EngineStatus { + online, + in_session, + state, + } +} + +/// The freshest (< 12 s) `gui_status.json` heartbeat for THIS id across every +/// directory the engine/broker might write it to. Returns `(ts, state, +/// in_session)`, or `None` when nothing fresh for this id is found. +fn freshest_status(cfg: &HostConfig) -> Option<(f64, String, bool)> { + let now = now_secs(); + let mut best: Option<(f64, String, bool)> = None; + for dir in status_dirs(cfg) { + let s = match std::fs::read_to_string(dir.join("gui_status.json")) { + Ok(s) => s, + Err(_) => continue, + }; + let v: Value = match serde_json::from_str(&s) { + Ok(v) => v, + Err(_) => continue, + }; + let ts = v.get("ts").and_then(Value::as_f64).unwrap_or(0.0); + if now - ts >= 12.0 { + continue; // stale heartbeat + } + // A status file naming a DIFFERENT id belongs to another device (e.g. a + // shared folder) — never read this machine's state from it. + if !cfg.id.trim().is_empty() { + if let Some(fid) = v.get("id").and_then(Value::as_str) { + if !fid.is_empty() && fid != cfg.id { + continue; + } + } + } + if best.as_ref().map(|(b, _, _)| ts > *b).unwrap_or(true) { + let state = v + .get("state") + .and_then(Value::as_str) + .unwrap_or("running") + .to_string(); + let in_session = v.get("in_session").and_then(Value::as_bool).unwrap_or(false); + best = Some((ts, state, in_session)); + } + } + best +} + +/// Every directory a NetDesk engine or broker might have written +/// `gui_status.json` into. The Python engine writes it next to ITS config +/// (`default_config_dir()`): the exe folder for a frozen/portable build, else +/// `%APPDATA%\NetDesk`. A unified install keeps the engine beside the viewer, +/// and the SYSTEM service mirrors to ProgramData. We read them all and trust the +/// freshest, so the panel sees the real engine regardless of how it launched. +fn status_dirs(cfg: &HostConfig) -> Vec { + let mut dirs: Vec = Vec::new(); + let mut push = |d: PathBuf| { + if !d.as_os_str().is_empty() && !dirs.iter().any(|x| x == &d) { + dirs.push(d); + } + }; + // 1. The config dir the panel itself resolved. + if let Some(p) = cfg.path.parent() { + push(p.to_path_buf()); + } + // 2. Beside the running viewer (unified/portable layout: engine next to us). + if let Ok(exe) = std::env::current_exe() { + if let Some(d) = exe.parent() { + push(d.to_path_buf()); + } + } + // 3. Beside the engine exe we would spawn / that is installed. + if let Some(d) = host_engine_exe(cfg).parent() { + push(d.to_path_buf()); + } + // 4. The per-user location (%APPDATA%\NetDesk or ~/.netdesk). + if let Some(d) = per_user_config().and_then(|c| c.parent().map(Path::to_path_buf)) { + push(d); + } + // 5. The SYSTEM service's ProgramData mirror. + #[cfg(windows)] + push(programdata_dir()); + dirs +} + +// --- relay online flag (fed by the panel's /api/devices poll) --------------- + +/// The relay's last-known online flag for a host id, and when we observed it. +struct ServerOnline { + id: String, + online: bool, + at: Instant, +} +static SERVER_ONLINE: Mutex> = Mutex::new(None); +static SERVER_REFRESH_AT: Mutex> = Mutex::new(None); +static SERVER_REFRESH_INFLIGHT: AtomicBool = AtomicBool::new(false); + +/// Record what `GET /api/devices` reported for `id`. The panel already polls the +/// device list every ~20 s; calling this on each result lets [`engine_status`] +/// honor the relay's online flag for free, with no extra network traffic. +pub fn note_server_devices(id: &str, devices: &[Device]) { + let online = devices.iter().any(|d| d.id == id && d.online); + note_server_online(id, online); +} + +/// Record a single relay online observation for `id`. +pub fn note_server_online(id: &str, online: bool) { + if let Ok(mut g) = SERVER_ONLINE.lock() { + *g = Some(ServerOnline { + id: id.to_string(), + online, + at: Instant::now(), + }); + } +} + +/// `true` when the relay reported THIS id online within the last 45 s (longer +/// than the panel's 20 s poll so one missed poll never flips it to Stopped). +fn server_says_online(id: &str) -> bool { + if id.trim().is_empty() { + return false; + } + SERVER_ONLINE + .lock() + .ok() + .and_then(|g| { + g.as_ref() + .map(|s| s.id == id && s.online && s.at.elapsed() < Duration::from_secs(45)) + }) + .unwrap_or(false) +} + +/// Best-effort, throttled (<= once / 15 s) background refresh of the relay +/// online flag for signed-in accounts. Never blocks the caller: the result +/// lands in `SERVER_ONLINE` for the next [`engine_status`]. Keeps the status +/// honest even when nothing else happens to be polling `/api/devices`. +fn maybe_refresh_server_online(cfg: &HostConfig) { + if cfg.account_token.trim().is_empty() || cfg.id.trim().is_empty() { + return; + } + { + let mut g = match SERVER_REFRESH_AT.lock() { + Ok(g) => g, + Err(_) => return, + }; + if let Some(t) = *g { + if t.elapsed() < Duration::from_secs(15) { + return; + } + } + *g = Some(Instant::now()); + } + if SERVER_REFRESH_INFLIGHT.swap(true, Ordering::SeqCst) { + return; // one refresh already in flight + } + let cfg = cfg.clone(); + std::thread::spawn(move || { + if let Ok(devs) = list_devices(&cfg) { + note_server_devices(&cfg.id, &devs); + } + SERVER_REFRESH_INFLIGHT.store(false, Ordering::SeqCst); + }); +} + +/// Handle to the in-process native Rust host engine (the unified app). Start +/// runs `netdesk_host::run::run_engine` on a background thread IN THIS PROCESS — +/// capture + hardware encode + SendInput, no external Python engine. Windows. +#[cfg(windows)] +static NATIVE_ENGINE: Mutex> = Mutex::new(None); + +/// Live status of the in-process native engine, or `None` when not running. +#[cfg(windows)] +pub fn native_engine_status() -> Option { + NATIVE_ENGINE + .lock() + .ok() + .and_then(|g| g.as_ref().map(|h| h.snapshot())) +} +#[cfg(not(windows))] +pub fn native_engine_status() -> Option<()> { + None +} + +/// Kill any stray external Python `NetDeskHost.exe --engine` so only the native +/// in-process engine holds the ID. +#[cfg(windows)] +fn stop_python_engine() { + let ps = "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--engine*' } | \ + ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"; + let _ = run_ps(ps); +} + +/// Start the host engine IN-PROCESS (native Rust): DXGI capture + hardware +/// H.264 encode + SendInput, serving viewers with auto-reconnect. This is the +/// unified app — no Python engine is spawned. +pub fn start_service(cfg: &HostConfig) -> Result<()> { + #[cfg(windows)] + { + if NATIVE_ENGINE.lock().map(|g| g.is_some()).unwrap_or(false) { + return Err(CtlError::Os("Engine is already running.".into())); + } + // Apply a machine-wide managed policy (if present) BEFORE starting, so an + // admin's locked server / permissions / lock-on-end win over the local + // config. In-memory only (not persisted), so removing the policy reverts. + let mut cfg_owned = cfg.clone(); + let locked = apply_policy(&mut cfg_owned); + if !locked.is_empty() { + eprintln!("[netdesk] managed policy active; locked: {locked:?}"); + } + let cfg = &cfg_owned; + let id = cfg.id.trim().to_string(); + if id.len() != 9 || !id.chars().all(|c| c.is_ascii_digit()) { + return Err(CtlError::Invalid("No valid 9-digit host ID.".into())); + } + let password = cfg.pw_plain.trim().to_string(); + if password.chars().count() < 8 { + return Err(CtlError::Invalid( + "Set a password (8+ characters) before starting.".into(), + )); + } + let ws_url = if cfg.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + cfg.server.trim().to_string() + }; + let name = if cfg.name.trim().is_empty() { + "NetDesk".to_string() + } else { + cfg.name.trim().to_string() + }; + + // Only the native engine should hold the ID. + stop_python_engine(); + + let engine_cfg = netdesk_host::run::EngineConfig { + ws_url, + host_id: id, + name, + password, + monitor: 0, + fps: 60, + bitrate: 8_000_000, + // The config.json permission switches are ENFORCED by the engine + // (events for a disabled channel are never emitted) and advertised + // to viewers in session.accept info.perms. + perms: netdesk_core::host::HostPerms { + input: cfg.perms.input, + clipboard: cfg.perms.clipboard, + files: cfg.perms.files, + audio: cfg.perms.audio, + }, + approve: cfg.approve_click, + totp_secret: cfg.totp.clone(), + // The GUI host uses the relay; direct-IP is opt-in via the engine bin + // (ND_DIRECT_PORT) for now. The CIDR allow-list still applies if set. + direct_port: None, + allow_ips: cfg.allow_ips.clone(), + max_viewers: 1, + }; + let handle = netdesk_host::run::EngineHandle::new(); + let thread_handle = handle.clone(); + std::thread::Builder::new() + .name("netdesk-engine".into()) + .spawn(move || { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + if let Err(e) = + rt.block_on(netdesk_host::run::run_engine(engine_cfg, thread_handle)) + { + eprintln!("[netdesk] engine exited: {e}"); + } + } + Err(e) => eprintln!("[netdesk] engine runtime: {e}"), + } + }) + .map_err(|e| CtlError::Os(format!("spawn engine thread: {e}")))?; + *NATIVE_ENGINE.lock().unwrap() = Some(handle); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = cfg; + Err(CtlError::Os("Hosting is Windows-only in this build.".into())) + } +} + +/// Stop the in-process native engine (and kill any stray Python engine). +pub fn stop_service(cfg: &HostConfig) -> Result<()> { + let _ = cfg; + if let Ok(mut g) = LAST_ENGINE_PID.lock() { + *g = None; + } + #[cfg(windows)] + { + if let Ok(mut g) = NATIVE_ENGINE.lock() { + if let Some(h) = g.take() { + h.stop(); + } + } + stop_python_engine(); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = std::process::Command::new("pkill") + .args(["-f", "--engine"]) + .status(); + Ok(()) + } +} + +// =========================================================================== +// Password / 2FA. +// =========================================================================== + +/// Set the unattended password: fresh salt + PBKDF2-200k verifier (identical to +/// the host engine), saved to `salt` / `iters` / `verifier` (+ `pw_plain`). +pub fn set_unattended_password(cfg: &mut HostConfig, pw: &str) -> Result<()> { + let pw = pw.trim(); + if pw.chars().count() < 8 { + return Err(CtlError::Invalid("Use at least 8 characters.".into())); + } + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw.to_string(); + write_config(cfg) +} + +/// One-click random password (10 chars), saved and returned (mirrors +/// `generate_password`). The engine picks it up at the next connection. +pub fn generate_password(cfg: &mut HostConfig) -> Result { + let pw = netdesk_core::crypto::gen_password(10); + let (salt, iters, verifier) = netdesk_core::crypto::new_password_record(&pw); + cfg.salt = salt; + cfg.iters = iters; + cfg.verifier = verifier; + cfg.pw_plain = pw.clone(); + write_config(cfg)?; + Ok(pw) +} + +/// Enable or disable 2FA. Enabling generates a 32-char base32 secret, writes it +/// to config and to `totp_secret.txt` next to the config (mirrors `toggle_2fa`). +pub fn toggle_2fa(cfg: &mut HostConfig, on: bool) -> Result { + if on { + let secret = netdesk_core::crypto::gen_totp_secret(); + cfg.totp = secret.clone(); + write_config(cfg)?; + let mut secret_file = None; + if let Some(dir) = cfg.path.parent() { + let p = dir.join("totp_secret.txt"); + if std::fs::write(&p, format!("NetDesk 2FA secret (base32)\n\n{secret}\n")).is_ok() { + secret_file = Some(p); + } + } + Ok(TwoFa { + on: true, + secret: Some(secret), + secret_file, + }) + } else { + cfg.totp.clear(); + write_config(cfg)?; + Ok(TwoFa { + on: false, + secret: None, + secret_file: None, + }) + } +} + +// =========================================================================== +// Switches: privacy / lock_on_end / startup / close_to_tray / service. +// =========================================================================== + +/// Flip a named switch and perform the matching OS action. Recognised names: +/// `privacy`, `lock_on_end` (alias `lock`), `close_to_tray`, +/// `start_with_windows` (alias `startup`), `service_installed` (alias +/// `service`). Returns a short human message (may be empty). +pub fn set_toggle(cfg: &mut HostConfig, name: &str, on: bool) -> Result { + match name { + "privacy" => { + cfg.privacy_on_session = on; + cfg.allow_display_power = on; + write_config(cfg)?; + Ok(String::new()) + } + "lock_on_end" | "lock" => { + cfg.lock_on_end = on; + write_config(cfg)?; + Ok(String::new()) + } + "close_to_tray" => { + cfg.close_to_tray = on; + write_config(cfg)?; + Ok(String::new()) + } + "start_with_windows" | "startup" => set_startup(cfg, on), + "service_installed" | "service" => { + if on { + install_service(cfg) + } else { + remove_service() + } + } + other => Err(CtlError::Invalid(format!("unknown toggle: {other}"))), + } +} + +#[cfg(windows)] +fn set_startup(_cfg: &HostConfig, on: bool) -> Result { + const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; + if on { + // Auto-start the unified app itself in headless host mode (native Rust + // in-process engine) — not the old Python NetDeskHost.exe. + let exe = std::env::current_exe() + .map_err(|e| CtlError::Os(format!("cannot resolve the app path: {e}")))?; + let val = format!("\"{}\" --host", exe.display()); + let (code, _) = run_capture( + "reg", + &["add", RUN_KEY, "/v", "NetDesk", "/t", "REG_SZ", "/d", &val, "/f"], + )?; + cache_set(&STU_CACHE, true); + if code != 0 { + return Err(CtlError::Os("could not write the startup registry entry".into())); + } + Ok("Startup entry added.".into()) + } else { + let _ = run_capture("reg", &["delete", RUN_KEY, "/v", "NetDesk", "/f"]); + cache_set(&STU_CACHE, false); + Ok("Startup entry removed.".into()) + } +} +#[cfg(not(windows))] +fn set_startup(_cfg: &HostConfig, _on: bool) -> Result { + Err(CtlError::Os("the startup toggle is Windows-only".into())) +} + +#[cfg(windows)] +fn install_service(cfg: &HostConfig) -> Result { + // Hand over: stop the panel-spawned engine so the service owns the link. + let _ = stop_service(cfg); + let exe = host_engine_exe(cfg); + let server = if cfg.server.trim().is_empty() { + DEFAULT_SERVER + } else { + cfg.server.trim() + }; + let result_path = programdata_dir().join("install_result.json"); + let _ = std::fs::remove_file(&result_path); + + // Elevated install via UAC (Start-Process -Verb RunAs). The elevated + // NetDeskHost.exe --install creates the Scheduled Task. + let mut arglist = format!("'--install','--server','{}'", ps_single_quote(server)); + if !cfg.reg_key.trim().is_empty() { + arglist.push_str(&format!(",'--reg-key','{}'", ps_single_quote(cfg.reg_key.trim()))); + } + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList {} -Verb RunAs", + ps_single_quote(&exe.display().to_string()), + arglist + ); + run_ps(&ps).map_err(|e| CtlError::Os(format!("could not launch the installer: {e}")))?; + + // Wait for the elevated installer (self-extract + task create). Trust + // install_result.json, fall back to querying the task (mirrors Python). + for _ in 0..90 { + if let Ok(s) = std::fs::read_to_string(&result_path) { + if let Ok(v) = serde_json::from_str::(&s) { + if v.get("ok").and_then(Value::as_bool).unwrap_or(false) { + cache_set(&SVC_CACHE, true); + return Ok("Service installed — reachable at boot, the logon screen \ + and UAC prompts. The engine handed over to the service." + .into()); + } + let err = v + .get("error") + .and_then(Value::as_str) + .unwrap_or("install failed"); + return Err(CtlError::Os(err.to_string())); + } + } + if service_installed_uncached() == Some(true) { + cache_set(&SVC_CACHE, true); + return Ok("Service installed — reachable at boot, the logon screen \ + and UAC prompts." + .into()); + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(CtlError::Os( + "Install is taking longer than expected. If you accepted the UAC prompt, \ + the toggle should turn on shortly — reopen the panel to check." + .into(), + )) +} +#[cfg(not(windows))] +fn install_service(_cfg: &HostConfig) -> Result { + Err(CtlError::Os("the service is Windows-only".into())) +} + +#[cfg(windows)] +fn remove_service() -> Result { + let _ = run_capture("schtasks", &["/Delete", "/TN", "NetDesk", "/F"]); + // Also stop any broker/engine processes left from the removed service. + let ps = "Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--broker*' -or $_.CommandLine -like '*--engine*' } | \ + ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"; + let _ = run_ps(ps); + cache_set(&SVC_CACHE, false); + Ok("Service removed (if it existed).".into()) +} +#[cfg(not(windows))] +fn remove_service() -> Result { + Err(CtlError::Os("the service is Windows-only".into())) +} + +/// Is the NetDesk Scheduled Task installed? Distinguishes not-found from +/// access-denied (task EXISTS) by HRESULT, locale-independent. Cached 10 s. +pub fn service_installed() -> bool { + #[cfg(windows)] + { + if let Some(v) = cache_get(&SVC_CACHE, Duration::from_secs(10)) { + return v; + } + let v = service_installed_uncached().unwrap_or_else(|| cache_last(&SVC_CACHE)); + cache_set(&SVC_CACHE, v); + v + } + #[cfg(not(windows))] + { + false + } +} + +#[cfg(windows)] +fn service_installed_uncached() -> Option { + let ps = "$s=New-Object -ComObject Schedule.Service;$s.Connect();\ + try{$null=$s.GetFolder('\\').GetTask('NetDesk');Write-Output 1}\ + catch{if($_.Exception.HResult -eq -2147024891){Write-Output 1}\ + else{Write-Output 0}}"; + match run_capture("powershell", &["-NoProfile", "-Command", ps]) { + Ok((_, out)) => Some(out.trim().ends_with('1')), + Err(_) => None, + } +} + +/// Is the HKCU Run startup entry present? Cached 10 s. +pub fn startup_present() -> bool { + #[cfg(windows)] + { + if let Some(v) = cache_get(&STU_CACHE, Duration::from_secs(10)) { + return v; + } + let v = match run_capture( + "reg", + &[ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + "NetDesk", + ], + ) { + Ok((code, _)) => code == 0, + Err(_) => cache_last(&STU_CACHE), + }; + cache_set(&STU_CACHE, v); + v + } + #[cfg(not(windows))] + { + false + } +} + +// =========================================================================== +// Account (relay /api) + devices + ping — HTTP via blocking ureq (rustls). +// =========================================================================== + +fn http_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(20)) + .timeout_write(Duration::from_secs(20)) + .build() +} + +/// POST JSON to ``, attaching the account bearer token when set. +/// Returns the parsed JSON body; a 4xx/5xx with `{"error":…}` becomes +/// `CtlError::Server`, an unreachable relay becomes `CtlError::Http`. +fn api_post(cfg: &HostConfig, path: &str, body: Value) -> Result { + let url = format!("{}{}", cfg.api_base(), path); + let mut req = http_agent() + .post(&url) + .set("Content-Type", "application/json"); + if !cfg.account_token.is_empty() { + req = req.set("Authorization", &format!("Bearer {}", cfg.account_token)); + } + match req.send_json(body) { + Ok(resp) => resp + .into_json::() + .map_err(|e| CtlError::Server(format!("bad server response: {e}"))), + Err(ureq::Error::Status(code, resp)) => Err(CtlError::Server(server_error(resp, code))), + Err(ureq::Error::Transport(t)) => { + Err(CtlError::Http(format!("cannot reach server: {t}"))) + } + } +} + +/// Apply a successful auth response (save token + email) or surface its error. +fn account_result(cfg: &mut HostConfig, r: Value) -> Result { + let ok = r.get("ok").and_then(Value::as_bool).unwrap_or(false); + let token = r.get("token").and_then(Value::as_str).unwrap_or(""); + if ok && !token.is_empty() { + let email = r + .get("email") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let msg = r + .get("msg") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + cfg.account_token = token.to_string(); + cfg.account_email = email.clone(); + write_config(cfg)?; + Ok(Account { email, msg }) + } else { + let err = r + .get("error") + .and_then(Value::as_str) + .unwrap_or("sign-in failed") + .to_string(); + Err(CtlError::Server(err)) + } +} + +/// Sign in with an existing account and bind this PC to it. +pub fn account_login(cfg: &mut HostConfig, email: &str, password: &str) -> Result { + let email = email.trim().to_lowercase(); + if !email.contains('@') || password.is_empty() { + return Err(CtlError::Invalid("Enter your email and password.".into())); + } + let r = api_post( + cfg, + "/api/auth/login", + serde_json::json!({ "email": email, "password": password, "app": true }), + )?; + account_result(cfg, r) +} + +/// Create a new account (8+ char password) and bind this PC to it. +pub fn account_signup(cfg: &mut HostConfig, email: &str, password: &str) -> Result { + let email = email.trim().to_lowercase(); + if !email.contains('@') { + return Err(CtlError::Invalid("Enter a valid email.".into())); + } + if password.chars().count() < 8 { + return Err(CtlError::Invalid("Password needs 8+ characters.".into())); + } + let r = api_post( + cfg, + "/api/auth/signup", + serde_json::json!({ "email": email, "password": password, "app": true }), + )?; + account_result(cfg, r) +} + +/// Sign out: best-effort server logout, then clear the saved token + email. +pub fn account_logout(cfg: &mut HostConfig) -> Result<()> { + if !cfg.account_token.is_empty() { + let _ = api_post(cfg, "/api/auth/logout", serde_json::json!({})); + } + cfg.account_token.clear(); + cfg.account_email.clear(); + write_config(cfg) +} + +/// List this account's computers from `GET /api/devices`. +pub fn list_devices(cfg: &HostConfig) -> Result> { + if cfg.account_token.is_empty() { + return Err(CtlError::Invalid("not signed in".into())); + } + let url = format!("{}/api/devices", cfg.api_base()); + let resp = http_agent() + .get(&url) + .set("Authorization", &format!("Bearer {}", cfg.account_token)) + .call(); + match resp { + Ok(r) => { + let v: Value = r + .into_json() + .map_err(|e| CtlError::Server(format!("bad server response: {e}")))?; + let arr = v.get("devices").cloned().unwrap_or(Value::Array(vec![])); + Ok(serde_json::from_value::>(arr).unwrap_or_default()) + } + Err(ureq::Error::Status(code, r)) => Err(CtlError::Server(server_error(r, code))), + Err(ureq::Error::Transport(t)) => { + Err(CtlError::Http(format!("cannot reach server: {t}"))) + } + } +} + +/// Round-trip time (ms) to the relay's `/health`. Retries once so a single +/// dropped packet never flashes "offline" (mirrors `ping_relay`). +pub fn ping_relay(cfg: &HostConfig) -> Result { + let url = format!("{}/health", cfg.api_base()); + let mut last = String::from("ping failed"); + for _ in 0..2 { + let t0 = Instant::now(); + match http_agent().get(&url).call() { + Ok(resp) => { + let _ = resp.into_string(); + return Ok(t0.elapsed().as_millis() as u32); + } + Err(e) => last = e.to_string(), + } + } + Err(CtlError::Http(last)) +} + +// =========================================================================== +// Misc actions: copy id, open web viewer, open logs, state snapshot. +// =========================================================================== + +/// Copy this PC's 9-digit NetDesk id to the clipboard. +pub fn copy_id(cfg: &HostConfig) -> Result<()> { + let mut cb = + arboard::Clipboard::new().map_err(|e| CtlError::Os(format!("clipboard: {e}")))?; + cb.set_text(cfg.id.clone()) + .map_err(|e| CtlError::Os(format!("clipboard: {e}")))?; + Ok(()) +} + +/// Open the web viewer (`https://netdesk.space/app`) in the default browser. +pub fn open_web_viewer(cfg: &HostConfig) -> Result<()> { + open_url(&format!("{}/app", cfg.api_base())) +} + +/// Open the folder that holds the config + logs in the OS file manager. +pub fn open_logs_folder(cfg: &HostConfig) -> Result<()> { + let dir = cfg + .path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + #[cfg(windows)] + { + let mut cmd = std::process::Command::new("explorer"); + cmd.arg(&dir); + hidden(&mut cmd); + // explorer returns a non-zero exit code even on success; just launch it. + cmd.spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&dir) + .spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(&dir) + .spawn() + .map_err(|e| CtlError::Os(format!("open logs: {e}")))?; + Ok(()) + } +} + +/// A ready-to-render snapshot of this PC's identity + live state (mirrors the +/// Python `Api.get_state`). +pub fn snapshot(cfg: &HostConfig) -> HostState { + let es = engine_status(cfg); + HostState { + id: cfg.id.clone(), + name: cfg.name.clone(), + server: if cfg.server.trim().is_empty() { + DEFAULT_SERVER.to_string() + } else { + cfg.server.clone() + }, + account: cfg.account_email.clone(), + password: cfg.pw_plain.clone(), + state: es.state, + online: es.online, + in_session: es.in_session, + totp: !cfg.totp.trim().is_empty(), + privacy: cfg.privacy_on_session && cfg.allow_display_power, + lock_on_end: cfg.lock_on_end, + close_to_tray: cfg.close_to_tray, + service_installed: service_installed(), + startup: startup_present(), + config_path: cfg.path.display().to_string(), + } +} + +// =========================================================================== +// Internal helpers. +// =========================================================================== + +/// Tracks the PID of the engine this panel last spawned (Start), so the UI can +/// know it launched one. Stop kills by name+command-line regardless. +static LAST_ENGINE_PID: Mutex> = Mutex::new(None); + +// Short status caches so UI polling never spawns a subprocess every frame. +static SVC_CACHE: Mutex> = Mutex::new(None); +static STU_CACHE: Mutex> = Mutex::new(None); +static PROC_CACHE: Mutex> = Mutex::new(None); + +fn cache_get(cell: &Mutex>, max: Duration) -> Option { + if let Ok(g) = cell.lock() { + if let Some((v, t)) = *g { + if t.elapsed() < max { + return Some(v); + } + } + } + None +} +fn cache_set(cell: &Mutex>, v: bool) { + if let Ok(mut g) = cell.lock() { + *g = Some((v, Instant::now())); + } +} +fn cache_last(cell: &Mutex>) -> bool { + cell.lock().ok().and_then(|g| g.map(|(v, _)| v)).unwrap_or(false) +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +fn computer_name() -> String { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "host".to_string()) +} + +/// Locate the host engine executable: next to the current exe, then next to the +/// config (portable folder), else the bare name (resolved via PATH). +fn host_engine_exe(cfg: &HostConfig) -> PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let c = dir.join(ENGINE_EXE); + if c.is_file() { + return c; + } + } + } + if let Some(dir) = cfg.path.parent() { + let c = dir.join(ENGINE_EXE); + if c.is_file() { + return c; + } + } + PathBuf::from(ENGINE_EXE) +} + +fn dir_writable(dir: &Path) -> bool { + let probe = dir.join(".nd_write_test"); + match std::fs::write(&probe, b"") { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// Pull a `{"error": "..."}` message out of an error response body. +fn server_error(resp: ureq::Response, code: u16) -> String { + match resp.into_json::() { + Ok(v) => v + .get("error") + .and_then(Value::as_str) + .map(String::from) + .unwrap_or_else(|| format!("server error {code}")), + Err(_) => format!("server error {code}"), + } +} + +// ---- Value coercion helpers for the tolerant config loader ---------------- + +fn str_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + } +} +fn bool_of(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false), + Value::String(s) => { + let s = s.to_lowercase(); + s == "1" || s == "true" || s == "yes" + } + _ => false, + } +} +fn u32_of(v: &Value, dflt: u32) -> u32 { + match v { + Value::Number(n) => n.as_u64().map(|x| x as u32).unwrap_or(dflt), + Value::String(s) => s.parse().unwrap_or(dflt), + _ => dflt, + } +} + +// ---- Windows process / shell helpers -------------------------------------- + +#[cfg(windows)] +fn hidden(cmd: &mut std::process::Command) -> &mut std::process::Command { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW) +} +#[cfg(not(windows))] +fn hidden(cmd: &mut std::process::Command) -> &mut std::process::Command { + cmd +} + +/// Run a program hidden and capture `(exit_code, stdout)`. +fn run_capture(program: &str, args: &[&str]) -> Result<(i32, String)> { + let mut cmd = std::process::Command::new(program); + cmd.args(args); + hidden(&mut cmd); + let out = cmd + .output() + .map_err(|e| CtlError::Os(format!("{program}: {e}")))?; + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + Ok((code, stdout)) +} + +#[cfg(windows)] +fn run_ps(ps: &str) -> Result { + let (_, out) = run_capture("powershell", &["-NoProfile", "-Command", ps])?; + Ok(out) +} + +/// Escape a value for embedding inside a PowerShell single-quoted string. +#[cfg(windows)] +fn ps_single_quote(s: &str) -> String { + s.replace('\'', "''") +} + +#[cfg(windows)] +fn programdata_dir() -> PathBuf { + PathBuf::from(std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string())) + .join("NetDesk") +} + +#[cfg(windows)] +fn engine_process_running() -> bool { + if let Some(v) = cache_get(&PROC_CACHE, Duration::from_secs(5)) { + return v; + } + let ps = "if (Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | \ + Where-Object { $_.CommandLine -like '*--engine*' }) \ + { Write-Output 1 } else { Write-Output 0 }"; + let v = match run_capture("powershell", &["-NoProfile", "-Command", ps]) { + Ok((_, out)) => out.trim().ends_with('1'), + Err(_) => false, + }; + cache_set(&PROC_CACHE, v); + v +} +#[cfg(not(windows))] +fn engine_process_running() -> bool { + false +} + +/// Open a URL (or folder) in the OS default handler. +fn open_url(target: &str) -> Result<()> { + #[cfg(windows)] + { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/C", "start", "", target]); + hidden(&mut cmd); + cmd.spawn().map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(target) + .spawn() + .map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(target) + .spawn() + .map_err(|e| CtlError::Os(format!("open: {e}")))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A managed policy overrides + locks the listed fields in memory, leaves + /// everything else alone, and reports exactly the locked keys. No policy + /// file => nothing locked. + #[test] + fn managed_policy_overrides_and_locks() { + // Unique temp policy path (no Date/rand in tests; use pid + a counter). + let dir = std::env::temp_dir(); + let path = dir.join(format!("nd_policy_test_{}.json", std::process::id())); + + // No file yet -> nothing locked, config untouched. + let mut cfg = HostConfig::default(); + cfg.server = "wss://user-choice/ws".to_string(); + let locked = apply_policy_from(&mut cfg, path.clone()); + assert!(locked.is_empty()); + assert_eq!(cfg.server, "wss://user-choice/ws"); + + // Write a policy that pins the server + disables file transfer + forces + // lock-on-end, and leaves the password/name alone. + std::fs::write( + &path, + r#"{"server":"wss://corp-relay/ws","lock_on_end":true, + "perms":{"files":false,"clipboard":false},"reg_key":"CORPKEY"}"#, + ) + .unwrap(); + let mut cfg = HostConfig::default(); + cfg.server = "wss://user-choice/ws".to_string(); + cfg.name = "my-pc".to_string(); + cfg.perms.input = true; + let locked = apply_policy_from(&mut cfg, path.clone()); + + assert_eq!(cfg.server, "wss://corp-relay/ws", "policy pins the server"); + assert!(cfg.lock_on_end, "policy forces lock-on-end"); + assert!(!cfg.perms.files, "policy disables file transfer"); + assert!(!cfg.perms.clipboard, "policy disables clipboard"); + assert!(cfg.perms.input, "unlisted perm untouched"); + assert_eq!(cfg.reg_key, "CORPKEY"); + assert_eq!(cfg.name, "my-pc", "unlisted field untouched"); + // The locked set is exactly the policy's top-level keys. + assert!(locked.contains("server") && locked.contains("lock_on_end") + && locked.contains("perms") && locked.contains("reg_key")); + assert!(!locked.contains("name")); + + let _ = std::fs::remove_file(&path); + } + + /// The relay HTTP base is derived from `server` like the Python `_api_base`. + #[test] + fn api_base_derivation() { + let base = |s: &str| { + let mut c = HostConfig::default(); + c.server = s.to_string(); + c.api_base() + }; + assert_eq!(base("wss://netdesk.space/ws"), "https://netdesk.space"); + assert_eq!(base("ws://127.0.0.1:8443/ws"), "http://127.0.0.1:8443"); + assert_eq!(base("https://netdesk.space"), "https://netdesk.space"); + assert_eq!(base(""), "https://netdesk.space"); + assert_eq!(base("wss://host:9/ws/"), "https://host:9"); + } + + /// Parsing then re-serialising the real config shape preserves every field, + /// including unknown ones — so the panel never corrupts the shared identity. + #[test] + fn config_round_trip_preserves_fields() { + let src = r#"{ + "id": "123456789", + "salt": "077dcb07e5435d1bce2b4285d401225e", + "iters": 200000, + "verifier": "bf664d73be94bd4d5288d7ee8ebcbbf20182cd326eebfefd2b637a908dfac514", + "server": "wss://netdesk.space/ws", + "name": "DEVPC", + "perms": { "input": true, "clipboard": true, "files": true, "audio": true }, + "lock_on_end": false, + "privacy_on_session": false, + "allow_display_power": false, + "server_locked_by_user": true, + "account_token": "TEST-DUMMY-ACCOUNT-TOKEN", + "account_email": "owner@example.com", + "allow_ips": "", + "pw_plain": "test-password-123", + "future_unknown_field": {"keep": [1, 2, 3]} +}"#; + let cfg = parse_config(src); + assert_eq!(cfg.id, "123456789"); + assert_eq!(cfg.iters, 200_000); + assert_eq!(cfg.name, "DEVPC"); + assert_eq!(cfg.account_email, "owner@example.com"); + assert_eq!(cfg.pw_plain, "test-password-123"); + assert!(cfg.server_locked_by_user); + assert!(cfg.perms.audio); + // Unknown key must survive in `extra`. + assert!(cfg.extra.contains_key("future_unknown_field")); + + // Re-serialise and re-parse: nothing lost. + let out = serde_json::to_string_pretty(&cfg).unwrap(); + let again = parse_config(&out); + assert_eq!(again.id, cfg.id); + assert_eq!(again.verifier, cfg.verifier); + assert_eq!(again.salt, cfg.salt); + assert_eq!(again.account_token, cfg.account_token); + assert_eq!(again.pw_plain, cfg.pw_plain); + assert!(again.extra.contains_key("future_unknown_field")); + } + + /// End-to-end proof that the native PBKDF2 verifier matches the real host + /// config byte-for-byte: PBKDF2-HMAC-SHA256(pw, salt, 200000, 32) over the + /// sample config's salt + `pw_plain` must equal its stored `verifier`. + #[test] + fn verifier_matches_real_config() { + // hex 077dcb07e5435d1bce2b4285d401225e + let salt: [u8; 16] = [ + 0x07, 0x7d, 0xcb, 0x07, 0xe5, 0x43, 0x5d, 0x1b, 0xce, 0x2b, 0x42, 0x85, 0xd4, 0x01, + 0x22, 0x5e, + ]; + let got = netdesk_core::crypto::verifier_hex("test-password-123", &salt, 200_000); + assert_eq!( + got, + "bf664d73be94bd4d5288d7ee8ebcbbf20182cd326eebfefd2b637a908dfac514" + ); + } + + /// A tolerant load must still recover identity when a field has an odd type + /// (here `iters` as a string) instead of discarding the whole config. + #[test] + fn lenient_parse_recovers_identity() { + let src = r#"{ "id": "900000001", "iters": "200000", "verifier": "ab", "server": "x" }"#; + let cfg = parse_config(src); + assert_eq!(cfg.id, "900000001"); + assert_eq!(cfg.iters, 200_000); + assert_eq!(cfg.verifier, "ab"); + } + + // ---- engine_status: status-file reading ------------------------------ + + /// Write a `gui_status.json` into a fresh temp dir and return a `HostConfig` + /// whose `path` sits in that dir (so the dir is candidate #1 of + /// `status_dirs`). `age`/`id`/`state` let each test shape the heartbeat. + fn cfg_with_status(tag: &str, id: &str, state: &str, age_secs: f64, in_session: bool) -> HostConfig { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("nd_ctl_{tag}_{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + let ts = now_secs() - age_secs; + let body = serde_json::json!({ + "state": state, "id": id, "name": "T", + "server": "wss://x/ws", "in_session": in_session, + "ts": ts, "pid": 1 + }); + std::fs::write(dir.join("gui_status.json"), body.to_string()).unwrap(); + let mut c = HostConfig::default(); + c.id = id.to_string(); + c.account_token = String::new(); // never touch the network in these tests + c.path = dir.join("config.json"); + c + } + + /// A fresh "online" heartbeat sitting next to the config is seen as online — + /// the core of the fix (the dir is resolved from the config, not hard-coded). + #[test] + fn freshest_status_reads_fresh_online() { + let cfg = cfg_with_status("fresh", "811000001", "online", 2.0, false); + let got = freshest_status(&cfg).expect("fresh heartbeat should be found"); + assert_eq!(got.1, "online"); + assert!(!got.2); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A heartbeat older than 12 s is ignored (engine gone / status stale). + #[test] + fn freshest_status_ignores_stale() { + let cfg = cfg_with_status("stale", "811000002", "online", 60.0, false); + assert!(freshest_status(&cfg).is_none(), "stale heartbeat must be ignored"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh heartbeat that names a DIFFERENT id is not this machine's status. + #[test] + fn freshest_status_ignores_other_id() { + let mut cfg = cfg_with_status("otherid", "999000003", "online", 2.0, false); + // Ask about a different id than the file carries. + cfg.id = "811000003".to_string(); + assert!(freshest_status(&cfg).is_none(), "another device's status must be ignored"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh "error: already connected elsewhere" heartbeat (stale-duplicate + /// noise) is treated as online when a local heartbeat is present. + #[test] + fn already_connected_maps_to_online() { + let cfg = cfg_with_status( + "aconn", + "811000004", + "error: already connected elsewhere", + 2.0, + false, + ); + let es = engine_status_local(&cfg); + assert!(es.online, "already-connected heartbeat should read online"); + assert_eq!(es.state, "online"); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + /// A fresh "session" heartbeat reports online AND in_session. + #[test] + fn session_state_sets_in_session() { + let cfg = cfg_with_status("sess", "811000005", "session", 2.0, true); + let es = engine_status_local(&cfg); + assert!(es.online); + assert!(es.in_session); + let _ = std::fs::remove_dir_all(cfg.path.parent().unwrap()); + } + + // ---- engine_status: relay online flag (detection c) ------------------ + + /// The relay-online cache round-trips per id and respects the online flag, + /// so `engine_status` can honor `/api/devices` without a blocking call. + #[test] + fn server_online_cache_roundtrips() { + let id = "822000101"; + note_server_online(id, true); + assert!(server_says_online(id)); + // A devices list without this id online clears it. + let devs = vec![Device { id: id.to_string(), online: false, ..Default::default() }]; + note_server_devices(id, &devs); + assert!(!server_says_online(id)); + // And an online device in the list sets it. + let devs = vec![Device { id: id.to_string(), online: true, ..Default::default() }]; + note_server_devices(id, &devs); + assert!(server_says_online(id)); + // A different id is unaffected by this one's flag. + assert!(!server_says_online("822000102")); + } + + /// LIVE check (ignored by default). With the engine running and + /// `NETDESK_CONFIG` pointed at its config, `engine_status` must read online. + /// cargo test -p netdesk-viewer -- --ignored --nocapture live_engine_online + #[test] + #[ignore] + fn live_engine_online() { + let cfg = read_config().expect("config"); + let mut es = engine_status(&cfg); + // Give the background relay refresh one moment to land, in case the + // local heartbeat dir isn't visible to this process. + for _ in 0..4 { + if es.online { + break; + } + std::thread::sleep(Duration::from_millis(1500)); + es = engine_status(&cfg); + } + eprintln!( + "LIVE id={} online={} state={} in_session={} config={}", + cfg.id, es.online, es.state, es.in_session, cfg.path.display() + ); + assert!(es.online, "engine_status should be online; state={}", es.state); + } +} diff --git a/native/viewer/src/main.rs b/native/viewer/src/main.rs new file mode 100644 index 0000000..0214d29 --- /dev/null +++ b/native/viewer/src/main.rs @@ -0,0 +1,4653 @@ +//! NetDesk native viewer — the branded, full-feature in-session client. +//! +//! GPU-filtered remote-desktop viewer built on `eframe` + `egui` (winit + glow), +//! matching the NetDesk web viewer feature-for-feature at RustDesk/TeamViewer +//! quality. The decoded H.264 frame is uploaded to an egui texture with bilinear +//! (`TextureOptions::LINEAR`) filtering so scaling stays smooth AND clear; a real +//! egui menu-bar + toolbar + dockable panels sit over the image. +//! +//! Two launch modes: +//! * No args -> a branded dark CONNECT screen (logo + Partner ID + password). +//! * `netdesk-viewer [ws-url] [totp]` -> connect immediately. +//! +//! In-session parity with the web viewer (server/web/app.html + app.js): +//! Menu bar : Actions · View · Communicate · Extras · Help(About) +//! Toolbar : monitor / quality selectors, view-only, clipboard sync, chat, +//! power+processes, privacy, record, fullscreen, monitor cycle, +//! live stats, disconnect, collapse tab. +//! Panels : Chat, Remote power (power actions + run-command + processes + +//! lock-on-end), File transfer (remote browser: navigate, upload, +//! download, new folder, rename, delete, with live progress). +//! Dialogs : About, Session information, confirmations. +//! +//! Every control is wired to the `netdesk_core::Session` control protocol. +//! +//! Developed by Akronlogic (https://akronlogic.com). + +// Ship as a GUI app: in release builds no black console window pops up next to +// the window. Debug builds keep the console so logs are visible while developing. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::collections::VecDeque; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use eframe::egui; +use egui_phosphor::regular as ph; +use egui::{ + Align, Color32, Key, Layout, Margin, Modifiers, Pos2, Rect, RichText, Sense, + Rounding, Stroke, TextureOptions, Vec2, +}; + +use netdesk_core::decode::{best_decoder, best_decoder_for}; +use netdesk_core::signaling::{FileEntry, MonitorInfo, UPLOAD_CHUNK}; +use netdesk_core::{connect, ConnectConfig, Session, SessionEvent}; + +use tokio::sync::mpsc; + +/// HOST-CONTROL backend (native port of the Python agent's `Api`): reads/writes +/// the shared `config.json`, reports engine status, starts/stops the engine, +/// manages the unattended password / 2FA / switches, and signs in to the relay. +/// Wired here so it compiles into the crate; the stage-2 host panel UI calls it. +mod host_ctl; + +/// Remote audio playback (Opus decode + cpal output). Windows-only for now — +/// the vendored libopus in netdesk-opus targets the x86_64-pc-windows-gnu ABI. +#[cfg(windows)] +mod audio; + +/// HOME / HOST PANEL (stage-2 native UI): the enterprise management console the +/// app launches into. It controls the engine through `host_ctl` and, on a +/// partner connect, hands routing back to the viewer. +mod home; + +/// Mouse-button bitmask bits understood by the host. +const BTN_LEFT: u32 = 1; +const BTN_RIGHT: u32 = 2; +const BTN_MIDDLE: u32 = 4; + +/// The NetDesk logo (green mark, 256px). Embedded so the window icon and the +/// in-app logo work from a single-file portable exe (no assets dir at runtime). +const LOGO_PNG: &[u8] = include_bytes!("../../assets/icon.png"); + +// ----- NetDesk brand palette (dark premium theme) --------------------------- +const C_BG: Color32 = Color32::from_rgb(11, 15, 21); // #0B0F15 page background +const C_PANEL: Color32 = Color32::from_rgb(18, 24, 33); // card / window +const C_BAR: Color32 = Color32::from_rgb(15, 20, 28); // toolbar +const C_FIELD: Color32 = Color32::from_rgb(26, 33, 44); // input / inactive widget +const C_HOVER: Color32 = Color32::from_rgb(34, 43, 56); +const C_STROKE: Color32 = Color32::from_rgb(38, 47, 60); +// c_accent() / c_accent_hi() are now runtime: c_accent() / c_accent_hi() (brand.json). +const C_TEXT: Color32 = Color32::from_rgb(223, 231, 240); +const C_MUTED: Color32 = Color32::from_rgb(139, 150, 166); +const C_DANGER: Color32 = Color32::from_rgb(220, 60, 60); + +/// Quality presets — identical fps/bitrate(kbps)/scale to the web viewer QUALITY. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Quality { + Min, + Low, + Balanced, + High, + Extreme, +} +impl Quality { + const ALL: [Quality; 5] = [ + Quality::Min, + Quality::Low, + Quality::Balanced, + Quality::High, + Quality::Extreme, + ]; + fn params(self) -> (u32, u32, f32) { + match self { + Quality::Min => (8, 700, 1.0), + Quality::Low => (15, 2500, 1.0), + Quality::Balanced => (30, 6000, 1.0), + Quality::High => (30, 12000, 1.0), + Quality::Extreme => (60, 20000, 1.0), + } + } + fn label(self) -> &'static str { + match self { + Quality::Min => "Min · 8fps", + Quality::Low => "Low · 15fps · 2 Mbps", + Quality::Balanced => "Balanced · 30fps · 6 Mbps", + Quality::High => "High · 30fps · 12 Mbps", + Quality::Extreme => "Extreme · 60fps · 20 Mbps", + } + } + fn short(self) -> &'static str { + match self { + Quality::Min => "Min", + Quality::Low => "Low", + Quality::Balanced => "Balanced", + Quality::High => "High", + Quality::Extreme => "Extreme", + } + } +} + +/// Canvas scaling mode (client-only; mirrors the web viewer's Best-fit / 1:1). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ScaleMode { + Fit, + Orig, +} + +/// A decoded frame, packed RGB8 (w*h*3 bytes), plus the instant its access unit +/// arrived (used for a best-effort pipeline-latency readout). +struct Frame { + w: u32, + h: u32, + rgb: Vec, + recv_at: Instant, +} + +/// Live stats + lifecycle, written by the net thread, read by the UI thread. +#[derive(Default, Clone)] +struct NetStats { + fps: f32, + mbps: f32, + res_w: u32, + res_h: u32, + e2e: bool, + host_name: String, + connected: bool, + status: String, + ended: Option, + /// Direct-P2P fast path active (media bypasses the relay over a WebRTC + /// DataChannel). Set from `SessionEvent::P2pState`; drives the path badge. + p2p: bool, +} + +/// One process row from a `sys.res` `kind:"processes"` reply. +#[derive(Clone)] +struct ProcInfo { + pid: u32, + name: String, + mem_kb: u64, +} + +/// A chat line in the transcript. +#[derive(Clone)] +struct ChatLine { + who: String, + text: String, + mine: bool, +} + +/// Parsed `session.info` (web viewer's `onInfo`). +#[derive(Default, Clone)] +struct SessionInfo { + w: u32, + h: u32, + mon: i32, + monitors: Vec, + fps: u32, + bitrate: u32, + host_name: String, + e2e: bool, + audio: bool, + lock_on_end: bool, + privacy_active: bool, + logon_screen: bool, + clipboard_allowed: bool, + files_allowed: bool, +} + +/// Control-protocol facts the net thread hands the UI thread each frame. +enum UiEvent { + Chat { from: String, text: String }, + Clipboard(String), + /// Remote cursor bitmap decoded to RGBA on the net thread; the UI uploads it + /// to a texture (shapes change rarely, so this is cheap). + CursorShape { rgba: Vec, w: u32, h: u32, hx: f32, hy: f32 }, + Info(SessionInfo), + Processes(Vec), + Modes { + items: Vec<(u32, u32)>, + current: Option<(u32, u32)>, + }, + /// privacy / block_input / wallpaper toggle echo. + Toggle { + kind: String, + on: bool, + ok: bool, + msg: String, + }, + /// run / kill / cad / setmode and any other generic result. + Generic { kind: String, ok: bool, msg: String }, + + // ---- file transfer (mirrors the web viewer's Files panel) ------------- + /// A remote directory listing arrived (host `file.roots.res` / `file.list.res`). + /// `path` is `""` for the drives/home listing. + FileList { path: String, entries: Vec }, + /// Bytes transferred so far for transfer `fid` (downloads: emitted by the + /// core per 0xf1 chunk; uploads: emitted by our upload worker per chunk). + FileProgress { fid: u32, done: u64 }, + /// A new transfer appeared and should be tracked in the panel. Emitted by + /// the upload worker (downloads add their row synchronously on click). + TransferStart { fid: u32, name: String, total: u64, up: bool }, + /// The upload worker has streamed every byte of `fid` to the host (the host + /// still confirms with `file.ok`). + UploadSent { fid: u32 }, + /// A download finished and was written to disk at `path`. + FileSaved { fid: u32, name: String, path: String, size: u64 }, + /// A transfer with a known `fid` failed (e.g. a local save error). + FileFail { fid: u32, msg: String }, + /// Host `file.ok` for an upload / mkdir / delete / rename. `refresh` ⇒ + /// re-list the current folder. + FileOk { msg: String, refresh: bool }, + /// Host `file.err` (no fid): shown on the panel's status line. + FileErr { msg: String }, +} + +/// Remote mouse-cursor position (frame-pixel coords), updated by the net thread +/// and read each frame by the UI to draw the overlay. +#[derive(Default, Clone, Copy)] +struct RemoteCursor { + x: f32, + y: f32, + visible: bool, +} + +/// Remote cursor bitmap uploaded to a GPU texture, plus its hotspot in bitmap px. +struct CursorTex { + tex: egui::TextureHandle, + hx: f32, + hy: f32, +} + +/// Shared between the net/decode thread and the egui UI thread. +struct Shared { + frame: Mutex>, + stats: Mutex, + inbox: Mutex>, + cursor: Mutex, + /// Active session recording: the net thread tees received frames into it and + /// (codec-aware) creates it on the first keyframe; the UI arms + finalizes it. + recorder: Mutex>, + /// Record armed (UI Rec on). The net thread creates the recorder on the next + /// keyframe for whatever codec is live (VP9 -> .webm, H.264 -> .mp4). + recording: AtomicBool, + /// Pending recording target set by the UI: (path stem, w, h, want_audio). + rec_pending: Mutex>, + /// "Play remote computer sound" toggle. The UI thread sets it; the net + /// thread reads it to decide whether to decode + play incoming Opus audio. + sound_on: AtomicBool, + /// Voice call: Some(stop) while the local-mic capture thread is running + /// (viewer mic -> Opus -> Session::voice -> host). Toggling off sets the + /// stop flag, which ends the thread. + voice_stop: Mutex>>, + /// Set by the UI when the user deliberately ends this session (Disconnect / + /// Home / starting a new session). The net thread checks it so a USER + /// disconnect never triggers the auto-reconnect loop — only an unexpected + /// drop does. + stop: AtomicBool, +} + +/// A live recording target: H.264 -> fragmented MP4, or VP9(+Opus) -> WebM. Both +/// muxers remux the received frames with NO re-encode. +enum SessionRec { + Mp4(netdesk_core::recorder::Mp4Recorder), + Webm(netdesk_core::webm::WebmRecorder), +} +impl SessionRec { + fn push_video(&mut self, data: &[u8], keyframe: bool, ts_ms: u64) { + let _ = match self { + SessionRec::Mp4(r) => r.push(data, keyframe, ts_ms), + SessionRec::Webm(r) => r.push_video(data, keyframe, ts_ms), + }; + } + fn push_audio(&mut self, data: &[u8], ts_ms: u64) { + if let SessionRec::Webm(r) = self { + let _ = r.push_audio(data, ts_ms); + } + } + fn finish(self) -> netdesk_core::Result<(PathBuf, u64)> { + match self { + SessionRec::Mp4(r) => r.finish(), + SessionRec::Webm(r) => r.finish(), + } + } +} + +impl Shared { + fn new() -> Arc { + Arc::new(Shared { + frame: Mutex::new(None), + stats: Mutex::new(NetStats::default()), + inbox: Mutex::new(Vec::new()), + cursor: Mutex::new(RemoteCursor::default()), + recorder: Mutex::new(None), + recording: AtomicBool::new(false), + rec_pending: Mutex::new(None), + sound_on: AtomicBool::new(true), + voice_stop: Mutex::new(None), + stop: AtomicBool::new(false), + }) + } + fn push(&self, ev: UiEvent) { + self.inbox.lock().unwrap().push(ev); + } +} + +/// A queued destructive action awaiting a Yes/Cancel confirmation. +#[derive(Clone)] +enum Confirm { + Reboot, + Shutdown, + Logoff, + Kill(u32, String), + SetMode(u32, u32), + /// Delete a remote file or folder (the web viewer's `confirm("Delete …")`). + DeleteFile { path: String, is_dir: bool, name: String }, +} + +/// Lifecycle of one file transfer shown in the Files panel. +#[derive(Clone)] +enum TransferState { + Active, + Done, + Failed(String), +} + +/// One in-flight or finished transfer, rendered with a progress bar. `total` +/// is the expected byte count (from the listing for a download, the local file +/// size for an upload); `done` is bytes transferred so far. +#[derive(Clone)] +struct Transfer { + fid: u32, + name: String, + total: u64, + done: u64, + up: bool, + state: TransferState, + detail: String, +} + +/// A small text-input modal for the Files panel (egui has no `prompt()`), used +/// for "New folder" and "Rename" — the web viewer's `prompt(...)` calls. +#[derive(Clone)] +enum FilePrompt { + Mkdir { name: String }, + Rename { src: String, name: String }, +} + +/// App-level route. The app launches in `Home` (the host panel); entering a +/// Partner ID + Connect moves to `Connecting` (the branded connect flow) and +/// then `Session` (the full viewer). A disconnect returns to `Home`. +#[derive(PartialEq, Clone, Copy)] +enum Route { + Home, + Connecting, + Session, +} + +/// Headless host/service mode: run the native Rust host engine in-process with +/// no window (unattended hosting, like RustDesk's service mode). Uses the +/// resolved config (honours `$NETDESK_CONFIG`). Runs until the process is +/// killed. This is the same in-process engine the GUI's Start Service uses. +fn run_headless_host() -> anyhow::Result<()> { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).try_init(); + let cfg = host_ctl::read_config().map_err(|e| anyhow::anyhow!("read config: {e}"))?; + let server = if cfg.server.trim().is_empty() { + host_ctl::DEFAULT_SERVER + } else { + cfg.server.trim() + }; + println!( + "NetDesk host (headless) — id {} via {}", + cfg.id, server + ); + if std::env::var("NETDESK_HOST_DEBUG").is_ok() { + // Never print the password itself, even in debug — only its presence. + eprintln!( + "DEBUG config_path={} id={:?} pw_len={} verifier_present={}", + cfg.path.display(), + cfg.id, + cfg.pw_plain.len(), + !cfg.verifier.is_empty() + ); + } + host_ctl::start_service(&cfg).map_err(|e| anyhow::anyhow!("start host: {e}"))?; + loop { + std::thread::sleep(Duration::from_secs(2)); + #[cfg(windows)] + if let Some(s) = host_ctl::native_engine_status() { + println!( + "host: registered={} viewer_connected={} e2e={} encoder=\"{}\"{}", + s.registered, + s.viewer_connected, + s.e2e, + s.encoder, + s.last_error + .as_ref() + .map(|e| format!(" error={e}")) + .unwrap_or_default() + ); + } + } +} + +/// Enable "admin mode": register a per-logon, highest-privilege Scheduled Task +/// so the NetDesk host runs with a full Administrator token — `run` commands +/// then execute as Administrator with no per-command UAC prompt, and the host +/// still captures the real interactive desktop. Self-elevates via one UAC +/// prompt, then drops the plain per-user autostart so only one host launches. +fn install_admin_task() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + println!("requesting elevation — accept the Windows UAC prompt to enable admin mode…"); + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--install-admin-task' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let tr = format!("\"{exe_s}\" --host"); + let ok = std::process::Command::new("schtasks") + .args(["/create", "/tn", "NetDesk", "/tr", &tr, "/sc", "onlogon", "/rl", "highest", "/f"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + // Only the elevated task should start the host; drop the plain + // HKCU autostart so two hosts never fight over the screen/id. + let _ = std::process::Command::new("reg") + .args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/f"]) + .status(); + // Make UAC prompts approvable over a remote session, and allow a + // software-generated Ctrl+Alt+Del — the rest of "full admin". + let sys = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System"; + let _ = std::process::Command::new("reg") + .args(["add", sys, "/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", "0", "/f"]) + .status(); + let _ = std::process::Command::new("reg") + .args(["add", sys, "/v", "SoftwareSASGeneration", "/t", "REG_DWORD", "/d", "1", "/f"]) + .status(); + // Swap the running (standard) host for the elevated task NOW: stop + // every other NetDesk host (not this installer process), then start + // the task so capture resumes elevated without a sign-out. + let self_pid = std::process::id(); + let kill = format!( + "Get-Process NetDesk -ErrorAction SilentlyContinue | Where-Object {{ $_.Id -ne {self_pid} }} | Stop-Process -Force -ErrorAction SilentlyContinue", + ); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &kill]) + .status(); + let _ = std::process::Command::new("schtasks") + .args(["/run", "/tn", "NetDesk"]) + .status(); + println!("admin mode ENABLED — NetDesk now runs as Administrator:"); + println!(" • run CMD/PowerShell execute elevated (no per-command UAC)"); + println!(" • UAC prompts appear on the desktop, approvable over a remote session"); + println!(" • Ctrl+Alt+Del available; starts elevated at every logon"); + } else { + println!("admin task registration FAILED — run this elevated."); + } + } + #[cfg(not(windows))] + { + eprintln!("admin task is Windows-only"); + } + Ok(()) +} + +/// Disable admin mode: remove the elevated task and restore the plain autostart. +fn uninstall_admin_task() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--uninstall-admin-task' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let _ = std::process::Command::new("schtasks") + .args(["/delete", "/tn", "NetDesk", "/f"]) + .status(); + let run = format!("\"{exe_s}\" --host"); + let _ = std::process::Command::new("reg") + .args(["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/t", "REG_SZ", "/d", &run, "/f"]) + .status(); + println!("admin mode disabled; restored standard autostart."); + } + #[cfg(not(windows))] + { + eprintln!("admin task is Windows-only"); + } + Ok(()) +} + +/// Install UNATTENDED access: a LocalSystem service that captures the lock / +/// sign-in screen so the machine can be signed in and unlocked remotely with +/// nobody present. Self-elevates (one UAC), supersedes the user-session host + +/// admin task (the service owns the host), makes UAC remotely approvable, allows +/// software Ctrl+Alt+Del, and keeps the PC from sleeping on AC power. +/// Check the server's release manifest and, with `--apply`, download + verify + +/// launch the installer. Verified (sha256) before anything runs — RustDesk-style +/// safe self-update. Headless + bounded; reports clearly and never installs +/// unverified bytes. +fn check_update(apply: bool) -> anyhow::Result<()> { + use netdesk_core::update; + use std::io::Read as _; + let base = { + // Derive the https base from the configured server (fall back to default). + let cfg = host_ctl::read_config().unwrap_or_default(); + cfg.api_base() + }; + let manifest_url = format!("{base}/download/version.json"); + println!("[update] current v{}; checking {manifest_url}", netdesk_core::VERSION); + let body = ureq::get(&manifest_url) + .timeout(std::time::Duration::from_secs(15)) + .call() + .map_err(|e| anyhow::anyhow!("fetch manifest: {e}"))? + .into_string()?; + let m = update::parse_manifest(&body)?; + if !update::update_available(netdesk_core::VERSION, &m) { + println!("[update] up to date (latest v{}).", m.version); + return Ok(()); + } + println!("[update] NEW v{} available: {}", m.version, m.notes); + if !apply { + println!("[update] run with --apply to download + verify + install."); + return Ok(()); + } + println!("[update] downloading {} …", m.url); + let mut buf = Vec::new(); + ureq::get(&m.url) + .timeout(std::time::Duration::from_secs(600)) + .call() + .map_err(|e| anyhow::anyhow!("download: {e}"))? + .into_reader() + .read_to_end(&mut buf)?; + if !update::verify_sha256(&buf, &m.sha256) { + return Err(anyhow::anyhow!( + "sha256 mismatch — refusing to install ({} bytes)", + buf.len() + )); + } + println!("[update] sha256 verified ({} bytes).", buf.len()); + // Write to a temp file and launch the installer (NSIS). Silent /S. + let tmp = std::env::temp_dir().join("NetDesk-Setup-update.exe"); + std::fs::write(&tmp, &buf)?; + #[cfg(windows)] + { + match std::process::Command::new(&tmp).arg("/S").spawn() { + Ok(_) => println!("[update] launched installer ({}). It will update NetDesk.", tmp.display()), + Err(e) => println!("[update] saved to {} but could not launch ({e}); run it manually.", tmp.display()), + } + } + #[cfg(not(windows))] + println!("[update] saved verified artifact to {}", tmp.display()); + Ok(()) +} + +fn install_unattended() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + println!("requesting elevation — accept the Windows UAC prompt to install unattended access…"); + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--install-service' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + // The service owns the host now: stop the standard host + its autostarts. + let self_pid = std::process::id(); + let kill = format!( + "Get-Process NetDesk -ErrorAction SilentlyContinue | Where-Object {{ $_.Id -ne {self_pid} }} | Stop-Process -Force -ErrorAction SilentlyContinue", + ); + let _ = std::process::Command::new("powershell").args(["-NoProfile", "-Command", &kill]).status(); + let _ = std::process::Command::new("reg") + .args(["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/f"]).status(); + let _ = std::process::Command::new("schtasks").args(["/delete", "/tn", "NetDesk", "/f"]).status(); + // Make UAC remotely approvable + allow software Ctrl+Alt+Del. + let sys = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System"; + let _ = std::process::Command::new("reg").args(["add", sys, "/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", "0", "/f"]).status(); + let _ = std::process::Command::new("reg").args(["add", sys, "/v", "SoftwareSASGeneration", "/t", "REG_DWORD", "/d", "1", "/f"]).status(); + // Keep the PC reachable: never sleep/hibernate on AC. + let _ = std::process::Command::new("powercfg").args(["/change", "standby-timeout-ac", "0"]).status(); + let _ = std::process::Command::new("powercfg").args(["/change", "hibernate-timeout-ac", "0"]).status(); + match netdesk_host::service::install_service(&exe_s) { + Ok(()) => { + println!("UNATTENDED ACCESS installed — the NetDesk service runs as SYSTEM and starts at boot."); + println!("It captures the lock + sign-in screen, so you can sign in and unlock this PC remotely"); + println!("with nobody present. Test it: lock the screen, then reconnect from your viewer."); + } + Err(e) => println!("service install FAILED: {e}"), + } + } + #[cfg(not(windows))] + { + eprintln!("unattended service is Windows-only"); + } + Ok(()) +} + +/// Remove the unattended service and restore the standard user-session autostart. +fn uninstall_unattended() -> anyhow::Result<()> { + #[cfg(windows)] + { + let exe = std::env::current_exe()?; + let exe_s = exe.display().to_string(); + if !netdesk_host::sys::is_elevated() { + let ps = format!( + "Start-Process -FilePath '{}' -ArgumentList '--uninstall-service' -Verb RunAs", + exe_s.replace('\'', "''") + ); + std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .status()?; + return Ok(()); + } + let _ = netdesk_host::service::uninstall_service(); + // Restore the plain per-user autostart. + let run = format!("\"{exe_s}\" --host"); + let _ = std::process::Command::new("reg") + .args(["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "NetDesk", "/t", "REG_SZ", "/d", &run, "/f"]).status(); + println!("unattended service removed; standard autostart restored."); + } + #[cfg(not(windows))] + { + eprintln!("unattended service is Windows-only"); + } + Ok(()) +} + +/// Value following `flag` in the argument list (`--server wss://…` -> the url). +fn flag_val(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .cloned() +} + +/// Unattended config injection: write `config.json` non-interactively from +/// command-line flags, for MSI / GPO / scripted enterprise deployment. An admin +/// bakes in the relay, registration key and unattended password without ever +/// opening the UI. `read_config_at` guarantees a valid id + verifier exist; each +/// flag overrides a specific field. `--password` is hashed (PBKDF2), never stored +/// in plaintext. Usage: +/// NetDesk.exe --config [--path ] [--server ] [--id ] +/// [--name ] [--reg-key ] [--allow-ips ] [--totp ] +/// [--password ] [--run] +#[cfg(windows)] +fn apply_cli_config(args: &[String]) -> anyhow::Result<()> { + let path = flag_val(args, "--path") + .map(std::path::PathBuf::from) + .unwrap_or_else(host_ctl::config_path); + let mut cfg = host_ctl::read_config_at(path.clone()) + .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?; + let mut changed: Vec<&str> = Vec::new(); + if let Some(s) = flag_val(args, "--server") { + cfg.server = s; + cfg.server_locked_by_user = true; + changed.push("server"); + } + if let Some(id) = flag_val(args, "--id") { + cfg.id = id; + changed.push("id"); + } + if let Some(n) = flag_val(args, "--name") { + cfg.name = n; + changed.push("name"); + } + if let Some(k) = flag_val(args, "--reg-key") { + cfg.reg_key = k; + changed.push("reg_key"); + } + if let Some(ips) = flag_val(args, "--allow-ips") { + cfg.allow_ips = ips; + changed.push("allow_ips"); + } + if let Some(t) = flag_val(args, "--totp") { + cfg.totp = t; + changed.push("totp"); + } + if let Some(pw) = flag_val(args, "--password") { + host_ctl::set_unattended_password(&mut cfg, &pw) + .map_err(|e| anyhow::anyhow!("set password: {e}"))?; + changed.push("password"); + } + host_ctl::write_config(&cfg).map_err(|e| anyhow::anyhow!("write config: {e}"))?; + println!( + "NetDesk: wrote {} (id={}, server={}, set: [{}])", + cfg.path.display(), + cfg.id, + if cfg.server.is_empty() { + "" + } else { + cfg.server.as_str() + }, + changed.join(", ") + ); + Ok(()) +} + +/// The white-label self-config encoded in THIS executable's own filename +/// (`NetDesk-server=...,key=....exe`). Empty if the name carries nothing. +#[cfg(windows)] +fn exe_filename_config() -> netdesk_core::brand::FilenameConfig { + std::env::current_exe() + .ok() + .and_then(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }) + .map(|stem| netdesk_core::brand::parse_filename_config(&stem)) + .unwrap_or_default() +} + +/// The optional `brand.json` manifest dropped next to the exe (full white-label: +/// name/vendor/accent/logo/server). Loaded + cached once. +#[cfg(windows)] +fn brand_manifest() -> Option { + use std::sync::OnceLock; + static M: OnceLock> = OnceLock::new(); + M.get_or_init(|| { + let exe = std::env::current_exe().ok()?; + let s = std::fs::read_to_string(exe.parent()?.join("brand.json")).ok()?; + Some(netdesk_core::brand::parse_brand_manifest(&s)) + }) + .clone() +} + +/// The brand logo bytes from the manifest's `logo` PNG (next to the exe), if set. +#[cfg(windows)] +fn brand_logo_bytes() -> Option> { + let m = brand_manifest()?; + if m.logo.is_empty() { + return None; + } + let exe = std::env::current_exe().ok()?; + std::fs::read(exe.parent()?.join(&m.logo)).ok() +} + +/// Apply the filename self-config to config.json (default location, or +/// `path_override` for testing): a reseller / MSP ships a pre-pointed client by +/// renaming the exe — no rebuild. The server is locked (the reseller's choice); +/// key/id/name fill in. Returns whether anything changed. Runs on every launch. +#[cfg(windows)] +fn apply_filename_selfconfig(path_override: Option) -> anyhow::Result { + let fc = exe_filename_config(); + if !fc.has_config() { + return Ok(false); + } + let path = path_override.unwrap_or_else(host_ctl::config_path); + let mut cfg = host_ctl::read_config_at(path.clone()) + .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?; + let mut changed = false; + if let Some(s) = fc.server { + if cfg.server != s { + cfg.server = s; + cfg.server_locked_by_user = true; + changed = true; + } + } + if let Some(k) = fc.key { + if cfg.reg_key != k { + cfg.reg_key = k; + changed = true; + } + } + // id identifies this host — only set it when none exists yet (never clobber + // an established identity). The reseller-chosen display name overrides. + if let Some(id) = fc.id { + if cfg.id.is_empty() { + cfg.id = id; + changed = true; + } + } + if let Some(n) = fc.name { + if cfg.name != n { + cfg.name = n; + changed = true; + } + } + if changed { + host_ctl::write_config(&cfg).map_err(|e| anyhow::anyhow!("write config: {e}"))?; + } + Ok(changed) +} + +/// The resolved product name shown in the UI. A white-labeled client (renamed +/// `AcmeRemote-server=....exe`) shows its brand; otherwise "NetDesk". Set once +/// at startup; task/registry/app-id identities stay "NetDesk". +#[cfg(windows)] +static BRAND_NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn brand_name() -> String { + #[cfg(windows)] + { + BRAND_NAME + .get() + .cloned() + .unwrap_or_else(|| netdesk_core::BRAND.to_string()) + } + #[cfg(not(windows))] + { + netdesk_core::BRAND.to_string() + } +} + +/// Nudge a colour brighter (+) or darker (-) by `d` per channel (clamped). +fn shade(c: Color32, d: i16) -> Color32 { + let a = |v: u8| (v as i16 + d).clamp(0, 255) as u8; + Color32::from_rgb(a(c.r()), a(c.g()), a(c.b())) +} + +/// The brand accent — from a brand.json `accent`, else the default NetDesk +/// green. Runtime (read per use) so a white-labeled client recolours without a +/// rebuild. `_hi`/`_lo` are the brighter/darker variants (hover, gradient ends). +fn c_accent() -> Color32 { + #[cfg(windows)] + if let Some((r, g, b)) = brand_manifest().and_then(|m| m.accent_rgb()) { + return Color32::from_rgb(r, g, b); + } + Color32::from_rgb(34, 197, 94) // #22C55E +} +fn c_accent_hi() -> Color32 { + #[cfg(windows)] + if let Some(m) = brand_manifest() { + if let Some((r, g, b)) = m.accent_hi_rgb() { + return Color32::from_rgb(r, g, b); + } + if let Some((r, g, b)) = m.accent_rgb() { + return shade(Color32::from_rgb(r, g, b), 14); + } + } + Color32::from_rgb(45, 212, 110) +} +fn c_accent_lo() -> Color32 { + #[cfg(windows)] + if let Some((r, g, b)) = brand_manifest().and_then(|m| m.accent_rgb()) { + return shade(Color32::from_rgb(r, g, b), -14); + } + Color32::from_rgb(22, 167, 101) +} + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // White-label: a renamed exe self-configures from its filename. The + // `--selfconfig ` hook applies it to a given file + exits (headless + // test); the normal launch applies it to config.json below. + #[cfg(windows)] + if args.iter().any(|a| a == "--selfconfig") { + let path = flag_val(&args, "--selfconfig").map(std::path::PathBuf::from); + let fc = exe_filename_config(); + let changed = apply_filename_selfconfig(path)?; + println!( + "selfconfig: brand={} server={} key={} changed={}", + fc.brand.as_deref().unwrap_or(""), + fc.server.as_deref().unwrap_or(""), + fc.key.as_deref().unwrap_or(""), + changed + ); + return Ok(()); + } + // Normal launch: point a renamed client at the reseller's relay (no-op when + // the exe isn't renamed with encoded settings). + #[cfg(windows)] + { + let _ = apply_filename_selfconfig(None); + // Product name: brand.json manifest > filename brand prefix > "NetDesk". + let _ = BRAND_NAME.set( + brand_manifest() + .map(|m| m.name) + .filter(|s| !s.is_empty()) + .or_else(|| exe_filename_config().brand) + .unwrap_or_else(|| netdesk_core::BRAND.to_string()), + ); + } + + // Admin-mode activation (register/remove the elevated host task). + if args.iter().any(|a| a == "--install-admin-task") { + return install_admin_task(); + } + if args.iter().any(|a| a == "--uninstall-admin-task") { + return uninstall_admin_task(); + } + + // Unattended SYSTEM service (lock/sign-in-screen capture for remote unlock). + #[cfg(windows)] + { + if args.iter().any(|a| a == "--service-run") { + return netdesk_host::service::run_service(); + } + if args.iter().any(|a| a == "--session-agent") { + // The per-session host the service spawns as SYSTEM: follow the input + // desktop so the secure (lock/sign-in) desktop is captured. + std::env::set_var("NETDESK_FOLLOW_DESKTOP", "1"); + return run_headless_host(); + } + } + if args.iter().any(|a| a == "--check-update") { + return check_update(args.iter().any(|a| a == "--apply")); + } + if args.iter().any(|a| a == "--install-service") { + return install_unattended(); + } + if args.iter().any(|a| a == "--uninstall-service") { + return uninstall_unattended(); + } + + // Unattended config injection (MSI / scripted deploy): write config.json + // non-interactively from flags, then optionally start the host (--run). + #[cfg(windows)] + if args.iter().any(|a| a == "--config") { + apply_cli_config(&args)?; + if args.iter().any(|a| a == "--run") { + return run_headless_host(); + } + return Ok(()); + } + + // Headless host/service mode (no GUI) — unattended hosting. + if args.iter().any(|a| a == "--host" || a == "--service") { + return run_headless_host(); + } + + // With args -> auto-connect (CLI compatible). Without -> show connect screen. + let auto: Option = if args.len() >= 3 { + Some(ConnectConfig { + host_id: args[1].clone(), + password: args[2].clone(), + ws_url: args + .get(3) + .cloned() + .unwrap_or_else(|| "wss://netdesk.space/ws".to_string()), + totp: args.get(4).cloned().unwrap_or_default(), + takeover: false, + }) + } else { + None + }; + + println!( + "NetDesk native viewer v{} (by {})", + netdesk_core::VERSION, + netdesk_core::VENDOR + ); + if let Some(cfg) = &auto { + println!("connecting to host {} via {} ...", cfg.host_id, cfg.ws_url); + } + + // Decode the embedded logo once for the window + taskbar + Alt-Tab icon. + let (icon_rgba, iw, ih) = decode_logo(); + // Standard, compact window like the previous desktop app (~1060x720). NOT + // maximized. `persist_window: false` below stops eframe from restoring a + // large/maximized size saved from an earlier run. + // NETDESK_WIN="WxH" (e.g. 900x600) overrides the initial inner size — used + // by the responsive-layout test matrix; harmless when unset. + let mut win_w = 1060.0f32; + let mut win_h = 720.0f32; + if let Ok(v) = std::env::var("NETDESK_WIN") { + if let Some((w, h)) = v.trim().to_ascii_lowercase().split_once('x') { + if let (Ok(w), Ok(h)) = (w.trim().parse::(), h.trim().parse::()) { + if (320.0..=7680.0).contains(&w) && (240.0..=4320.0).contains(&h) { + win_w = w; + win_h = h; + } + } + } + } + let mut viewport = egui::ViewportBuilder::default() + .with_title(brand_name()) + .with_app_id("com.akronlogic.netdesk") + .with_inner_size([win_w, win_h]) + .with_min_inner_size([win_w.min(900.0), win_h.min(600.0)]) + .with_maximized(false) + .with_resizable(true); + if iw > 0 { + viewport = viewport.with_icon(egui::IconData { + rgba: icon_rgba, + width: iw, + height: ih, + }); + } + + let native_options = eframe::NativeOptions { + viewport, + vsync: true, + persist_window: false, // always open at the standard size, don't restore a huge one + ..Default::default() + }; + + eframe::run_native( + "NetDesk", + native_options, + Box::new(move |cc| Ok(Box::new(NetDeskApp::new(cc, auto)))), + ) + .map_err(|e| anyhow::anyhow!("eframe: {e}"))?; + + Ok(()) +} + +/// Decode `LOGO_PNG` -> (rgba8, w, h). Returns an empty vec on failure. +fn decode_logo() -> (Vec, u32, u32) { + // White-label: a brand.json "logo" PNG next to the exe overrides the mark. + #[cfg(windows)] + if let Some(bytes) = brand_logo_bytes() { + if let Ok(img) = image::load_from_memory(&bytes) { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + return (rgba.into_raw(), w, h); + } + } + match image::load_from_memory(LOGO_PNG) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + (rgba.into_raw(), w, h) + } + Err(_) => (Vec::new(), 0, 0), + } +} + +struct NetDeskApp { + shared: Arc, + session: Arc>>, + route: Route, + /// The host panel (Home). Owns config.json, engine control, account, etc. + home: home::HomeState, + /// True once the net thread has been spawned for the current connect attempt + /// (distinguishes the connect FORM from the live "connecting…" progress). + connecting_active: bool, + theme_set: bool, + /// First-frame window sizing done (original-app formula). Pre-set to true + /// when the NETDESK_WIN test override is in use so tests keep their size. + sized: bool, + + // logo + logo_rgba: Vec, + logo_dim: (u32, u32), + logo_tex: Option, + + // connect form + form_id: String, + form_pw: String, + form_server: String, + form_totp: String, + form_view_only: bool, + /// Last view-only value pushed to the host this connection (None = not yet + /// sent / disconnected); the per-frame sync re-sends on change + reconnect. + sent_view_only: Option, + show_pw: bool, + show_advanced: bool, + connect_error: Option, + + // video texture + texture: Option, + tex_size: (u32, u32), + /// Shadow copy of the most recent decoded frame (w, h, rgb) kept for the + /// "Save screenshot" action, since the live frame slot is usually already + /// drained into the GPU texture by the time the user clicks. + last_frame_rgb: Option<(u32, u32, Vec)>, + /// Remote cursor bitmap texture (None = draw the arrow fallback overlay). + cursor_shape: Option, + + // session UI state + inited: bool, + view_only: bool, + /// Relative / game mouse (pointer-lock-style): send raw frame deltas + + /// hide the local cursor, for FPS games / 3D apps that want raw motion. + game_mouse: bool, + /// Translate / international keyboard: send typed characters as Unicode + /// (Event::Text) so AltGr / dead-key / non-US layouts type correctly. + translate_kbd: bool, + /// Collaborative annotation: draw ink on the host's screen overlay. Strokes + /// are kept in REMOTE pixel coords (resize-safe) and rendered over the frame. + annotate: bool, + annot_drawing: bool, + annot_cur: Vec<(i32, i32)>, + annot_strokes: Vec>, + quality: Quality, + // Custom image-quality (overrides the preset when active). The `scale` lever + // (host-side resolution downscale) is otherwise unused by the presets. + qc_custom: bool, + qc_fps: u32, + qc_mbps: f32, + qc_scale: f32, + fullscreen: bool, + monitors: Vec, + cur_mon: i32, + scale_mode: ScaleMode, + + // toggles / live info + info: Option, + privacy_on: bool, + block_input_on: bool, + wallpaper_hidden: bool, + lock_on_end: bool, + sound_on: bool, + clip_sync: bool, + + // resolution modes + modes: Vec<(u32, u32)>, + cur_mode: Option<(u32, u32)>, + modes_loaded: bool, + + // processes + processes: Vec, + + // chat + chat_log: Vec, + chat_input: String, + chat_unread: u32, + + // run-command box + run_cmd: String, + + // recording stub state + recording: bool, + + // panels / dialogs + show_chat: bool, + show_sys: bool, + show_files: bool, + show_about: bool, + show_info: bool, + pending_confirm: Option, + + // file transfer (remote browser + active transfers) + file_path: String, + file_entries: Vec, + file_status: String, + file_status_err: bool, + file_loading: bool, + file_opened: bool, + transfers: Vec, + file_prompt: Option, + file_prompt_focus: bool, + + // toolbar + bar_last_active: Instant, + bar_collapsed: bool, + + // transient banner (text, expiry, ok?) + banner: Option<(String, Instant, bool)>, + + // clipboard sync bookkeeping + last_clip_poll: Instant, + last_local_clip: String, + last_remote_clip: String, + + // input tracking + last_sent: Option<(i32, i32, u32)>, + prev_mods: Modifiers, + lat_ms: f32, + + // session timer + session_start: Option, +} + +impl NetDeskApp { + fn new(cc: &eframe::CreationContext<'_>, auto: Option) -> Self { + let (logo_rgba, lw, lh) = decode_logo(); + let shared = Shared::new(); + let session: Arc>> = Arc::new(Mutex::new(None)); + + // System-key grab: install the WH_KEYBOARD_LL hook once on the UI thread + // (which pumps messages). It stays inert until a session is focused. + #[cfg(windows)] + let _ = netdesk_host::keyhook::install(); + + let home = home::HomeState::new(&cc.egui_ctx); + + let auto_id = auto.as_ref().map(|c| c.host_id.clone()).unwrap_or_default(); + let (route, connecting_active) = if let Some(cfg) = auto { + spawn_net_thread(cfg, shared.clone(), session.clone(), cc.egui_ctx.clone()); + (Route::Connecting, true) + } else { + (Route::Home, false) + }; + + Self { + shared, + session, + route, + home, + connecting_active, + theme_set: false, + sized: std::env::var("NETDESK_WIN").is_ok(), + logo_rgba, + logo_dim: (lw, lh), + logo_tex: None, + form_id: auto_id, + form_pw: String::new(), + form_server: "wss://netdesk.space/ws".to_string(), + form_totp: String::new(), + form_view_only: false, + sent_view_only: None, + show_pw: false, + show_advanced: false, + connect_error: None, + texture: None, + tex_size: (0, 0), + last_frame_rgb: None, + cursor_shape: None, + inited: false, + view_only: false, + game_mouse: false, + translate_kbd: false, + annotate: false, + annot_drawing: false, + annot_cur: Vec::new(), + annot_strokes: Vec::new(), + quality: Quality::High, + qc_custom: false, + qc_fps: 30, + qc_mbps: 8.0, + qc_scale: 1.0, + fullscreen: false, + monitors: Vec::new(), + cur_mon: 0, + scale_mode: ScaleMode::Fit, + info: None, + privacy_on: false, + block_input_on: false, + wallpaper_hidden: false, + lock_on_end: false, + sound_on: true, + clip_sync: true, + modes: Vec::new(), + cur_mode: None, + modes_loaded: false, + processes: Vec::new(), + chat_log: Vec::new(), + chat_input: String::new(), + chat_unread: 0, + run_cmd: String::new(), + recording: false, + show_chat: false, + show_sys: false, + show_files: false, + show_about: false, + show_info: false, + pending_confirm: None, + file_path: String::new(), + file_entries: Vec::new(), + file_status: String::new(), + file_status_err: false, + file_loading: false, + file_opened: false, + transfers: Vec::new(), + file_prompt: None, + file_prompt_focus: false, + bar_last_active: Instant::now(), + bar_collapsed: false, + banner: None, + last_clip_poll: Instant::now(), + last_local_clip: String::new(), + last_remote_clip: String::new(), + last_sent: None, + prev_mods: Modifiers::default(), + lat_ms: 0.0, + session_start: None, + } + } + + /// Run `f` against the live session, if any. + fn with_session(&self, f: F) { + if let Some(s) = self.session.lock().unwrap().as_ref() { + f(s); + } + } + + /// Toggle the two-way voice call: start/stop capturing THIS PC's microphone + /// and streaming it to the host (Opus over CHAN_VOICE). Reuses the host + /// crate's WASAPI capture engine with source=microphone. + fn toggle_voice(&mut self) { + let was_on = { + let mut g = self.shared.voice_stop.lock().unwrap(); + if let Some(stop) = g.take() { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + true + } else { + false + } + }; + if was_on { + self.banner("Voice call ended", true, 2); + return; + } + #[cfg(windows)] + { + let stop = Arc::new(AtomicBool::new(false)); + *self.shared.voice_stop.lock().unwrap() = Some(stop.clone()); + let sess = self.session.clone(); + std::thread::spawn(move || { + let src = Arc::new(AtomicU8::new(2)); // 2 = microphone + netdesk_host::audio::run_audio(stop, || true, src, move |opus| { + if let Some(s) = sess.lock().unwrap().as_ref() { + s.voice(&opus); + } + }); + }); + self.banner("Voice call started — speak into your mic", true, 2); + } + #[cfg(not(windows))] + { + self.banner("Voice call needs Windows (microphone capture)", true, 2); + } + } + + fn apply_quality(&mut self, q: Quality) { + self.qc_custom = false; + self.quality = q; + let (fps, br, sc) = q.params(); + self.with_session(|s| { + s.set_quality(fps, br, sc); + s.request_keyframe(); + }); + } + + /// Apply the custom FPS / bitrate / scale sliders. `scale` downscales the + /// host's capture before encoding (sharp text at 1.0, much less bandwidth at + /// 0.5) — a lever the fixed presets never expose. + fn apply_custom_quality(&mut self) { + self.qc_custom = true; + let fps = self.qc_fps.clamp(1, 60); + let br = ((self.qc_mbps * 1000.0) as u32).clamp(200, 40000); + let sc = self.qc_scale.clamp(0.2, 1.0); + self.with_session(|s| { + s.set_quality(fps, br, sc); + s.request_keyframe(); + }); + self.banner( + format!("Custom: {fps} fps · {:.1} Mbps · scale {:.2}", self.qc_mbps, sc), + true, + 2, + ); + } + + /// Where a new recording is written: the user's Videos folder (created if + /// needed), falling back to the temp dir. + fn recording_path(&self, w: u16, h: u16) -> PathBuf { + let base = std::env::var_os("USERPROFILE") + .map(|p| PathBuf::from(p).join("Videos")) + .unwrap_or_else(std::env::temp_dir); + let _ = std::fs::create_dir_all(&base); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + base.join(format!("NetDesk-{ts}-{w}x{h}")) // extension added per-codec (.mp4/.webm) + } + + /// Start teeing the live H.264 into a .mp4 (no re-encode). The net thread + /// feeds the recorder each access unit; a keyframe is requested so the file + /// gets its SPS/PPS and a clean start promptly. + fn start_recording(&mut self) { + let (w, h) = { + let s = self.shared.stats.lock().unwrap(); + (s.res_w as u16, s.res_h as u16) + }; + if w < 2 || h < 2 { + self.banner("Connect to a host first, then record", false, 2); + return; + } + let stem = self.recording_path(w, h); + let want_audio = self.shared.sound_on.load(std::sync::atomic::Ordering::Relaxed); + *self.shared.rec_pending.lock().unwrap() = Some((stem, w, h, want_audio)); + self.shared.recording.store(true, std::sync::atomic::Ordering::Relaxed); + self.recording = true; + // No forced codec: the net thread records whatever codec is live (VP9 -> + // .webm with an Opus track, H.264 -> .mp4) and pins THAT for the duration + // so the container codec stays consistent. A keyframe starts a clean file. + self.with_session(|s| s.request_keyframe()); + self.banner("● Recording…", true, 3); + } + + /// Finalize the recording and report where it was saved. + fn stop_recording(&mut self) { + self.recording = false; + self.shared.recording.store(false, std::sync::atomic::Ordering::Relaxed); + self.shared.rec_pending.lock().unwrap().take(); + // Release the record codec pin so the host resumes adaptive selection. + self.with_session(|s| s.pin_codec(-1)); + let rec = self.shared.recorder.lock().unwrap().take(); + if let Some(rec) = rec { + match rec.finish() { + Ok((p, n)) => self.banner(format!("Saved {} ({n} frames)", p.display()), true, 5), + Err(e) => self.banner(format!("Recording failed: {e}"), false, 4), + } + } + } + + fn banner(&mut self, text: impl Into, ok: bool, secs: u64) { + self.banner = Some((text.into(), Instant::now() + Duration::from_secs(secs), ok)); + } + + fn logo_texture(&mut self, ctx: &egui::Context) -> Option { + if self.logo_tex.is_none() && self.logo_dim.0 > 0 { + let (w, h) = self.logo_dim; + let img = + egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &self.logo_rgba); + self.logo_tex = Some(ctx.load_texture("nd-logo", img, TextureOptions::LINEAR)); + } + self.logo_tex.clone() + } + + /// Start a connection from the connect-screen form. + fn start_from_form(&mut self, ctx: &egui::Context) { + let id: String = self.form_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if id.len() < 6 { + self.connect_error = Some("Enter the 9-digit Partner ID.".into()); + return; + } + if self.form_pw.is_empty() { + self.connect_error = Some("Enter the password.".into()); + return; + } + let ws = if self.form_server.trim().is_empty() { + "wss://netdesk.space/ws".to_string() + } else { + self.form_server.trim().to_string() + }; + let cfg = ConnectConfig { + host_id: id, + password: std::mem::take(&mut self.form_pw), + ws_url: ws, + totp: self.form_totp.trim().to_string(), + takeover: false, + }; + self.view_only = self.form_view_only; + self.start_session(cfg, ctx); + } + + /// Start a session from a fully-built config: fresh `Shared` (so a prior + /// attempt's net thread can wind down without touching this one), reset the + /// session UI, spawn the net thread, and route to `Connecting`. + fn start_session(&mut self, cfg: ConnectConfig, ctx: &egui::Context) { + // Tell any previous net thread to stop (don't let it auto-reconnect the + // old host) before we swap in fresh shared state. + self.shared.stop.store(true, Ordering::Relaxed); + let shared = Shared::new(); + self.shared = shared.clone(); + *self.session.lock().unwrap() = None; + self.inited = false; + self.texture = None; + self.tex_size = (0, 0); + self.monitors.clear(); + self.cur_mon = 0; + self.modes.clear(); + self.modes_loaded = false; + self.connect_error = None; + spawn_net_thread(cfg, shared, self.session.clone(), ctx.clone()); + self.connecting_active = true; + self.route = Route::Connecting; + } + + /// Tear the current session down and return to the Home host panel. + fn go_home(&mut self) { + // Finalize any in-progress recording before the shared state is replaced + // (dropping the writer without write_end() would truncate the MP4). + if self.recording { + self.stop_recording(); + } + // Stop the net thread's reconnect loop — this is a deliberate exit. + self.shared.stop.store(true, Ordering::Relaxed); + self.with_session(|s| s.close()); + *self.session.lock().unwrap() = None; + self.shared = Shared::new(); + self.inited = false; + self.texture = None; + self.tex_size = (0, 0); + self.monitors.clear(); + self.cur_mon = 0; + self.processes.clear(); + self.chat_log.clear(); + self.chat_unread = 0; + self.show_chat = false; + self.show_sys = false; + self.show_files = false; + self.show_info = false; + self.show_about = false; + self.file_opened = false; + self.file_entries.clear(); + self.transfers.clear(); + self.file_prompt = None; + self.session_start = None; + self.banner = None; + self.connecting_active = false; + self.connect_error = None; + self.form_pw.clear(); + self.route = Route::Home; + self.home.refresh(); + } + + fn push_local_clipboard(&mut self) { + match arboard::Clipboard::new().and_then(|mut c| c.get_text()) { + Ok(text) if !text.is_empty() => { + self.last_remote_clip = text.clone(); + self.last_local_clip = text.clone(); + self.with_session(|s| s.clipboard(&text)); + self.banner("Clipboard sent to remote", true, 2); + } + _ => self.banner("Local clipboard is empty", false, 2), + } + } + + fn set_local_clipboard(&mut self, text: &str) { + if let Ok(mut c) = arboard::Clipboard::new() { + let _ = c.set_text(text.to_string()); + } + } + + // ---- file transfer ---------------------------------------------------- + /// Open the Files panel. The first time it is opened on a live session we + /// request the drives/home listing (the web viewer's `send("file.roots")`). + fn open_files(&mut self) { + self.show_files = true; + if !self.file_opened && self.session.lock().unwrap().is_some() { + self.file_opened = true; + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_roots()); + } + } + + /// Navigate to a remote directory (`file.list`). An empty path asks the host + /// for the drives/home listing. + fn list_dir_ui(&mut self, path: String) { + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_dir(&path)); + } + + /// Re-list the folder currently shown (used after mkdir/delete/rename/upload + /// when the host asks for a refresh). + fn refresh_files(&mut self) { + let p = self.file_path.clone(); + self.with_session(|s| s.list_dir(&p)); + } + + /// Start a download of a remote file and register a transfer row. The core + /// assembles the bytes and the net thread writes them to the Downloads + /// folder, reporting progress via `FileProgress` / `FileSaved`. + fn download_entry(&mut self, full: String, name: String, size: u64) { + let fid = { + let g = self.session.lock().unwrap(); + g.as_ref().map(|s| s.download(&full)) + }; + match fid { + Some(fid) => { + self.transfers.push(Transfer { + fid, + name: name.clone(), + total: size, + done: 0, + up: false, + state: TransferState::Active, + detail: String::new(), + }); + self.file_status = format!("Downloading {name}…"); + self.file_status_err = false; + } + None => { + self.file_status = "No active session".into(); + self.file_status_err = true; + } + } + } + + /// Pick one or more local files with the native dialog and upload each to + /// the folder currently shown (the web viewer's "Upload…" button). An empty + /// current path makes the host drop the files on the remote Desktop. + fn pick_and_upload(&mut self, ctx: &egui::Context) { + let picked = rfd::FileDialog::new() + .set_title("Upload to remote") + .pick_files(); + if let Some(files) = picked { + let remote_dir = self.file_path.clone(); + for f in files { + self.start_upload(f, remote_dir.clone(), ctx.clone()); + } + } + } + + /// Spawn a background thread that streams one local file to the host in + /// `UPLOAD_CHUNK` pieces, reporting progress through the shared inbox. + fn start_upload(&self, local: PathBuf, remote_dir: String, ctx: egui::Context) { + let session = self.session.clone(); + let shared = self.shared.clone(); + std::thread::spawn(move || upload_worker(session, shared, ctx, local, remote_dir)); + } + + /// TeamViewer-style drag & drop: files dropped onto the window (while + /// connected) upload to the remote folder currently shown in the File + /// panel, or the remote Desktop when no folder is open. A full-window hint + /// shows while files are being dragged over us. + fn handle_file_drops(&mut self, ctx: &egui::Context) { + // Dragging-over hint overlay. + if ctx.input(|i| !i.raw.hovered_files.is_empty()) { + let screen = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("nd_drop_hint"), + )); + painter.rect_filled(screen, 0.0, Color32::from_rgba_unmultiplied(8, 14, 20, 170)); + painter.rect_stroke( + screen.shrink(10.0), + Rounding::same(12.0), + Stroke::new(2.0_f32, c_accent()), + ); + painter.text( + screen.center(), + egui::Align2::CENTER_CENTER, + "Drop files to send to the remote PC", + egui::FontId::proportional(22.0), + c_accent(), + ); + } + // Actual drops. + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + if dropped.is_empty() { + return; + } + let remote_dir = self.file_path.clone(); + let mut n = 0; + for f in dropped { + if let Some(path) = f.path { + if path.is_file() { + self.start_upload(path, remote_dir.clone(), ctx.clone()); + n += 1; + } + } + } + if n > 0 { + self.show_files = true; // reveal the transfer progress + let dest = if remote_dir.trim().is_empty() { + "remote Desktop".to_string() + } else { + remote_dir + }; + self.banner(format!("Uploading {n} file(s) → {dest}"), true, 3); + } + } +} + +impl eframe::App for NetDeskApp { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + [0.043, 0.059, 0.082, 1.0] // C_BG + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if !self.theme_set { + apply_theme(ctx); + self.theme_set = true; + } + if !self.sized { + // Size like the original desktop app: a fraction of the monitor, + // clamped to the same bounds, centered; slightly denser UI scale so + // the panel reads as compact as the previous one. + if let Some(mon) = ctx.input(|i| i.viewport().monitor_size) { + let w = (mon.x * 0.62).clamp(880.0, 1060.0).min(mon.x - 40.0); + let h = (mon.y * 0.75).clamp(600.0, 760.0).min(mon.y - 60.0); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2( + ((mon.x - w) / 2.0).max(0.0), + ((mon.y - h) / 2.4).max(0.0), + ))); + } + ctx.set_zoom_factor(0.90); + self.sized = true; + } + + // NOTE: there is deliberately NO per-frame auto-fit zoom here. The old + // block re-derived the zoom from `inner_rect * zoom_factor()` every + // frame, but `zoom_factor()` updates one frame before `inner_rect` + // does, so the feedback loop never converged and the window oscillated + // between zoom levels (visible as the whole UI resizing every frame). + // The Home panel is instead laid out to fit ~1030x700 logical points + // with a fixed zoom (see home.rs LAYOUT METRICS); smaller windows fall + // back to the page ScrollArea. + + // System-key grab: while a session window is focused, forward the keys + // Windows normally eats (Win / PrtScn / Alt+Tab / Alt+Esc / Ctrl+Esc) to + // the host and swallow them locally, so they act on the REMOTE machine. + #[cfg(windows)] + { + let active = matches!(self.route, Route::Session) && ctx.input(|i| i.focused); + netdesk_host::keyhook::set_active(active); + let grabbed = netdesk_host::keyhook::poll(); + if active { + for (code, down) in grabbed { + self.with_session(|s| s.key(code, down)); + } + } + } + + match self.route { + Route::Home => self.update_home(ctx), + Route::Connecting => self.update_connecting(ctx), + Route::Session => self.update_session(ctx), + } + + // keep the UI lively (stats / auto-hide / clipboard poll / banner expiry) + ctx.request_repaint_after(Duration::from_millis(200)); + } +} + +// ============================ HOME / HOST PANEL ============================ +impl NetDeskApp { + fn update_home(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx).map(|t| t.id()); + match self.home.ui(ctx, logo) { + home::HomeAction::Connect { id } => { + // fold the branded connect flow in: seed the form from the + // partner ID + the host's own relay, then route to Connecting. + self.form_id = id; + self.form_pw.clear(); + self.form_totp.clear(); + self.form_view_only = false; + self.form_server = self.home.server(); + self.connect_error = None; + self.connecting_active = false; + self.show_advanced = false; + self.route = Route::Connecting; + } + home::HomeAction::None => {} + } + } +} + +// ============================ CONNECTING FLOW ============================== +impl NetDeskApp { + /// The branded outbound-connect flow (reached only from Home). Shows a + /// credential card for the partner ID; once the user hits Connect it spawns + /// the net thread and shows live "Connecting…" progress, advancing to the + /// Session viewer when the stream is up or back to the form on failure. + fn update_connecting(&mut self, ctx: &egui::Context) { + if self.connecting_active { + let stats = self.shared.stats.lock().unwrap().clone(); + if stats.connected { + self.route = Route::Session; + self.update_session(ctx); + return; + } + if let Some(reason) = stats.ended.clone() { + self.connect_error = Some(reason); + self.connecting_active = false; + } + } + if self.connecting_active { + self.update_connecting_progress(ctx); + } else { + self.update_connect(ctx); + } + } + + /// Centered "Connecting to …" progress screen with a Cancel button. + fn update_connecting_progress(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx); + let status = self.shared.stats.lock().unwrap().status.clone(); + let id_disp = { + let d: String = self.form_id.chars().filter(|c| c.is_ascii_digit()).collect(); + if d.len() == 9 { + format!("{} {} {}", &d[0..3], &d[3..6], &d[6..9]) + } else { + d + } + }; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(C_BG)) + .show(ctx, |ui| { + let avail = ui.available_size(); + ui.add_space((avail.y * 0.26).min(180.0)); + ui.vertical_centered(|ui| { + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(72.0, 72.0), + ))); + } + ui.add_space(14.0); + ui.add(egui::Spinner::new().size(26.0).color(c_accent())); + ui.add_space(12.0); + ui.label( + RichText::new(format!("Connecting to {id_disp}…")) + .size(20.0) + .strong() + .color(C_TEXT), + ); + ui.add_space(4.0); + ui.label( + RichText::new(if status.is_empty() { + "Establishing an encrypted tunnel to the relay".to_string() + } else { + status + }) + .size(13.0) + .color(C_MUTED), + ); + ui.add_space(18.0); + let cancel = egui::Button::new( + RichText::new("Cancel").color(C_TEXT).strong(), + ) + .fill(C_FIELD) + .min_size(egui::vec2(140.0, 38.0)) + .rounding(Rounding::same(9.0)); + if ui.add(cancel).clicked() { + self.go_home(); + } + }); + }); + ctx.request_repaint_after(Duration::from_millis(120)); + } +} + +// ============================ CONNECT SCREEN ================================ +impl NetDeskApp { + fn update_connect(&mut self, ctx: &egui::Context) { + let logo = self.logo_texture(ctx); + let enter = ctx.input(|i| i.key_pressed(Key::Enter)); + + let mut back = false; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(C_BG)) + .show(ctx, |ui| { + let avail = ui.available_size(); + // top-left: back to the host panel + ui.horizontal(|ui| { + ui.add_space(4.0); + // A clearly-bordered button (not faint text) so it reads as + // the obvious way back to the host panel. + if ui + .add( + egui::Button::new( + RichText::new(format!("{} Back to Home", ph::ARROW_LEFT)) + .size(13.5) + .color(C_TEXT), + ) + .fill(C_FIELD) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .rounding(8.0) + .min_size(egui::vec2(132.0, 32.0)), + ) + .on_hover_text("Return to the host panel") + .clicked() + { + back = true; + } + }); + ui.add_space((avail.y * 0.08).min(64.0)); + ui.vertical_centered(|ui| { + // logo mark + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(84.0, 84.0), + ))); + } + ui.add_space(10.0); + ui.label(RichText::new(brand_name()).size(38.0).strong().color(C_TEXT)); + ui.label(RichText::new("Developed by Akronlogic.com").size(15.0).color(c_accent())); + ui.add_space(4.0); + ui.label( + RichText::new("Connect out to a partner — fast, self-hosted, end-to-end encrypted") + .size(13.0) + .color(C_MUTED), + ); + ui.add_space(22.0); + + // the card + egui::Frame::none() + .fill(C_PANEL) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .rounding(Rounding::same(14.0)) + .inner_margin(Margin::symmetric(26.0, 24.0)) + .show(ui, |ui| { + // fluid card: 380px, but never wider than the window + ui.set_width(380.0_f32.min(ui.available_width() - 8.0).max(240.0)); + ui.spacing_mut().item_spacing.y = 8.0; + + ui.label(RichText::new("Connect to a computer").size(17.0).strong().color(C_TEXT)); + ui.label(RichText::new("Enter the ID shown by the NetDesk agent.").size(12.0).color(C_MUTED)); + ui.add_space(8.0); + + ui.label(RichText::new("Partner ID").size(12.0).color(C_MUTED)); + let id_resp = ui.add( + egui::TextEdit::singleline(&mut self.form_id) + .hint_text("123 456 789") + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Heading), + ); + + ui.add_space(4.0); + ui.label(RichText::new("Password").size(12.0).color(C_MUTED)); + ui.horizontal(|ui| { + let pw = egui::TextEdit::singleline(&mut self.form_pw) + .hint_text("permanent password") + .password(!self.show_pw) + .desired_width(ui.available_width() - 46.0); + ui.add(pw); + let eye = if self.show_pw { "🙈" } else { "👁" }; + if ui.button(eye).on_hover_text("Show / hide password").clicked() { + self.show_pw = !self.show_pw; + } + }); + + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox(&mut self.form_view_only, "View only (no input)"); + }); + + // advanced: server + 2FA + let adv = self.show_advanced; + if ui + .selectable_label(adv, RichText::new("Advanced — server & 2FA").size(12.0).color(C_MUTED)) + .clicked() + { + self.show_advanced = !self.show_advanced; + } + if self.show_advanced { + ui.label(RichText::new("Server").size(12.0).color(C_MUTED)); + ui.add( + egui::TextEdit::singleline(&mut self.form_server) + .hint_text("wss://netdesk.space/ws · or ws://PC-IP:8901 (LAN)") + .desired_width(f32::INFINITY), + ); + ui.label(RichText::new("2FA code (if the host requires it)").size(12.0).color(C_MUTED)); + ui.add( + egui::TextEdit::singleline(&mut self.form_totp) + .hint_text("6-digit code") + .desired_width(f32::INFINITY), + ); + } + + ui.add_space(12.0); + let btn = egui::Button::new(RichText::new("Connect").strong().size(15.0).color(Color32::WHITE)) + .fill(c_accent()) + .min_size(egui::vec2(ui.available_width(), 42.0)) + .rounding(Rounding::same(10.0)); + let clicked = ui.add(btn).clicked(); + + if let Some(err) = &self.connect_error { + ui.add_space(6.0); + ui.label(RichText::new(err).color(C_DANGER).size(12.0)); + } + + // Enter anywhere in the form (except a multiline) connects. + let enter_in_field = id_resp.lost_focus() && enter; + if clicked || enter_in_field || (enter && !self.form_id.is_empty() && !self.form_pw.is_empty()) { + self.start_from_form(ui.ctx()); + } + }); + + ui.add_space(18.0); + ui.label( + RichText::new(format!( + "NetDesk viewer v{} · {}", + netdesk_core::VERSION, + netdesk_core::VENDOR + )) + .size(11.0) + .color(C_MUTED), + ); + }); + }); + if back { + self.go_home(); + } + } +} + +// ============================ SESSION SCREEN ================================ +impl NetDeskApp { + fn update_session(&mut self, ctx: &egui::Context) { + // ---- 1. pull the newest decoded frame and upload it to the GPU ------- + if let Some(fr) = self.shared.frame.lock().unwrap().take() { + let sample = fr.recv_at.elapsed().as_secs_f32() * 1000.0; + self.lat_ms = if self.lat_ms == 0.0 { + sample + } else { + self.lat_ms * 0.85 + sample * 0.15 + }; + let size = [fr.w as usize, fr.h as usize]; + let img = egui::ColorImage::from_rgb(size, &fr.rgb); + match self.texture { + Some(ref mut t) if self.tex_size == (fr.w, fr.h) => { + t.set(img, TextureOptions::LINEAR); + } + _ => { + self.texture = Some(ctx.load_texture("remote", img, TextureOptions::LINEAR)); + self.tex_size = (fr.w, fr.h); + } + } + // keep the pixels for "Save screenshot" (ColorImage copied above, so + // this is a free move of the original Vec, not an extra allocation). + self.last_frame_rgb = Some((fr.w, fr.h, fr.rgb)); + } + + // ---- 2. read stats snapshot + drain control-protocol inbox ---------- + let stats = self.shared.stats.lock().unwrap().clone(); + let events = std::mem::take(&mut *self.shared.inbox.lock().unwrap()); + for ev in events { + self.handle_ui_event(ctx, ev); + } + + // ---- 3. one-time setup once the session is live --------------------- + if stats.connected && !self.inited { + self.inited = true; + self.session_start = Some(Instant::now()); + let title = if stats.host_name.is_empty() { + "NetDesk".to_string() + } else { + format!("NetDesk — {}", stats.host_name) + }; + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + if let Some(s) = self.session.lock().unwrap().as_ref() { + if self.monitors.is_empty() { + self.monitors = s.monitors().to_vec(); + } + self.cur_mon = s.current_monitor(); + } + self.apply_quality(self.quality); + } + + // ---- 4. clipboard sync poll (local -> remote) ----------------------- + if stats.connected + && self.clip_sync + && self.last_clip_poll.elapsed() > Duration::from_millis(900) + { + self.last_clip_poll = Instant::now(); + if let Ok(text) = arboard::Clipboard::new().and_then(|mut c| c.get_text()) { + if !text.is_empty() && text != self.last_local_clip && text != self.last_remote_clip + { + self.last_local_clip = text.clone(); + self.with_session(|s| s.clipboard(&text)); + } + } + } + + // ---- 5. toolbar (menu bar + buttons), auto-hide like TeamViewer ----- + let pointer = ctx.input(|i| i.pointer.latest_pos()); + let near_top = pointer.map_or(false, |p| p.y < 96.0); + let pointer_moved = ctx.input(|i| i.pointer.delta() != Vec2::ZERO); + let focused = ctx.input(|i| i.focused); + if near_top || pointer_moved || focused || !stats.connected { + self.bar_last_active = Instant::now(); + } + let show_bar = !self.bar_collapsed + && (self.bar_last_active.elapsed() < Duration::from_secs(4) || !stats.connected); + + if show_bar { + self.draw_toolbar(ctx, &stats); + } else { + // collapsed / hidden: a tiny restore tab, top-center + egui::Area::new(egui::Id::new("tbShow")) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + egui::Frame::none() + .fill(C_BAR) + .rounding(Rounding { + nw: 0.0, + ne: 0.0, + sw: 8.0, + se: 8.0, + }) + .inner_margin(Margin::symmetric(12.0, 4.0)) + .show(ui, |ui| { + if ui + .add(egui::Button::new(RichText::new("▼ NetDesk").color(C_MUTED)).frame(false)) + .clicked() + { + self.bar_collapsed = false; + self.bar_last_active = Instant::now(); + } + }); + }); + } + + // ---- 6. central video area (letterboxed or 1:1, GPU-scaled) --------- + let mut img_rect: Option = None; + let mut go_home_now = false; + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(Color32::BLACK)) + .show(ctx, |ui| { + let avail = ui.available_rect_before_wrap(); + ui.painter().rect_filled(avail, 0.0, Color32::BLACK); + + if let Some(tex) = &self.texture { + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + if iw > 0.0 && ih > 0.0 { + let uv = Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)); + match self.scale_mode { + ScaleMode::Fit => { + let rect = fit_rect(avail, iw, ih); + ui.painter().image(tex.id(), rect, uv, Color32::WHITE); + img_rect = Some(rect); + } + ScaleMode::Orig => { + egui::ScrollArea::both() + .drag_to_scroll(false) + .show(ui, |ui| { + let (rect, _r) = ui.allocate_exact_size( + egui::vec2(iw, ih), + Sense::hover(), + ); + ui.painter().image(tex.id(), rect, uv, Color32::WHITE); + img_rect = Some(rect); + }); + } + } + } + } else { + let msg = if let Some(r) = &stats.ended { + format!("Session ended: {r}") + } else if stats.connected { + "Waiting for first frame…".to_string() + } else if !stats.status.is_empty() { + format!("{}…", stats.status) + } else { + "Connecting…".to_string() + }; + ui.painter().text( + avail.center(), + egui::Align2::CENTER_CENTER, + msg, + egui::FontId::proportional(18.0), + C_MUTED, + ); + if stats.ended.is_some() { + let br = Rect::from_center_size( + avail.center() + Vec2::new(0.0, 44.0), + Vec2::new(180.0, 40.0), + ); + let b = egui::Button::new( + RichText::new(format!("{} Return to Home", ph::ARROW_LEFT)).color(Color32::WHITE).strong(), + ) + .fill(c_accent()) + .rounding(Rounding::same(9.0)); + if ui.put(br, b).clicked() { + go_home_now = true; + } + } + } + + // ---- remote mouse cursor overlay (RustDesk-style) ----------- + // The host streams the cursor position + bitmap separately from + // the video, so we draw it ourselves over the letterboxed frame. + if let Some(rect) = img_rect { + let cur = self.shared.cursor.lock().map(|c| *c).unwrap_or_default(); + if cur.visible && self.tex_size.0 > 0 { + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + let px = rect.left() + (cur.x / iw) * rect.width(); + let py = rect.top() + (cur.y / ih) * rect.height(); + let tip = egui::pos2(px, py); + if rect.contains(tip) { + match &self.cursor_shape { + Some(cs) => { + let sz = cs.tex.size_vec2(); + let tl = egui::pos2(px - cs.hx, py - cs.hy); + ui.painter().image( + cs.tex.id(), + Rect::from_min_size(tl, sz), + Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1.0, 1.0), + ), + Color32::WHITE, + ); + } + None => draw_arrow_cursor(ui.painter(), tip), + } + } + } + // Annotation ink: strokes are in remote pixels; map to view. + let (iw, ih) = (self.tex_size.0 as f32, self.tex_size.1 as f32); + if iw > 0.0 && ih > 0.0 && (self.annotate || !self.annot_strokes.is_empty()) { + let map = |pt: &(i32, i32)| { + egui::pos2( + rect.left() + (pt.0 as f32 / iw) * rect.width(), + rect.top() + (pt.1 as f32 / ih) * rect.height(), + ) + }; + let stroke = egui::Stroke::new(3.0, Color32::from_rgb(0xFF, 0x33, 0x33)); + for st in self.annot_strokes.iter().chain(std::iter::once(&self.annot_cur)) { + if st.len() >= 2 { + let vpts: Vec = st.iter().map(map).collect(); + ui.painter().add(egui::Shape::line(vpts, stroke)); + } else if st.len() == 1 { + ui.painter().circle_filled(map(&st[0]), 2.0, stroke.color); + } + } + } + } + }); + if go_home_now { + self.go_home(); + } + + // ---- 7. banner overlay ---------------------------------------------- + if let Some((text, until, ok)) = self.banner.clone() { + if Instant::now() < until { + egui::Area::new(egui::Id::new("banner")) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 104.0)) + .show(ctx, |ui| { + let bg = if ok { C_PANEL } else { Color32::from_rgb(60, 24, 24) }; + egui::Frame::none() + .fill(bg) + .stroke(Stroke::new(1.0_f32, if ok { c_accent() } else { C_DANGER })) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::symmetric(14.0, 8.0)) + .show(ui, |ui| { + ui.label(RichText::new(text).color(C_TEXT)); + }); + }); + } else { + self.banner = None; + } + } + + // ---- 8. floating panels + dialogs ----------------------------------- + self.draw_panels(ctx); + + // ---- 9. input forwarding -------------------------------------------- + // Keep the HOST-ENFORCED view-only flag in sync with the local toggle: + // sent once per change (and re-sent after a reconnect, when sent_view_only + // resets), so the engine drops input even from a modified client. + if stats.connected { + if self.sent_view_only != Some(self.view_only) { + let on = self.view_only; + self.with_session(|s| s.view_only(on)); + self.sent_view_only = Some(on); + } + } else { + self.sent_view_only = None; + } + // Keyboard/pointer are forwarded only when egui itself does not want them + // (so typing in chat / the run-command box, dragging a panel, or using a + // menu never leaks to the remote). Gating happens inside forward_input. + if stats.connected && !self.view_only { + self.forward_input(ctx, img_rect, stats.res_w, stats.res_h); + } else { + self.prev_mods = ctx.input(|i| i.modifiers); + } + + // ---- 10. drag & drop file upload (TeamViewer-style) ----------------- + if stats.connected { + self.handle_file_drops(ctx); + } + } + + fn handle_ui_event(&mut self, ctx: &egui::Context, ev: UiEvent) { + match ev { + UiEvent::CursorShape { rgba, w, h, hx, hy } => { + let img = + egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); + let tex = ctx.load_texture("remote-cursor", img, TextureOptions::NEAREST); + self.cursor_shape = Some(CursorTex { tex, hx, hy }); + } + UiEvent::Chat { from, text } => { + self.chat_log.push(ChatLine { who: from, text, mine: false }); + if !self.show_chat { + self.chat_unread += 1; + self.banner("New chat message", true, 3); + } + } + UiEvent::Clipboard(text) => { + if !text.is_empty() && text != self.last_remote_clip { + self.last_remote_clip = text.clone(); + self.last_local_clip = text.clone(); + self.set_local_clipboard(&text); + } + } + UiEvent::Info(info) => { + self.privacy_on = info.privacy_active; + self.lock_on_end = info.lock_on_end; + if !info.monitors.is_empty() { + self.monitors = info.monitors.clone(); + } + if info.mon != 0 || self.cur_mon == 0 { + self.cur_mon = info.mon; + } + if info.logon_screen { + self.banner("Host is at the Windows logon screen — you can type credentials.", true, 4); + } + self.info = Some(info); + } + UiEvent::Processes(list) => self.processes = list, + UiEvent::Modes { items, current } => { + self.modes = items; + self.cur_mode = current; + self.modes_loaded = true; + } + UiEvent::Toggle { kind, on, ok, msg } => { + match kind.as_str() { + "privacy" => self.privacy_on = on, + "block_input" => self.block_input_on = on, + "wallpaper" => self.wallpaper_hidden = on, + _ => {} + } + if !msg.is_empty() { + self.banner(msg, ok, 3); + } + } + UiEvent::Generic { kind, ok, msg } => { + if kind == "setmode" && ok { + self.modes_loaded = false; + self.with_session(|s| s.refresh_screen()); + } + if !msg.is_empty() { + self.banner( + if ok { msg } else { format!("Failed: {msg}") }, + ok, + 3, + ); + } + } + + // ---- file transfer ---- + UiEvent::FileList { path, entries } => { + self.file_path = path; + self.file_entries = entries; + self.file_loading = false; + } + UiEvent::FileProgress { fid, done } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = done; + if t.total < done { + t.total = done; + } + } + } + UiEvent::TransferStart { fid, name, total, up } => { + self.transfers.push(Transfer { + fid, + name, + total, + done: 0, + up, + state: TransferState::Active, + detail: String::new(), + }); + } + UiEvent::UploadSent { fid } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = t.total; + t.state = TransferState::Done; + } + } + UiEvent::FileSaved { fid, name, path, size } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.done = size; + if t.total < size { + t.total = size; + } + t.state = TransferState::Done; + t.detail = path.clone(); + } + self.file_status = format!("Saved {name}"); + self.file_status_err = false; + self.banner(format!("Saved to {path}"), true, 5); + } + UiEvent::FileFail { fid, msg } => { + if let Some(t) = self.transfers.iter_mut().find(|t| t.fid == fid) { + t.state = TransferState::Failed(msg.clone()); + } + self.file_status = msg; + self.file_status_err = true; + } + UiEvent::FileOk { msg, refresh } => { + self.file_status = if msg.is_empty() { "OK".into() } else { msg }; + self.file_status_err = false; + if refresh { + self.refresh_files(); + } + } + UiEvent::FileErr { msg } => { + self.file_status = format!("Error: {msg}"); + self.file_status_err = true; + self.banner(format!("File error: {msg}"), false, 4); + } + } + } +} + +// ----------------------------- toolbar -------------------------------------- +impl NetDeskApp { + fn draw_toolbar(&mut self, ctx: &egui::Context, stats: &NetStats) { + let logo = self.logo_texture(ctx); + egui::TopBottomPanel::top("toolbar") + .frame( + egui::Frame::none() + .fill(C_BAR) + .stroke(Stroke::new(1.0_f32, C_STROKE)) + .inner_margin(Margin::symmetric(8.0, 5.0)), + ) + .show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + // --- brand --- + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(20.0, 20.0), + ))); + } + ui.label(RichText::new(brand_name()).strong().color(C_TEXT)); + ui.separator(); + + // ===================== MENU BAR ===================== + self.menu_actions(ui); + self.menu_view(ui); + self.menu_communicate(ui); + self.menu_extras(ui); + self.menu_help(ui); + ui.separator(); + + // ===================== SELECTORS ===================== + // monitor + if !self.monitors.is_empty() { + let cur = self + .monitors + .iter() + .find(|m| m.i == self.cur_mon) + .map(|m| if m.i < 0 { + format!("🖥 All displays ({}×{})", m.w, m.h) + } else { + format!("🖥 {} ({}×{})", m.i, m.w, m.h) + }) + .unwrap_or_else(|| "🖥 Display".to_string()); + let mut switch_to = None; + egui::ComboBox::from_id_salt("mon") + .selected_text(cur) + .show_ui(ui, |ui| { + for m in &self.monitors { + let txt = if m.i < 0 { + format!("All displays ({}×{})", m.w, m.h) + } else { + format!("Display {} ({}×{})", m.i, m.w, m.h) + }; + if ui.selectable_label(self.cur_mon == m.i, txt).clicked() { + switch_to = Some(m.i); + } + } + }); + if let Some(i) = switch_to { + self.switch_monitor(i); + } + } + + // quality (presets + a Custom fps/bitrate/scale section) + let mut new_q = None; + let mut apply_custom = false; + let sel_txt = if self.qc_custom { + "⚙ Custom".to_string() + } else { + format!("⚙ {}", self.quality.short()) + }; + egui::ComboBox::from_id_salt("quality") + .selected_text(sel_txt) + .show_ui(ui, |ui| { + for q in Quality::ALL { + if ui.selectable_label(!self.qc_custom && self.quality == q, q.label()).clicked() { + new_q = Some(q); + } + } + ui.separator(); + ui.label(RichText::new("Custom").size(11.0).color(C_MUTED)); + ui.add(egui::Slider::new(&mut self.qc_fps, 1..=60).text("fps")); + ui.add( + egui::Slider::new(&mut self.qc_mbps, 0.3..=30.0) + .logarithmic(true) + .text("Mbps"), + ); + ui.add( + egui::Slider::new(&mut self.qc_scale, 0.2..=1.0) + .text("scale") + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), + ); + if ui.button("Apply custom").clicked() { + apply_custom = true; + } + }); + if let Some(q) = new_q { + self.apply_quality(q); + self.banner(format!("Quality: {}", q.short()), true, 2); + } + if apply_custom { + self.apply_custom_quality(); + } + ui.separator(); + + // ===================== ICON BUTTONS ===================== + // view-only + if self.tb_toggle(ui, "👁", "Input", !self.view_only, "Toggle remote input (view-only)").clicked() { + self.view_only = !self.view_only; + // (The per-frame sync in update() pushes this to the host, + // which enforces it engine-side — defense in depth.) + if self.view_only { + // release any buttons currently held on the host + if let Some((x, y, _)) = self.last_sent { + self.with_session(|s| s.mouse(x, y, 0, 0.0, 0.0)); + } + self.last_sent = None; + } + self.banner( + if self.view_only { "View-only: input disabled" } else { "Input enabled" }, + true, + 2, + ); + } + + // clipboard sync (push local -> remote, and toggle auto-sync via right not available; left pushes) + if self.tb_toggle(ui, "📋", "Clip", self.clip_sync, "Clipboard sync — click to push local clipboard to remote").clicked() { + self.push_local_clipboard(); + } + + // chat + let chat_lbl = if self.chat_unread > 0 { format!("Chat({})", self.chat_unread) } else { "Chat".into() }; + if self.tb_toggle(ui, "💬", &chat_lbl, self.show_chat, "Chat").clicked() { + self.show_chat = !self.show_chat; + if self.show_chat { + self.chat_unread = 0; + } + } + + // power / processes + if self.tb_toggle(ui, "⏻", "Power", self.show_sys, "Power / processes").clicked() { + self.show_sys = !self.show_sys; + if self.show_sys { + self.with_session(|s| s.list_processes()); + } + } + + // files + if self.tb_toggle(ui, "🗁", "Files", self.show_files, "File transfer").clicked() { + if self.show_files { + self.show_files = false; + } else { + self.open_files(); + } + } + + // privacy + if self.tb_toggle(ui, "🛡", "Privacy", self.privacy_on, "Privacy mode (host monitor off)").clicked() { + self.with_session(|s| s.privacy()); + } + + // record → real .mp4 (H.264 remux, no re-encode) + if self.tb_toggle(ui, "⏺", "Rec", self.recording, "Record this session (VP9->WebM+audio / H.264->MP4, no re-encode)").clicked() { + if self.recording { + self.stop_recording(); + } else { + self.start_recording(); + } + } + + // relative / game mouse (raw deltas, hidden cursor) + if self + .tb_toggle(ui, "🎮", "Game", self.game_mouse, "Relative / game mouse (raw deltas, hidden cursor)") + .clicked() + { + self.game_mouse = !self.game_mouse; + } + + // translate / international keyboard (Unicode text) + if self + .tb_toggle(ui, "🌐", "Intl", self.translate_kbd, "Translate keyboard: type international / AltGr / dead-key characters as Unicode") + .clicked() + { + self.translate_kbd = !self.translate_kbd; + } + + // collaborative annotation (draw on the host's screen) + if self + .tb_toggle(ui, "✏", "Draw", self.annotate, "Annotate: draw on the host's screen (collaborative overlay)") + .clicked() + { + self.annotate = !self.annotate; + if !self.annotate { + self.annot_strokes.clear(); + self.annot_cur.clear(); + self.annot_drawing = false; + self.with_session(|s| s.annotate_clear()); + } + } + + // monitor cycle (only with 2+ displays) + if self.monitors.len() >= 2 { + let n = self.monitors.len(); + let idx = self + .monitors + .iter() + .position(|m| m.i == self.cur_mon) + .unwrap_or(0); + if self.tb_toggle(ui, "🔁", &format!("{}/{}", idx + 1, n), false, "Switch monitor").clicked() { + let next = self.monitors[(idx + 1) % n].i; + self.switch_monitor(next); + } + } + + // fullscreen + let fs_lbl = if self.fullscreen { "Window" } else { "Full" }; + if self.tb_toggle(ui, "⛶", fs_lbl, self.fullscreen, "Fullscreen").clicked() { + self.fullscreen = !self.fullscreen; + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); + } + + // ===================== RIGHT: stats + disconnect + collapse === + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // collapse tab + if ui.add(egui::Button::new("▲").frame(false)).on_hover_text("Hide the toolbar").clicked() { + self.bar_collapsed = true; + } + + // disconnect + let disc = egui::Button::new(RichText::new(format!("{} Disconnect", ph::SIGN_OUT)).color(Color32::WHITE).strong()) + .fill(C_DANGER) + .rounding(Rounding::same(8.0)); + if ui.add(disc).clicked() { + self.go_home(); + } + + ui.separator(); + + // live stats + let e2e = if stats.e2e { "🔒E2E" } else { "TLS" }; + let path = if stats.p2p { " · ⚡Direct P2P" } else { "" }; + let line = if stats.connected { + format!( + "{}×{} · {:.0} fps · {:.1} Mbps · {:.0} ms · {}{}", + stats.res_w, stats.res_h, stats.fps, stats.mbps, self.lat_ms, e2e, path + ) + } else if let Some(reason) = &stats.ended { + format!("ended: {reason}") + } else if !stats.status.is_empty() { + stats.status.clone() + } else { + "connecting…".to_string() + }; + let col = if self.lat_ms < 80.0 { + c_accent() + } else if self.lat_ms < 180.0 { + Color32::from_rgb(220, 190, 90) + } else { + Color32::from_rgb(220, 120, 90) + }; + ui.label(RichText::new(line).color(col).monospace()); + }); + }); + }); + } + + /// A compact toolbar toggle button: emoji glyph + short label, highlighted + /// green when `on`. + fn tb_toggle(&self, ui: &mut egui::Ui, glyph: &str, label: &str, on: bool, tip: &str) -> egui::Response { + let txt = RichText::new(format!("{glyph} {label}")).color(if on { Color32::WHITE } else { C_TEXT }); + let mut btn = egui::Button::new(txt).rounding(Rounding::same(7.0)); + if on { + btn = btn.fill(c_accent().linear_multiply(0.85)).stroke(Stroke::new(1.0_f32, c_accent())); + } + ui.add(btn).on_hover_text(tip) + } + + fn switch_monitor(&mut self, i: i32) { + self.cur_mon = i; + self.with_session(|s| { + s.monitor_switch(i); + s.request_keyframe(); + }); + } + + // ------- menus ------- + fn menu_actions(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Actions ▾", ph::LIGHTNING), |ui| { + if ui.button(format!("{} Lock remote computer", ph::LOCK)).clicked() { + self.with_session(|s| s.lock()); + ui.close_menu(); + } + if ui.button(format!("{} Sign out on remote computer", ph::SIGN_OUT)).clicked() { + self.pending_confirm = Some(Confirm::Logoff); + ui.close_menu(); + } + if ui.button(format!("{} Remote reboot", ph::ARROW_CLOCKWISE)).clicked() { + self.pending_confirm = Some(Confirm::Reboot); + ui.close_menu(); + } + if ui.button(format!("{} Shut down", ph::POWER)).clicked() { + self.pending_confirm = Some(Confirm::Shutdown); + ui.close_menu(); + } + if ui.button(format!("{} Send Ctrl + Alt + Del", ph::KEYBOARD)).clicked() { + self.with_session(|s| s.ctrl_alt_del()); + ui.close_menu(); + } + ui.separator(); + let mut blk = self.block_input_on; + if ui.checkbox(&mut blk, "Block remote user's input").clicked() { + self.with_session(|s| s.block_input()); + ui.close_menu(); + } + let mut loe = self.lock_on_end; + if ui.checkbox(&mut loe, "Lock on session end").clicked() { + self.lock_on_end = loe; + self.with_session(|s| s.set_lock_on_end(loe)); + ui.close_menu(); + } + ui.separator(); + if ui.button(format!("{} Session information…", ph::INFO)).clicked() { + self.show_info = true; + ui.close_menu(); + } + }); + } + + fn menu_view(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} View ▾", ph::MONITOR), |ui| { + ui.label(RichText::new("Monitor").color(C_MUTED).size(11.0)); + if self.monitors.is_empty() { + ui.label(RichText::new(" (single display)").color(C_MUTED)); + } else { + let mut switch_to = None; + for m in &self.monitors { + let on = self.cur_mon == m.i; + if ui + .selectable_label( + on, + if m.i < 0 { + format!("All displays ({}×{})", m.w, m.h) + } else { + format!("Display {} ({}×{})", m.i, m.w, m.h) + }, + ) + .clicked() + { + switch_to = Some(m.i); + } + } + if let Some(i) = switch_to { + self.switch_monitor(i); + ui.close_menu(); + } + } + + ui.separator(); + ui.label(RichText::new("Quality").color(C_MUTED).size(11.0)); + let mut new_q = None; + for q in Quality::ALL { + if ui.selectable_label(self.quality == q, q.label()).clicked() { + new_q = Some(q); + } + } + if let Some(q) = new_q { + self.apply_quality(q); + ui.close_menu(); + } + + ui.separator(); + ui.label(RichText::new("Scaling").color(C_MUTED).size(11.0)); + if ui.selectable_label(self.scale_mode == ScaleMode::Fit, "Best fit").clicked() { + self.scale_mode = ScaleMode::Fit; + ui.close_menu(); + } + if ui.selectable_label(self.scale_mode == ScaleMode::Orig, "Original (1:1, scroll)").clicked() { + self.scale_mode = ScaleMode::Orig; + ui.close_menu(); + } + + ui.separator(); + ui.label(RichText::new("Screen resolution").color(C_MUTED).size(11.0)); + if !self.modes_loaded { + if ui.button("Load resolutions…").clicked() { + self.with_session(|s| s.list_modes()); + self.banner("Loading resolutions…", true, 2); + ui.close_menu(); + } + } else if self.modes.is_empty() { + ui.label(RichText::new(" No modes reported").color(C_MUTED)); + } else { + let mut set_to = None; + for (w, h) in self.modes.clone().into_iter().take(18) { + let cur = self.cur_mode == Some((w, h)); + if ui.selectable_label(cur, format!("{w} × {h}")).clicked() { + set_to = Some((w, h)); + } + } + if let Some((w, h)) = set_to { + self.pending_confirm = Some(Confirm::SetMode(w, h)); + ui.close_menu(); + } + } + + ui.separator(); + let mut wp = self.wallpaper_hidden; + if ui.checkbox(&mut wp, "Hide wallpaper").clicked() { + self.with_session(|s| s.hide_wallpaper()); + ui.close_menu(); + } + if ui.button(format!("{} Refresh screen", ph::ARROWS_CLOCKWISE)).clicked() { + self.with_session(|s| s.refresh_screen()); + self.banner("Screen refreshed", true, 1); + ui.close_menu(); + } + }); + } + + fn menu_communicate(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Communicate ▾", ph::CHAT_CIRCLE), |ui| { + if ui.button(format!("{} Send message… (chat)", ph::CHAT_CIRCLE)).clicked() { + self.show_chat = true; + self.chat_unread = 0; + ui.close_menu(); + } + let mut snd = self.sound_on; + if ui.checkbox(&mut snd, "Play remote computer sound").clicked() { + self.sound_on = snd; + self.shared + .sound_on + .store(snd, std::sync::atomic::Ordering::Relaxed); + self.banner( + if snd { "Remote sound on" } else { "Remote sound muted" }, + true, + 2, + ); + ui.close_menu(); + } + ui.separator(); + let voice_on = self.shared.voice_stop.lock().unwrap().is_some(); + if ui + .button(if voice_on { "End voice call" } else { "Voice call (your mic)" }) + .clicked() + { + self.toggle_voice(); + ui.close_menu(); + } + }); + } + + fn menu_extras(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Extras ▾", ph::WRENCH), |ui| { + if ui.button(format!("{} File transfer", ph::FOLDER_OPEN)).clicked() { + self.open_files(); + ui.close_menu(); + } + if ui.button(format!("{} Chat", ph::CHAT_CIRCLE)).clicked() { + self.show_chat = true; + self.chat_unread = 0; + ui.close_menu(); + } + if ui.button(format!("{} Save screenshot", ph::CAMERA)).clicked() { + self.save_screenshot(); + ui.close_menu(); + } + if ui.button(format!("{} Record session", ph::RECORD)).clicked() { + self.recording = !self.recording; + self.banner( + if self.recording { "Recording — capture lands in a later wave" } else { "Recording stopped" }, + true, + 2, + ); + ui.close_menu(); + } + }); + } + + fn menu_help(&mut self, ui: &mut egui::Ui) { + ui.menu_button(format!("{} Help ▾", ph::QUESTION), |ui| { + if ui.button("About NetDesk…").clicked() { + self.show_about = true; + ui.close_menu(); + } + }); + } + + fn save_screenshot(&mut self) { + let (w, h) = self.tex_size; + if w == 0 || h == 0 { + self.banner("No frame yet", false, 2); + return; + } + // grab the newest decoded frame rgb (re-read the shared slot if present, + // otherwise we can't reconstruct from the GPU texture, so require a frame) + let rgb = { + let g = self.shared.frame.lock().unwrap(); + g.as_ref().map(|f| (f.w, f.h, f.rgb.clone())) + }; + // The UI usually consumes the frame before this runs, so keep a shadow + // copy of the last uploaded frame. + let (fw, fh, data) = match rgb.or_else(|| self.last_frame_rgb.clone()) { + Some(v) => v, + None => { + self.banner("No frame to save yet", false, 2); + return; + } + }; + let path = screenshot_path(); + match image::RgbImage::from_raw(fw, fh, data) { + Some(imgbuf) => match imgbuf.save(&path) { + Ok(_) => self.banner(format!("Screenshot saved: {}", path.display()), true, 4), + Err(e) => self.banner(format!("Save failed: {e}"), false, 4), + }, + None => self.banner("Screenshot failed (bad frame)", false, 3), + } + } +} + +// ----------------------------- panels / dialogs ----------------------------- +impl NetDeskApp { + fn draw_panels(&mut self, ctx: &egui::Context) { + // Panels clamp to the window so they can never open or resize wider / + // taller than the viewport (they stay movable within bounds — egui + // constrains windows to the screen rect). + let scr = ctx.screen_rect(); + let clamp_w = (scr.width() - 24.0).max(200.0); + let clamp_h = (scr.height() - 60.0).max(160.0); + // ---- Chat ---- + let mut show_chat = self.show_chat; + if show_chat { + egui::Window::new(format!("{} Chat", ph::CHAT_CIRCLE)) + .open(&mut show_chat) + .default_width(320.0_f32.min(clamp_w)) + .default_height(380.0_f32.min(clamp_h)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + let send_h = 36.0; + egui::ScrollArea::vertical() + .max_height(ui.available_height() - send_h) + .stick_to_bottom(true) + .show(ui, |ui| { + for line in &self.chat_log { + let who_col = if line.mine { c_accent() } else { C_MUTED }; + ui.horizontal_wrapped(|ui| { + ui.label(RichText::new(format!("{}:", if line.mine { "You" } else { &line.who })).color(who_col).strong()); + ui.label(RichText::new(&line.text).color(C_TEXT)); + }); + } + if self.chat_log.is_empty() { + ui.label(RichText::new("No messages yet.").color(C_MUTED)); + } + }); + ui.separator(); + ui.horizontal(|ui| { + let resp = ui.add( + egui::TextEdit::singleline(&mut self.chat_input) + .hint_text("Type a message…") + .desired_width(ui.available_width() - 60.0), + ); + let send = ui.button("Send").clicked() + || (resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter))); + if send { + let txt = self.chat_input.trim().to_string(); + if !txt.is_empty() { + self.with_session(|s| s.chat(&txt)); + self.chat_log.push(ChatLine { + who: "You".into(), + text: txt, + mine: true, + }); + self.chat_input.clear(); + resp.request_focus(); + } + } + }); + }); + self.show_chat = show_chat; + } + + // ---- Remote power / processes ---- + let mut show_sys = self.show_sys; + if show_sys { + egui::Window::new(format!("{} Remote power", ph::POWER)) + .open(&mut show_sys) + .default_width(340.0_f32.min(clamp_w)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + ui.label(RichText::new("Power actions").strong().color(C_TEXT)); + ui.horizontal_wrapped(|ui| { + if ui.button("Lock workstation").clicked() { + self.with_session(|s| s.lock()); + } + if ui.button("Sign out user").clicked() { + self.pending_confirm = Some(Confirm::Logoff); + } + if ui.button("Reboot").clicked() { + self.pending_confirm = Some(Confirm::Reboot); + } + let sd = egui::Button::new(RichText::new("Shut down").color(Color32::WHITE)).fill(C_DANGER); + if ui.add(sd).clicked() { + self.pending_confirm = Some(Confirm::Shutdown); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.run_cmd) + .hint_text("run a command on host (cmd /c …)") + .desired_width(ui.available_width() - 54.0), + ); + if ui.button("Run").clicked() { + let c = self.run_cmd.trim().to_string(); + if !c.is_empty() { + self.with_session(|s| s.run_command(&c)); + self.banner("Command sent", true, 2); + } + } + }); + + ui.separator(); + let mut loe = self.lock_on_end; + if ui.checkbox(&mut loe, "Lock PC when I disconnect").clicked() { + self.lock_on_end = loe; + self.with_session(|s| s.set_lock_on_end(loe)); + } + + ui.separator(); + ui.horizontal(|ui| { + ui.label(RichText::new("Processes").strong().color(C_TEXT)); + if ui.button(format!("{} Refresh", ph::ARROWS_CLOCKWISE)).clicked() { + self.with_session(|s| s.list_processes()); + } + }); + let mut kill_pid: Option<(u32, String)> = None; + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + if self.processes.is_empty() { + ui.label(RichText::new("No processes loaded.").color(C_MUTED)); + } + for p in &self.processes { + ui.horizontal(|ui| { + ui.label(RichText::new(&p.name).color(C_TEXT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("⛔").on_hover_text("End process").clicked() { + kill_pid = Some((p.pid, p.name.clone())); + } + ui.label( + RichText::new(format!("{} · {} MB", p.pid, p.mem_kb / 1024)) + .color(C_MUTED) + .size(11.0), + ); + }); + }); + } + }); + if let Some((pid, name)) = kill_pid { + self.pending_confirm = Some(Confirm::Kill(pid, name)); + } + }); + self.show_sys = show_sys; + } + + // ---- File transfer (remote file browser) ---- + let mut show_files = self.show_files; + if show_files { + // actions are collected inside the closure and applied afterwards so + // the closure only needs disjoint field borrows (no &mut self calls). + let mut act_home = false; + let mut act_up = false; + let mut act_go = false; + let mut act_refresh = false; + let mut act_mkdir = false; + let mut act_upload = false; + let mut act_clear = false; + let mut nav: Option = None; + let mut download_req: Option<(String, String, u64)> = None; + let mut rename_req: Option<(String, String)> = None; + let mut delete_req: Option<(String, bool, String)> = None; + + egui::Window::new(format!("{} File transfer", ph::FOLDER_OPEN)) + .open(&mut show_files) + .default_width(460.0_f32.min(clamp_w)) + .default_height(520.0_f32.min(clamp_h)) + .max_width(clamp_w) + .max_height(clamp_h) + .resizable(true) + .show(ctx, |ui| { + // --- navigation row --- + ui.horizontal(|ui| { + if ui.button(format!("{} Drives", ph::HARD_DRIVES)).on_hover_text("List drives / home").clicked() { + act_home = true; + } + if ui.button(format!("{} Up", ph::ARROW_UP)).on_hover_text("Parent folder").clicked() { + act_up = true; + } + if ui.button(ph::ARROWS_CLOCKWISE).on_hover_text("Refresh this folder").clicked() { + act_refresh = true; + } + }); + // --- address bar --- + // Lay the "Go" button out first (right-to-left) and let the + // field fill the REMAINING width. The old form sized the field + // to `available_width() - 46` and then added the button after + // it, so the row overran the window by a few px each frame and + // the resizable window kept growing to the right. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Go").clicked() { + act_go = true; + } + let resp = ui.add( + egui::TextEdit::singleline(&mut self.file_path) + .hint_text("remote path, e.g. C:\\Users") + .desired_width((ui.available_width() - 6.0).max(80.0)), + ); + if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + act_go = true; + } + }); + }); + ui.separator(); + + // --- file list --- + let list_h = (ui.available_height() - 150.0).max(100.0); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(list_h) + .show(ui, |ui| { + if self.file_loading && self.file_entries.is_empty() { + ui.label(RichText::new("Loading…").color(C_MUTED)); + } else if self.file_entries.is_empty() { + ui.label(RichText::new("Empty folder.").color(C_MUTED)); + } + for e in &self.file_entries { + let full = join_path(&self.file_path, &e.name); + let icon = if e.is_dir { "📁" } else { "📄" }; + // Actions are laid out from the right so they always + // keep their space; the name fills the left and + // truncates. The whole row takes a double-click. + let row = ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("🗑").on_hover_text("Delete").clicked() { + delete_req = Some((full.clone(), e.is_dir, e.name.clone())); + } + if ui.small_button("✏").on_hover_text("Rename").clicked() { + rename_req = Some((full.clone(), e.name.clone())); + } + if !e.is_dir { + if ui.small_button("⬇").on_hover_text("Download").clicked() { + download_req = Some((full.clone(), e.name.clone(), e.size)); + } + ui.label( + RichText::new(fmt_bytes(e.size)).color(C_MUTED).size(11.0), + ); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add( + egui::Label::new( + RichText::new(format!("{icon} {}", e.name)) + .color(C_TEXT), + ) + .truncate(), + ); + }); + }); + }); + let tip = if e.is_dir { "Double-click to open" } else { "Double-click to download" }; + if row.response.interact(Sense::click()).on_hover_text(tip).double_clicked() { + if e.is_dir { + nav = Some(full.clone()); + } else { + download_req = Some((full.clone(), e.name.clone(), e.size)); + } + } + } + }); + ui.separator(); + + // --- footer actions --- + ui.horizontal(|ui| { + if ui.button(format!("{} New folder", ph::FOLDER_PLUS)).clicked() { + act_mkdir = true; + } + if ui.button(format!("{} Upload…", ph::UPLOAD_SIMPLE)).clicked() { + act_upload = true; + } + }); + + // --- transfers + progress --- + if !self.transfers.is_empty() { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Transfers").strong().color(C_TEXT)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("Clear done").clicked() { + act_clear = true; + } + }); + }); + // show the most recent transfers (newest last, capped) + let start = self.transfers.len().saturating_sub(6); + for t in &self.transfers[start..] { + let frac = if t.total > 0 { + (t.done as f32 / t.total as f32).clamp(0.0, 1.0) + } else if matches!(t.state, TransferState::Active) { + 0.0 + } else { + 1.0 + }; + let arrow = if t.up { "⬆" } else { "⬇" }; + let (status, col) = match &t.state { + TransferState::Active => { + (format!("{} / {}", fmt_bytes(t.done), fmt_bytes(t.total)), C_MUTED) + } + TransferState::Done => ("done".to_string(), c_accent()), + TransferState::Failed(m) => (format!("failed: {m}"), C_DANGER), + }; + ui.label( + RichText::new(format!("{arrow} {} · {status}", t.name)) + .color(col) + .size(12.0), + ); + ui.add( + egui::ProgressBar::new(frac) + .desired_height(8.0) + .fill(c_accent()), + ); + } + } + + // --- status line --- + if !self.file_status.is_empty() { + ui.add_space(3.0); + let col = if self.file_status_err { C_DANGER } else { C_MUTED }; + ui.label(RichText::new(&self.file_status).color(col).size(12.0)); + } + }); + self.show_files = show_files; + + // apply the collected actions (free to borrow &mut self here) + if act_home { + self.file_path.clear(); + self.file_loading = true; + self.file_status.clear(); + self.file_status_err = false; + self.with_session(|s| s.list_roots()); + } + if act_up { + let p = parent_path(&self.file_path); + self.list_dir_ui(p); + } + if act_go { + let p = self.file_path.trim().to_string(); + self.list_dir_ui(p); + } + if act_refresh { + self.refresh_files(); + } + if let Some(p) = nav { + self.list_dir_ui(p); + } + if let Some((full, name, size)) = download_req { + self.download_entry(full, name, size); + } + if act_mkdir { + self.file_prompt = Some(FilePrompt::Mkdir { name: String::new() }); + self.file_prompt_focus = true; + } + if let Some((src, name)) = rename_req { + self.file_prompt = Some(FilePrompt::Rename { src, name }); + self.file_prompt_focus = true; + } + if let Some((path, is_dir, name)) = delete_req { + self.pending_confirm = Some(Confirm::DeleteFile { path, is_dir, name }); + } + if act_clear { + self.transfers + .retain(|t| matches!(t.state, TransferState::Active)); + } + if act_upload { + self.pick_and_upload(ctx); + } + } + + // ---- About dialog ---- + let mut show_about = self.show_about; + if show_about { + let logo = self.logo_texture(ctx); + egui::Window::new("About NetDesk") + .open(&mut show_about) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(6.0); + if let Some(tex) = &logo { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + egui::vec2(72.0, 72.0), + ))); + } + ui.add_space(8.0); + ui.label(RichText::new(brand_name()).size(26.0).strong().color(C_TEXT)); + ui.label(RichText::new("Developed by Akronlogic.com").size(14.0).strong().color(c_accent())); + ui.add_space(6.0); + ui.label(RichText::new(format!("Native viewer · v{}", netdesk_core::VERSION)).color(C_MUTED)); + ui.label(RichText::new("Fast, self-hosted, end-to-end encrypted").color(C_MUTED).size(12.0)); + ui.add_space(6.0); + ui.hyperlink_to("akronlogic.com", "https://akronlogic.com"); + ui.add_space(6.0); + }); + }); + self.show_about = show_about; + } + + // ---- Session information ---- + let mut show_info = self.show_info; + if show_info { + egui::Window::new(format!("{} Session information", ph::INFO)) + .open(&mut show_info) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + let info = self.info.clone().unwrap_or_default(); + let stats = self.shared.stats.lock().unwrap().clone(); + let dur = self + .session_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + // host-reported capture geometry (falls back to the live + // decoded resolution before the first session.info arrives) + let cap_w = if info.w > 0 { info.w } else { stats.res_w }; + let cap_h = if info.h > 0 { info.h } else { stats.res_h }; + let e2e = info.e2e || stats.e2e; + let mut enc_rate = String::new(); + if info.fps > 0 { + enc_rate = format!("{} fps", info.fps); + } + if info.bitrate > 0 { + if !enc_rate.is_empty() { + enc_rate.push_str(" · "); + } + enc_rate.push_str(&format!("{} kbps", info.bitrate)); + } + if enc_rate.is_empty() { + enc_rate = "—".into(); + } + let rows: Vec<(String, String)> = vec![ + ("Host".into(), if info.host_name.is_empty() { stats.host_name.clone() } else { info.host_name.clone() }), + ("Remote resolution".into(), format!("{} × {}", cap_w, cap_h)), + ("Monitor".into(), format!("{}", self.cur_mon)), + ("Encoder".into(), enc_rate), + ("Encryption".into(), if e2e { "ECDH P-256 + AES-256-GCM (end-to-end)".into() } else { "TLS 1.3 (transport)".into() }), + ("Remote audio".into(), if info.audio { "streaming".into() } else { "off".into() }), + ("Live".into(), format!("{:.0} fps · {:.1} Mbps · {:.0} ms", stats.fps, stats.mbps, self.lat_ms)), + ("Session time".into(), format!("{}m {}s", dur / 60, dur % 60)), + ("Lock on session end".into(), if self.lock_on_end { "on".into() } else { "off".into() }), + ("Clipboard allowed".into(), if info.clipboard_allowed { "yes".into() } else { "yes*".into() }), + ("File transfer allowed".into(), if info.files_allowed { "yes".into() } else { "yes*".into() }), + ]; + egui::Grid::new("info-grid").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { + for (k, v) in rows { + ui.label(RichText::new(k).color(C_MUTED)); + ui.label(RichText::new(v).color(C_TEXT)); + ui.end_row(); + } + }); + }); + self.show_info = show_info; + } + + // ---- confirmation modal ---- + if let Some(confirm) = self.pending_confirm.clone() { + let (title, body) = match &confirm { + Confirm::Reboot => ("Reboot remote computer?", "The session will end when the host reboots.".to_string()), + Confirm::Shutdown => ("Shut down remote computer?", "The session will end and the host will power off.".to_string()), + Confirm::Logoff => ("Sign out the remote user?", "All of the remote user's apps will close.".to_string()), + Confirm::Kill(_, name) => ("End process?", format!("End \"{name}\" on the remote computer?")), + Confirm::SetMode(w, h) => ("Change resolution?", format!("Change the remote screen to {w} × {h}?")), + Confirm::DeleteFile { name, is_dir, .. } => ( + "Delete?", + format!( + "Delete \"{name}\"{} on the remote computer?", + if *is_dir { " and everything inside it" } else { "" } + ), + ), + }; + let mut open = true; + let mut decided: Option = None; + egui::Window::new(title) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.label(RichText::new(body).color(C_TEXT)); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + decided = Some(false); + } + let yes = egui::Button::new(RichText::new("Yes").color(Color32::WHITE).strong()).fill(C_DANGER); + if ui.add(yes).clicked() { + decided = Some(true); + } + }); + }); + if decided == Some(true) { + match &confirm { + Confirm::Reboot => self.with_session(|s| s.reboot()), + Confirm::Shutdown => self.with_session(|s| s.shutdown()), + Confirm::Logoff => self.with_session(|s| s.logoff()), + Confirm::Kill(pid, _) => { + let pid = *pid; + self.with_session(|s| s.kill_process(pid)); + let s2 = self.session.clone(); + // ask for a refreshed list shortly after + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(700)); + if let Some(s) = s2.lock().unwrap().as_ref() { + s.list_processes(); + } + }); + } + Confirm::SetMode(w, h) => { + let (w, h) = (*w, *h); + self.with_session(|s| s.set_mode(w, h)); + } + Confirm::DeleteFile { path, is_dir, .. } => { + let (p, d) = (path.clone(), *is_dir); + self.file_status = "Deleting…".into(); + self.file_status_err = false; + self.with_session(|s| s.delete(&p, d)); + } + } + self.pending_confirm = None; + } else if decided == Some(false) || !open { + self.pending_confirm = None; + } + } + + // ---- file name prompt (New folder / Rename) ---- + if let Some(mut prompt) = self.file_prompt.take() { + let (title, hint) = match &prompt { + FilePrompt::Mkdir { .. } => ("New folder", "Folder name"), + FilePrompt::Rename { .. } => ("Rename", "New name"), + }; + let focus = std::mem::take(&mut self.file_prompt_focus); + let mut open = true; + let mut submit = false; + let mut cancel = false; + egui::Window::new(title) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + let name_ref: &mut String = match &mut prompt { + FilePrompt::Mkdir { name } => name, + FilePrompt::Rename { name, .. } => name, + }; + let resp = ui.add( + egui::TextEdit::singleline(name_ref) + .hint_text(hint) + .desired_width(260.0), + ); + if focus { + resp.request_focus(); + } + if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + submit = true; + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + let ok = egui::Button::new(RichText::new("OK").color(Color32::WHITE).strong()) + .fill(c_accent()); + if ui.add(ok).clicked() { + submit = true; + } + }); + }); + if submit { + match &prompt { + FilePrompt::Mkdir { name } => { + let nm = name.trim().to_string(); + if !nm.is_empty() { + let p = join_path(&self.file_path, &nm); + self.file_status = format!("Creating {nm}…"); + self.file_status_err = false; + self.with_session(|s| s.make_dir(&p)); + } + } + FilePrompt::Rename { src, name } => { + let nm = name.trim().to_string(); + if !nm.is_empty() { + let dst = join_path(&self.file_path, &nm); + let src = src.clone(); + self.file_status = format!("Renaming to {nm}…"); + self.file_status_err = false; + self.with_session(|s| s.rename(&src, &dst)); + } + } + } + // consumed (prompt dropped) + } else if cancel || !open { + // consumed (prompt dropped) + } else { + // keep the modal open for further editing + self.file_prompt = Some(prompt); + } + } + } +} + +// ----------------------------- input forwarding ----------------------------- +impl NetDeskApp { + fn forward_input(&mut self, ctx: &egui::Context, img_rect: Option, rw: u32, rh: u32) { + // A modal confirmation blocks all forwarding. + if self.pending_confirm.is_some() { + self.prev_mods = ctx.input(|i| i.modifiers); + return; + } + // egui tells us when one of its own widgets (a focused text field, an + // open menu, a window being dragged) should receive the input instead. + let want_kb = ctx.wants_keyboard_input(); + let want_ptr = ctx.wants_pointer_input(); + + let rect = match img_rect { + Some(r) if rw > 0 && rh > 0 => r, + _ => { + self.prev_mods = ctx.input(|i| i.modifiers); + return; + } + }; + + // --- keyboard (only when no egui widget wants it) --- + let mods = ctx.input(|i| i.modifiers); + if !want_kb { + // modifier diffing (egui has no standalone Ctrl/Shift/Alt key events) + if mods.ctrl != self.prev_mods.ctrl { + self.with_session(|s| s.key("ControlLeft", mods.ctrl)); + } + if mods.shift != self.prev_mods.shift { + self.with_session(|s| s.key("ShiftLeft", mods.shift)); + } + if mods.alt != self.prev_mods.alt { + self.with_session(|s| s.key("AltLeft", mods.alt)); + } + let key_events: Vec<(Key, bool)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed, .. } => Some((*key, *pressed)), + _ => None, + }) + .collect() + }); + // Translate / international-keyboard mode: printable characters go + // as literal Unicode text (so AltGr combos, dead keys and non-US + // layouts come through regardless of the host layout); their key + // events are suppressed to avoid double-typing, while control/nav + // keys and any modifier shortcut still go through as keys. + if self.translate_kbd { + let texts: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) => Some(t.clone()), + _ => None, + }) + .collect() + }); + for t in texts { + if !t.is_empty() { + self.with_session(|s| s.text(&t)); + } + } + } + let shortcut = mods.ctrl || mods.alt || mods.command; + for (key, pressed) in key_events { + if let Some(code) = key_to_dom(key) { + if self.translate_kbd && !shortcut && is_char_code(code) { + continue; // handled by Event::Text above + } + self.with_session(|s| s.key(code, pressed)); + } + } + } + self.prev_mods = mods; + + // --- pointer (only when no egui widget wants it) --- + if want_ptr { + return; + } + let (pos, primary, secondary, middle, scroll) = ctx.input(|i| { + ( + i.pointer.latest_pos(), + i.pointer.primary_down(), + i.pointer.secondary_down(), + i.pointer.middle_down(), + i.raw_scroll_delta, + ) + }); + + let mut buttons = 0u32; + if primary { + buttons |= BTN_LEFT; + } + if secondary { + buttons |= BTN_RIGHT; + } + if middle { + buttons |= BTN_MIDDLE; + } + + // Annotation mode: a drag draws ink (stored in remote coords + sent to + // the host overlay) instead of controlling the mouse. + if self.annotate { + let over = pos.map_or(false, |p| rect.contains(p)); + if primary && over { + if let Some(p) = pos { + self.annot_cur.push(map_to_remote(rect, p, rw, rh)); + } + self.annot_drawing = true; + } else if self.annot_drawing { + if !self.annot_cur.is_empty() { + let pts = std::mem::take(&mut self.annot_cur); + self.with_session(|s| s.annotate_stroke(0xFF_3333, 3, &pts)); + self.annot_strokes.push(pts); + } + self.annot_drawing = false; + } + return; + } + + // Relative / game mode: send raw frame-delta motion (scaled to remote + // pixels) instead of absolute coords, and hide the local cursor. + if self.game_mouse { + ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::None); + let delta = ctx.input(|i| i.pointer.delta()); + let sx = rw as f32 / rect.width().max(1.0); + let sy = rh as f32 / rect.height().max(1.0); + let dx = (delta.x * sx).round() as i32; + let dy = (delta.y * sy).round() as i32; + let b_changed = self.last_sent.map(|t| t.2) != Some(buttons); + if dx != 0 || dy != 0 || b_changed { + let wheel = if self.scale_mode == ScaleMode::Fit { scroll.y / 50.0 } else { 0.0 }; + self.with_session(|s| s.mouse_rel(dx, dy, buttons, wheel)); + self.last_sent = Some((0, 0, buttons)); + } + return; + } + + if let Some(p) = pos { + if rect.contains(p) { + let (x, y) = map_to_remote(rect, p, rw, rh); + let changed = self.last_sent != Some((x, y, buttons)); + // in 1:1 scroll mode the wheel pans locally, so don't also send it + let fit = self.scale_mode == ScaleMode::Fit; + let has_scroll = fit && (scroll.x.abs() > 0.1 || scroll.y.abs() > 0.1); + if changed || has_scroll { + let wheel = if fit { scroll.y / 50.0 } else { 0.0 }; + let hwheel = if fit { scroll.x / 50.0 } else { 0.0 }; + self.with_session(|s| s.mouse(x, y, buttons, wheel, hwheel)); + self.last_sent = Some((x, y, buttons)); + } + } + } + } +} + +// ----------------------------- net thread ----------------------------------- +fn spawn_net_thread( + cfg: ConnectConfig, + shared: Arc, + session_slot: Arc>>, + ctx: egui::Context, +) { + std::thread::Builder::new() + .name("netdesk-net".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(rt) => rt, + Err(e) => { + set_ended(&shared, format!("runtime: {e}")); + ctx.request_repaint(); + return; + } + }; + rt.block_on(async move { + // Auto-reconnect loop: a transient drop AFTER a successful connect + // re-dials with exponential backoff reusing the same credentials + // (RustDesk-style); a user disconnect (shared.stop) or an auth/ + // availability denial is terminal. Mirrors the web viewer's + // scheduleReconnect so the native viewer no longer dumps the user + // back to the connect form on a brief network blip. + let mut backoff = 1u64; + let mut ever_connected = false; + 'reconnect: loop { + if shared.stop.load(Ordering::Relaxed) { + set_ended(&shared, "disconnected".into()); + ctx.request_repaint(); + break; + } + let (tx, mut rx) = mpsc::unbounded_channel::(); + let session = match connect(cfg.clone(), tx).await { + Ok(s) => s, + Err(e) => { + if ever_connected && !shared.stop.load(Ordering::Relaxed) { + set_status(&shared, &format!("reconnecting… ({e})")); + ctx.request_repaint(); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + continue 'reconnect; + } + set_ended(&shared, format!("connect failed: {e}")); + ctx.request_repaint(); + break; + } + }; + session.request_keyframe(); + *session_slot.lock().unwrap() = Some(session); + let mut deny_reason: Option = None; + + let mut decoder = best_decoder(); + let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec) + // Lazily-opened remote-audio player (Opus decode + cpal output); + // created on the first audio packet while sound is on, dropped + // when the user mutes. Windows-only (vendored libopus). + #[cfg(windows)] + let mut audio_player: Option = None; + let mut frame_times: VecDeque = VecDeque::new(); + let mut byte_window: VecDeque<(Instant, usize)> = VecDeque::new(); + let rec_epoch = Instant::now(); // monotonic clock for recording timestamps + + while let Some(ev) = rx.recv().await { + match ev { + SessionEvent::Connecting => set_status(&shared, "connecting"), + SessionEvent::Challenged { e2e } => { + set_status(&shared, &format!("challenged (e2e={e2e})")) + } + SessionEvent::Connected { host_name, e2e, w, h } => { + // A successful connect arms auto-reconnect + resets backoff. + ever_connected = true; + backoff = 1; + { + let mut g = shared.stats.lock().unwrap(); + g.connected = true; + g.e2e = e2e; + g.host_name = host_name.clone(); + g.res_w = w; + g.res_h = h; + g.status = "connected".into(); + } + if let Some(s) = session_slot.lock().unwrap().as_ref() { + s.request_keyframe(); + } + println!(" CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}"); + ctx.request_repaint(); + } + SessionEvent::Video { au, keyframe, codec, .. } => { + // Follow the host's codec: it tags every frame and + // may switch H.264<->VP9 live by link quality. Swap + // the decoder only on a keyframe (a decoder can't + // start mid-GOP); drop non-key frames in a new codec + // and ask for a keyframe. + if codec != cur_codec { + if keyframe { + decoder = best_decoder_for(codec); + cur_codec = codec; + } else { + if let Some(s) = session_slot.lock().unwrap().as_ref() { + s.request_keyframe(); + } + continue; + } + } + let now = Instant::now(); + byte_window.push_back((now, au.len())); + // Tee into the session recording (Rec button), if active. + // The .mp4 muxer is H.264-only; feed it H.264 frames + // ONLY (we pin H.264 while recording, but guard here too + // so a stray VP9 frame can never corrupt the file). + if shared.recording.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(mut g) = shared.recorder.lock() { + // Create the recorder on the first keyframe for + // whatever codec is live, then pin that codec so + // the container stays consistent (no re-encode). + if g.is_none() && keyframe { + if let Some((stem, rw, rh, want_audio)) = + shared.rec_pending.lock().unwrap().take() + { + *g = Some(if codec == 1 { + SessionRec::Webm( + netdesk_core::webm::WebmRecorder::new( + stem.with_extension("webm"), + rw, + rh, + if want_audio { 2 } else { 0 }, + ), + ) + } else { + SessionRec::Mp4( + netdesk_core::recorder::Mp4Recorder::new( + stem.with_extension("mp4"), + rw, + rh, + ), + ) + }); + if let Some(s) = + session_slot.lock().unwrap().as_ref() + { + s.pin_codec(codec as i32); + } + } + } + if let Some(r) = g.as_mut() { + r.push_video( + &au, + keyframe, + rec_epoch.elapsed().as_millis() as u64, + ); + } + } + } + if let Ok(Some(frame)) = decoder.decode(&au) { + let fw = frame.w as u32; + let fh = frame.h as u32; + frame_times.push_back(now); + let cutoff = now - Duration::from_secs(1); + while frame_times.front().map_or(false, |t| *t < cutoff) { + frame_times.pop_front(); + } + while byte_window.front().map_or(false, |(t, _)| *t < cutoff) { + byte_window.pop_front(); + } + let fps = frame_times.len() as f32; + let bytes: usize = byte_window.iter().map(|(_, n)| *n).sum(); + let mbps = (bytes as f32 * 8.0) / 1_000_000.0; + { + let mut g = shared.stats.lock().unwrap(); + g.res_w = fw; + g.res_h = fh; + g.fps = fps; + g.mbps = mbps; + } + *shared.frame.lock().unwrap() = Some(Frame { + w: fw, + h: fh, + rgb: frame.rgb, + recv_at: now, + }); + ctx.request_repaint(); + } + } + // Remote system audio (Opus). Decode + play while the + // "Play remote computer sound" toggle is on; muting drops + // the player (stops the cpal stream). Windows-only. + #[cfg(windows)] + SessionEvent::Audio { data } => { + if shared.sound_on.load(std::sync::atomic::Ordering::Relaxed) { + if audio_player.is_none() { + match audio::AudioPlayer::new() { + Ok(p) => audio_player = Some(p), + Err(e) => { + eprintln!("[audio] cannot open output: {e}"); + } + } + } + if let Some(p) = audio_player.as_mut() { + p.push_opus(&data); + } + } else if audio_player.is_some() { + audio_player = None; // muted -> stop playback + } + // Tee the Opus into a live WebM recording's audio track + // (a no-op for an MP4 or video-only recording). + if shared.recording.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(mut g) = shared.recorder.lock() { + if let Some(r) = g.as_mut() { + r.push_audio( + &data, + rec_epoch.elapsed().as_millis() as u64, + ); + } + } + } + } + #[cfg(not(windows))] + SessionEvent::Audio { .. } => {} + SessionEvent::Cursor { x, y, hidden, .. } => { + if let Ok(mut c) = shared.cursor.lock() { + if hidden { + c.visible = false; + } else { + c.x = x as f32; + c.y = y as f32; + c.visible = true; + } + } + ctx.request_repaint(); + } + SessionEvent::CursorShape { png, hx, hy, .. } => { + if !png.is_empty() { + if let Ok(img) = image::load_from_memory(&png) { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + shared.push(UiEvent::CursorShape { + rgba: rgba.into_raw(), + w, + h, + hx: hx as f32, + hy: hy as f32, + }); + ctx.request_repaint(); + } + } + } + SessionEvent::ChatMessage { from, text } => { + shared.push(UiEvent::Chat { from, text }); + ctx.request_repaint(); + } + SessionEvent::Clipboard { text } => { + shared.push(UiEvent::Clipboard(text)); + ctx.request_repaint(); + } + // Folder-manifest replies are consumed by the netdesk-getdir + // CLI, not the GUI viewer. + SessionEvent::FileWalk { .. } => {} + SessionEvent::ClipboardImage { png } => { + // Put the host's clipboard image into THIS machine's + // clipboard (decode PNG -> RGBA -> arboard set_image). + if let Ok(img) = image::load_from_memory(&png) { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width() as usize, rgba.height() as usize); + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_image(arboard::ImageData { + width: w, + height: h, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }); + } + } + } + SessionEvent::Info { json } => { + if let Some(info) = parse_info(&json) { + shared.push(UiEvent::Info(info)); + ctx.request_repaint(); + } + } + SessionEvent::SysResult { json } => { + if let Some(ev) = parse_sysres(&json) { + shared.push(ev); + ctx.request_repaint(); + } + } + // ---- file transfer ---- + SessionEvent::FileList { path, entries } => { + shared.push(UiEvent::FileList { path, entries }); + ctx.request_repaint(); + } + SessionEvent::FileDownloadProgress { fid, received } => { + shared.push(UiEvent::FileProgress { fid, done: received }); + ctx.request_repaint(); + } + SessionEvent::FileDownloadDone { fid, name, size, path } => { + match move_download(&name, &path) { + Ok(dest) => shared.push(UiEvent::FileSaved { + fid, + name, + path: dest.display().to_string(), + size, + }), + Err(e) => { + let _ = std::fs::remove_file(&path); + shared.push(UiEvent::FileFail { + fid, + msg: format!("save failed: {e}"), + }); + } + } + ctx.request_repaint(); + } + SessionEvent::FileUploadAck { msg, refresh } => { + shared.push(UiEvent::FileOk { msg, refresh }); + ctx.request_repaint(); + } + SessionEvent::FileError { msg } => { + shared.push(UiEvent::FileErr { msg }); + ctx.request_repaint(); + } + // Interactive terminal (cmd) output. The egui terminal + // panel is a pending feature; the protocol is wired in + // core, so accept + drop the events here for now. + SessionEvent::TermData { .. } | SessionEvent::TermExit { .. } => {} + + SessionEvent::P2pState { active, direct } => { + if let Ok(mut st) = shared.stats.lock() { + st.p2p = active && direct; + } + ctx.request_repaint(); + } + SessionEvent::Denied { reason } => { + // Auth / availability decision — terminal, no retry. + deny_reason = Some(reason); + ctx.request_repaint(); + break; + } + SessionEvent::Ended { reason } => { + // Record it, but let the outer loop decide retry vs. stop. + set_status(&shared, &format!("link ended: {reason}")); + ctx.request_repaint(); + break; + } + } + } + // ---- inner event loop exited: reconnect or stop? ---- + if let ReconnectAction::Stop(kind) = reconnect_action( + shared.stop.load(Ordering::Relaxed), + deny_reason.is_some(), + ever_connected, + ) { + let msg = match (kind, &deny_reason) { + ("denied", Some(r)) => format!("denied: {r}"), + ("disconnected", _) => "disconnected".into(), + _ => "connection closed".into(), + }; + set_ended(&shared, msg); + ctx.request_repaint(); + break; + } + // Transient drop after a good session → reconnect with backoff. + // Keep the last frame on screen; clear `ended` so the UI shows + // "reconnecting…" instead of the Session-ended card. + *session_slot.lock().unwrap() = None; + { + let mut g = shared.stats.lock().unwrap(); + g.connected = false; + g.ended = None; + } + set_status(&shared, "connection lost — reconnecting…"); + ctx.request_repaint(); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(5); + } // 'reconnect loop + }); + }) + .expect("spawn net thread"); +} + +fn parse_info(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let mut info = SessionInfo { + w: v.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: v.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + mon: v.get("mon").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + fps: v.get("fps").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + bitrate: v.get("bitrate").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + host_name: v.get("host_name").and_then(|x| x.as_str()).unwrap_or("").to_string(), + e2e: v.get("e2e").and_then(|x| x.as_bool()).unwrap_or(false), + audio: v.get("audio").and_then(|x| x.as_bool()).unwrap_or(false), + lock_on_end: v.get("lock_on_end").and_then(|x| x.as_bool()).unwrap_or(false), + privacy_active: v.get("privacy_active").and_then(|x| x.as_bool()).unwrap_or(false), + logon_screen: v.get("logon_screen").and_then(|x| x.as_bool()).unwrap_or(false), + clipboard_allowed: true, + files_allowed: true, + monitors: Vec::new(), + }; + if let Some(perms) = v.get("perms") { + if perms.get("clipboard").and_then(|x| x.as_bool()) == Some(false) { + info.clipboard_allowed = false; + } + if perms.get("files").and_then(|x| x.as_bool()) == Some(false) { + info.files_allowed = false; + } + } + if let Some(arr) = v.get("monitors").and_then(|m| m.as_array()) { + for m in arr { + info.monitors.push(MonitorInfo { + i: m.get("i").and_then(|x| x.as_i64()).unwrap_or(0) as i32, + w: m.get("w").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + h: m.get("h").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + }); + } + } + Some(info) +} + +fn parse_sysres(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false); + let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or("").to_string(); + match kind.as_str() { + "processes" => { + let mut items = Vec::new(); + if let Some(arr) = v.get("items").and_then(|x| x.as_array()) { + for p in arr { + items.push(ProcInfo { + pid: p.get("pid").and_then(|x| x.as_u64()).unwrap_or(0) as u32, + name: p.get("name").and_then(|x| x.as_str()).unwrap_or("?").to_string(), + mem_kb: p.get("mem").and_then(|x| x.as_u64()).unwrap_or(0), + }); + } + } + Some(UiEvent::Processes(items)) + } + "modes" => { + let mut items = Vec::new(); + if let Some(arr) = v.get("items").and_then(|x| x.as_array()) { + for wh in arr { + if let Some(pair) = wh.as_array() { + let w = pair.get(0).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let h = pair.get(1).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + if w > 0 && h > 0 { + items.push((w, h)); + } + } + } + } + let current = v.get("current").and_then(|c| c.as_array()).and_then(|p| { + let w = p.get(0)?.as_u64()? as u32; + let h = p.get(1)?.as_u64()? as u32; + Some((w, h)) + }); + Some(UiEvent::Modes { items, current }) + } + "privacy" | "block_input" | "wallpaper" => { + let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(false); + Some(UiEvent::Toggle { kind, on, ok, msg }) + } + _ => Some(UiEvent::Generic { kind, ok, msg }), + } +} + +fn set_status(shared: &Arc, s: &str) { + shared.stats.lock().unwrap().status = s.to_string(); +} + +/// What the net thread does when its event loop exits. Pure so it's unit-tested. +#[derive(PartialEq, Debug, Clone, Copy)] +enum ReconnectAction { + /// Terminal: stop the net thread (carrying the reason kind for the message). + Stop(&'static str), + /// Transient drop after a good session: re-dial with backoff. + Retry, +} + +/// Decide whether to auto-reconnect after the session event loop ended. +/// Precedence: an auth/availability denial and a user-initiated disconnect are +/// both terminal; a drop before we ever connected is terminal (initial-connect +/// failure, surfaced on the form); otherwise a drop after a good session retries. +fn reconnect_action(user_stopped: bool, denied: bool, ever_connected: bool) -> ReconnectAction { + if denied { + ReconnectAction::Stop("denied") + } else if user_stopped { + ReconnectAction::Stop("disconnected") + } else if !ever_connected { + ReconnectAction::Stop("closed") + } else { + ReconnectAction::Retry + } +} + +fn set_ended(shared: &Arc, reason: String) { + let mut g = shared.stats.lock().unwrap(); + g.connected = false; + g.ended = Some(reason); +} + +// ----------------------------- file transfer helpers ------------------------ + +/// Join a remote directory and an entry name exactly like the web viewer's +/// `joinPath(dir, name)` (server/web/app.js): an empty dir yields just the +/// name, a dir already ending in `/` or `\` is concatenated directly, otherwise +/// a forward slash is inserted (the host accepts mixed separators on Windows). +fn join_path(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +/// Parent of a remote path — strips the last path component, mirroring the web +/// viewer's `#btnFileUp` regex `replace(/[\\/][^\\/]*[\\/]?$/, "")`. From a +/// drive root (`C:\`) this returns `""`, which lists the drives again. +fn parent_path(p: &str) -> String { + if p.is_empty() { + return String::new(); + } + let end = if p.ends_with('/') || p.ends_with('\\') { + p.len() - 1 + } else { + p.len() + }; + let head = &p[..end]; + match head.rfind(|c| c == '/' || c == '\\') { + Some(i) => head[..i].to_string(), + None => String::new(), + } +} + +/// Human-readable byte count, matching the web viewer's `fmtBytes`. +fn fmt_bytes(n: u64) -> String { + let f = n as f64; + if f > 1e9 { + format!("{:.2} GB", f / 1e9) + } else if f > 1e6 { + format!("{:.1} MB", f / 1e6) + } else if f > 1e3 { + format!("{:.1} KB", f / 1e3) + } else { + format!("{n} B") + } +} + +/// The local folder completed downloads are written to: `%USERPROFILE%\Downloads` +/// (falling back to the profile root, then the working directory). +fn downloads_dir() -> PathBuf { + if let Ok(up) = std::env::var("USERPROFILE") { + let d = Path::new(&up).join("Downloads"); + if d.is_dir() { + return d; + } + return PathBuf::from(up); + } + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +/// A non-clobbering path in `dir` for `name`: if `name` exists, insert +/// ` (1)`, ` (2)`, … before the extension (Explorer-style). +fn unique_path(dir: &Path, name: &str) -> PathBuf { + let candidate = dir.join(name); + if !candidate.exists() { + return candidate; + } + let p = Path::new(name); + let stem = p + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| name.to_string()); + let ext = p.extension().map(|s| s.to_string_lossy().to_string()); + for i in 1..100_000u32 { + let fname = match &ext { + Some(e) => format!("{stem} ({i}).{e}"), + None => format!("{stem} ({i})"), + }; + let c = dir.join(fname); + if !c.exists() { + return c; + } + } + candidate +} + +/// Move a completed download (streamed to a temp `.part` file on disk, never +/// buffered in RAM) into the Downloads folder, returning the final path. +fn move_download(name: &str, part: &std::path::Path) -> std::io::Result { + let dir = downloads_dir(); + let _ = std::fs::create_dir_all(&dir); + let safe = if name.trim().is_empty() { "file" } else { name }; + let path = unique_path(&dir, safe); + // rename is atomic on the same volume; fall back to copy+remove across volumes. + if std::fs::rename(part, &path).is_err() { + std::fs::copy(part, &path)?; + let _ = std::fs::remove_file(part); + } + Ok(path) +} + +/// Stream one local file to the host in `UPLOAD_CHUNK` pieces, reporting +/// progress through the shared inbox. Runs on its own thread (spawned by +/// `start_upload`) so the UI thread never blocks on disk I/O or encryption; the +/// session lock is taken per chunk so other UI actions can interleave. +fn upload_worker( + session: Arc>>, + shared: Arc, + ctx: egui::Context, + local: PathBuf, + remote_dir: String, +) { + let name = local + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + + let size = match std::fs::metadata(&local) { + Ok(m) => m.len(), + Err(e) => { + shared.push(UiEvent::FileErr { msg: format!("{name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + + // allocate the fid + announce the destination (file.put) under the lock. + let fid = match session.lock().unwrap().as_ref() { + Some(s) => s.upload_begin(&remote_dir, &name, size), + None => { + shared.push(UiEvent::FileErr { msg: "no active session".into() }); + ctx.request_repaint(); + return; + } + }; + shared.push(UiEvent::TransferStart { fid, name: name.clone(), total: size, up: true }); + ctx.request_repaint(); + + let mut file = match std::fs::File::open(&local) { + Ok(f) => f, + Err(e) => { + shared.push(UiEvent::FileFail { fid, msg: format!("open {name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + + if size == 0 { + // zero-byte file: the host completes on a single empty data frame. + if let Some(s) = session.lock().unwrap().as_ref() { + s.upload_chunk(fid, 0, &[]); + } + shared.push(UiEvent::UploadSent { fid }); + ctx.request_repaint(); + return; + } + + let mut buf = vec![0u8; UPLOAD_CHUNK]; + let mut off: u64 = 0; + loop { + let n = match file.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(e) => { + shared.push(UiEvent::FileFail { fid, msg: format!("read {name}: {e}") }); + ctx.request_repaint(); + return; + } + }; + match session.lock().unwrap().as_ref() { + Some(s) => s.upload_chunk(fid, off, &buf[..n]), + None => { + shared.push(UiEvent::FileFail { fid, msg: "session closed".into() }); + ctx.request_repaint(); + return; + } + } + off += n as u64; + shared.push(UiEvent::FileProgress { fid, done: off }); + ctx.request_repaint(); + } + + shared.push(UiEvent::UploadSent { fid }); + ctx.request_repaint(); +} + +// ----------------------------- helpers -------------------------------------- +fn apply_theme(ctx: &egui::Context) { + // Load the Phosphor icon font as a fallback family so button glyphs (web, + // copy, folder, lock, power, arrows…) always render crisply instead of + // missing-glyph boxes. + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + ctx.set_fonts(fonts); + + let mut v = egui::Visuals::dark(); + v.panel_fill = C_BG; + v.window_fill = C_PANEL; + v.extreme_bg_color = C_FIELD; + v.faint_bg_color = C_FIELD; + v.window_stroke = Stroke::new(1.0_f32, C_STROKE); + v.override_text_color = Some(C_TEXT); + v.hyperlink_color = c_accent(); + // Neutral SLATE-BLUE text selection (not green) with no outline, and a + // neutral light caret. A green-tinted selection/caret was being read as a + // stray "green line" when selecting/copying the ID or Partner-ID. + v.selection.bg_fill = Color32::from_rgba_unmultiplied(78, 108, 150, 96); + v.selection.stroke = Stroke::NONE; + v.text_cursor.stroke = Stroke::new(2.0_f32, Color32::from_rgb(214, 222, 233)); + + let r = Rounding::same(7.0); + v.widgets.noninteractive.bg_fill = C_FIELD; + v.widgets.noninteractive.weak_bg_fill = C_FIELD; + v.widgets.inactive.bg_fill = C_FIELD; + v.widgets.inactive.weak_bg_fill = C_FIELD; + v.widgets.inactive.rounding = r; + v.widgets.hovered.bg_fill = C_HOVER; + v.widgets.hovered.weak_bg_fill = C_HOVER; + v.widgets.hovered.rounding = r; + v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, c_accent().linear_multiply(0.6)); + v.widgets.active.bg_fill = c_accent_hi(); + v.widgets.active.weak_bg_fill = c_accent_hi(); + v.widgets.active.rounding = r; + v.widgets.open.bg_fill = C_HOVER; + v.widgets.open.rounding = r; + + ctx.set_visuals(v); + + let mut style = (*ctx.style()).clone(); + style.spacing.button_padding = egui::vec2(8.0, 5.0); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + ctx.set_style(style); +} + +/// Fit an `iw x ih` image into `avail`, preserving aspect ratio, centered. +/// Fallback remote-cursor pointer (white triangle, tip at `tip`), shown until +/// the host's real cursor bitmap arrives. +fn draw_arrow_cursor(p: &egui::Painter, tip: egui::Pos2) { + let pts = vec![ + tip, + tip + egui::vec2(0.0, 17.0), + tip + egui::vec2(12.0, 12.0), + ]; + p.add(egui::Shape::convex_polygon( + pts, + Color32::WHITE, + egui::Stroke::new(1.0, Color32::from_black_alpha(200)), + )); +} + +fn fit_rect(avail: Rect, iw: f32, ih: f32) -> Rect { + let scale = (avail.width() / iw).min(avail.height() / ih).max(0.0); + let w = iw * scale; + let h = ih * scale; + let min = Pos2::new( + avail.left() + (avail.width() - w) * 0.5, + avail.top() + (avail.height() - h) * 0.5, + ); + Rect::from_min_size(min, Vec2::new(w, h)) +} + +/// Invert the letterbox: window pixel -> remote screen pixel. +fn map_to_remote(rect: Rect, p: Pos2, rw: u32, rh: u32) -> (i32, i32) { + let fx = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0); + let fy = ((p.y - rect.top()) / rect.height()).clamp(0.0, 1.0); + let x = (fx * rw as f32).round() as i32; + let y = (fy * rh as f32).round() as i32; + (x.clamp(0, rw as i32 - 1), y.clamp(0, rh as i32 - 1)) +} + +/// Where to drop a saved screenshot: the user's Pictures folder if we can find +/// it, else the current directory. +fn screenshot_path() -> std::path::PathBuf { + let ts = { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now + }; + let name = format!("netdesk-{ts}.png"); + if let Ok(up) = std::env::var("USERPROFILE") { + let pics = std::path::Path::new(&up).join("Pictures"); + if pics.is_dir() { + return pics.join(name); + } + return std::path::Path::new(&up).join(&name); + } + std::path::PathBuf::from(name) +} + +/// Map an egui logical key to a W3C UI-Events DOM `code` string (what the host +/// expects). Modifier keys are handled separately via `Modifiers` diffing. +/// Whether a DOM key code names a printable character key (letters, digits, +/// space, punctuation). In translate mode these are typed via Event::Text +/// (Unicode) rather than key events, so this decides which keys to suppress. +fn is_char_code(code: &str) -> bool { + code.starts_with("Key") + || code.starts_with("Digit") + || matches!( + code, + "Space" | "Minus" | "Equal" | "BracketLeft" | "BracketRight" | "Backslash" + | "Semicolon" | "Quote" | "Backquote" | "Comma" | "Period" | "Slash" + ) +} + +fn key_to_dom(key: Key) -> Option<&'static str> { + use Key::*; + let s = match key { + A => "KeyA", B => "KeyB", C => "KeyC", D => "KeyD", E => "KeyE", F => "KeyF", + G => "KeyG", H => "KeyH", I => "KeyI", J => "KeyJ", K => "KeyK", L => "KeyL", + M => "KeyM", N => "KeyN", O => "KeyO", P => "KeyP", Q => "KeyQ", R => "KeyR", + S => "KeyS", T => "KeyT", U => "KeyU", V => "KeyV", W => "KeyW", X => "KeyX", + Y => "KeyY", Z => "KeyZ", + Num0 => "Digit0", Num1 => "Digit1", Num2 => "Digit2", Num3 => "Digit3", + Num4 => "Digit4", Num5 => "Digit5", Num6 => "Digit6", Num7 => "Digit7", + Num8 => "Digit8", Num9 => "Digit9", + F1 => "F1", F2 => "F2", F3 => "F3", F4 => "F4", F5 => "F5", F6 => "F6", + F7 => "F7", F8 => "F8", F9 => "F9", F10 => "F10", F11 => "F11", F12 => "F12", + F13 => "F13", F14 => "F14", F15 => "F15", F16 => "F16", F17 => "F17", + F18 => "F18", F19 => "F19", F20 => "F20", + F21 => "F21", F22 => "F22", F23 => "F23", F24 => "F24", + ArrowUp => "ArrowUp", ArrowDown => "ArrowDown", + ArrowLeft => "ArrowLeft", ArrowRight => "ArrowRight", + Enter => "Enter", Escape => "Escape", Backspace => "Backspace", + Tab => "Tab", Space => "Space", + Delete => "Delete", Insert => "Insert", + Home => "Home", End => "End", PageUp => "PageUp", PageDown => "PageDown", + Minus => "Minus", Equals => "Equal", Plus => "Equal", + Comma => "Comma", Period => "Period", Semicolon => "Semicolon", + Backslash => "Backslash", Slash => "Slash", + OpenBracket => "BracketLeft", CloseBracket => "BracketRight", + Backtick => "Backquote", + Quote => "Quote", // apostrophe / double-quote key — was unmapped (dead) + _ => return None, + }; + Some(s) +} + +#[cfg(test)] +mod key_tests { + use super::{key_to_dom, Key}; + + #[test] + fn maps_quote_high_fkeys_and_core_keys() { + // newly added mappings (were returning None → dead keys) + assert_eq!(key_to_dom(Key::Quote), Some("Quote")); + assert_eq!(key_to_dom(Key::F21), Some("F21")); + assert_eq!(key_to_dom(Key::F24), Some("F24")); + // regression guard on existing mappings + assert_eq!(key_to_dom(Key::A), Some("KeyA")); + assert_eq!(key_to_dom(Key::Num0), Some("Digit0")); + assert_eq!(key_to_dom(Key::Enter), Some("Enter")); + assert_eq!(key_to_dom(Key::Backtick), Some("Backquote")); + } +} + +#[cfg(test)] +mod reconnect_tests { + use super::{reconnect_action, ReconnectAction}; + + #[test] + fn transient_drop_after_connect_retries() { + // Was connected, not a user stop, not denied -> auto-reconnect. + assert_eq!(reconnect_action(false, false, true), ReconnectAction::Retry); + } + + #[test] + fn user_disconnect_is_terminal() { + // Deliberate disconnect never reconnects, even after a good session. + assert_eq!(reconnect_action(true, false, true), ReconnectAction::Stop("disconnected")); + } + + #[test] + fn denial_is_terminal_and_wins() { + // Wrong password / host offline -> no retry, even if "ever_connected". + assert_eq!(reconnect_action(false, true, true), ReconnectAction::Stop("denied")); + assert_eq!(reconnect_action(false, true, false), ReconnectAction::Stop("denied")); + } + + #[test] + fn drop_before_first_connect_is_terminal() { + // Socket closed mid-handshake (never connected) -> surface on the form, + // don't loop forever on an unreachable host. + assert_eq!(reconnect_action(false, false, false), ReconnectAction::Stop("closed")); + } +} + +#[cfg(all(test, windows))] +mod config_cli_tests { + // Unattended `--config` injection must write config.json from flags and hash + // the password (never store it in plaintext). + #[test] + fn writes_config_from_flags_and_hashes_password() { + let dir = std::env::temp_dir().join(format!("nd-cfgtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + let ps = path.to_str().unwrap().to_string(); + let args: Vec = [ + "NetDesk.exe", "--config", "--path", &ps, + "--server", "wss://deploy.example/ws", "--reg-key", "ENTERPRISE1", + "--id", "123456789", "--name", "Lab-PC-07", "--password", "Sup3rSecret!", + "--allow-ips", "10.0.0.0/8", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + super::apply_cli_config(&args).expect("apply_cli_config"); + + let s = std::fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["server"], "wss://deploy.example/ws"); + assert_eq!(v["reg_key"], "ENTERPRISE1"); + assert_eq!(v["id"], "123456789"); + assert_eq!(v["name"], "Lab-PC-07"); + assert_eq!(v["allow_ips"], "10.0.0.0/8"); + // The password is hashed into a PBKDF2 record (salt+iters+verifier) that + // the server verifies — the same records the GUI's "set password" writes. + assert!( + v["verifier"].as_str().map(|x| !x.is_empty()).unwrap_or(false), + "password must be hashed into a verifier" + ); + assert!( + v["salt"].as_str().map(|x| !x.is_empty()).unwrap_or(false), + "a PBKDF2 salt must be present" + ); + assert_eq!(v["iters"], 200_000, "PBKDF2 iteration count persisted"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/native/vpx/Cargo.toml b/native/vpx/Cargo.toml new file mode 100644 index 0000000..21bf3a1 --- /dev/null +++ b/native/vpx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "netdesk-vpx" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "VP8/VP9 (libvpx) encode/decode for NetDesk — the constrained-link codec. Developed by Akronlogic." +links = "vpx" +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } + +[build-dependencies] +bindgen = "=0.65.1" diff --git a/native/vpx/build.rs b/native/vpx/build.rs new file mode 100644 index 0000000..fe08b82 --- /dev/null +++ b/native/vpx/build.rs @@ -0,0 +1,62 @@ +//! Link the vendored static libvpx (UCRT MinGW ABI) + generate FFI bindings +//! from the vendored vpx headers via bindgen (libclang). RustDesk does the same +//! (its vpx.rs include!s an OUT_DIR/vpx_ffi.rs). We vendor a UCRT-ABI libvpx.a +//! so there is no vcpkg/MSYS2 or runtime DLL dependency. +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let tp = manifest.join("..").join("third_party").join("ucrt64"); + let lib = tp.join("lib"); + let inc = tp.join("include"); + + println!("cargo:rustc-link-search=native={}", lib.display()); + println!("cargo:rustc-link-lib=static=vpx"); + println!("cargo:rustc-link-lib=pthread"); + println!("cargo:rerun-if-changed=wrapper.h"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", lib.join("libvpx.a").display()); + + // bindgen needs libclang; point at the installed LLVM if the env is unset. + if std::env::var("LIBCLANG_PATH").is_err() { + for p in ["C:/Program Files/LLVM/bin", "C:/Program Files (x86)/LLVM/bin"] { + if PathBuf::from(p).join("libclang.dll").exists() { + std::env::set_var("LIBCLANG_PATH", p); + break; + } + } + } + + // Based on RustDesk's proven bindgen config (libs/scrap/build.rs): a single + // `^[vV].*` allowlist for types/vars/functions, rustified enums. + // + // vpx_codec_enc_cfg and vpx_codec_dec_cfg are forward-declared (by pointer) + // in vpx_codec.h's iface struct BEFORE vpx_encoder.h/vpx_decoder.h define + // them. libclang 22 then reports them to bindgen as incomplete, so bindgen + // emits them as opaque `_address: u8` blobs with no fields — making g_w / + // rc_target_bitrate / threads unsettable. (bindgen version, trust_clang_ + // mangling, rustified_enum, and a by-value use all failed to change this.) + // So we blocklist both here and hand-write their exact #[repr(C)] layouts + // in src/lib.rs (ffi module) from the vendored headers. Everything else + // (vpx_image, cx_pkt, ctx, enums, functions) still comes from bindgen. + let regex = "^[vV].*"; + let bindings = bindgen::Builder::default() + .header("wrapper.h") + .clang_arg(format!("-I{}", inc.display())) + .allowlist_type(regex) + .allowlist_var(regex) + .allowlist_function(regex) + .rustified_enum(regex) + .blocklist_type("vpx_codec_enc_cfg") + .blocklist_type("vpx_codec_enc_cfg_t") + .blocklist_type("vpx_codec_dec_cfg") + .blocklist_type("vpx_codec_dec_cfg_t") + .trust_clang_mangling(false) + .layout_tests(false) + .generate_comments(false) + .generate() + .expect("bindgen libvpx"); + bindings + .write_to_file(PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("vpx_ffi.rs")) + .expect("write vpx_ffi.rs"); +} diff --git a/native/vpx/src/lib.rs b/native/vpx/src/lib.rs new file mode 100644 index 0000000..e547db0 --- /dev/null +++ b/native/vpx/src/lib.rs @@ -0,0 +1,742 @@ +//! VP8/VP9 software codec over the vendored static libvpx (UCRT MinGW). +//! +//! The raw FFI is generated by bindgen from the vendored headers (build.rs), +//! EXCEPT for `vpx_codec_enc_cfg` / `vpx_codec_dec_cfg`: those are forward- +//! declared by pointer in vpx_codec.h before their definitions, which libclang +//! reports to bindgen as opaque (`_address: u8`) blobs with no settable fields. +//! They are blocklisted in build.rs and hand-written below with the exact +//! `#[repr(C)]` layout from the vendored headers (ABI-identical: C `enum` and +//! `unsigned int` are both 4 bytes, `struct vpx_rational` is two i32, and +//! `vpx_fixed_buf_t` is `{void*, size_t}`). +//! +//! The safe Encoder/Decoder are ported from RustDesk's libs/scrap vpxcodec.rs +//! (VP9 CBR realtime config, cpu-used 7, row-mt, 4 tile columns). Color +//! conversions (BGRA<->I420, BT.601) are pure-Rust so there is no libyuv dep. +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +use anyhow::{anyhow, bail, Result}; +use std::os::raw::{c_int, c_uint}; + +pub mod ffi { + #![allow( + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + dead_code, + improper_ctypes + )] + use std::os::raw::{c_int, c_uint, c_void}; + + // ---- hand-written config structs (blocklisted from bindgen) ---- + // Distinct helper names (Nd*) so they never collide with bindgen's own + // vpx_rational / vpx_fixed_buf while staying ABI-identical. + #[repr(C)] + #[derive(Copy, Clone)] + pub struct NdVpxRational { + pub num: c_int, + pub den: c_int, + } + #[repr(C)] + #[derive(Copy, Clone)] + pub struct NdVpxFixedBuf { + pub buf: *mut c_void, + pub sz: usize, + } + + /// Mirror of `vpx_codec_enc_cfg_t` (vpx_encoder.h) — 61 fields, in order. + #[repr(C)] + #[derive(Copy, Clone)] + pub struct vpx_codec_enc_cfg { + pub g_usage: c_uint, + pub g_threads: c_uint, + pub g_profile: c_uint, + pub g_w: c_uint, + pub g_h: c_uint, + pub g_bit_depth: c_uint, // enum vpx_bit_depth + pub g_input_bit_depth: c_uint, + pub g_timebase: NdVpxRational, + pub g_error_resilient: c_uint, // vpx_codec_er_flags_t + pub g_pass: c_uint, // enum vpx_enc_pass + pub g_lag_in_frames: c_uint, + pub rc_dropframe_thresh: c_uint, + pub rc_resize_allowed: c_uint, + pub rc_scaled_width: c_uint, + pub rc_scaled_height: c_uint, + pub rc_resize_up_thresh: c_uint, + pub rc_resize_down_thresh: c_uint, + pub rc_end_usage: c_uint, // enum vpx_rc_mode + pub rc_twopass_stats_in: NdVpxFixedBuf, + pub rc_firstpass_mb_stats_in: NdVpxFixedBuf, + pub rc_target_bitrate: c_uint, + pub rc_min_quantizer: c_uint, + pub rc_max_quantizer: c_uint, + pub rc_undershoot_pct: c_uint, + pub rc_overshoot_pct: c_uint, + pub rc_buf_sz: c_uint, + pub rc_buf_initial_sz: c_uint, + pub rc_buf_optimal_sz: c_uint, + pub rc_2pass_vbr_bias_pct: c_uint, + pub rc_2pass_vbr_minsection_pct: c_uint, + pub rc_2pass_vbr_maxsection_pct: c_uint, + pub rc_2pass_vbr_corpus_complexity: c_uint, + pub kf_mode: c_uint, // enum vpx_kf_mode + pub kf_min_dist: c_uint, + pub kf_max_dist: c_uint, + pub ss_number_layers: c_uint, + pub ss_enable_auto_alt_ref: [c_int; 5], // VPX_SS_MAX_LAYERS + pub ss_target_bitrate: [c_uint; 5], + pub ts_number_layers: c_uint, + pub ts_target_bitrate: [c_uint; 5], // VPX_TS_MAX_LAYERS + pub ts_rate_decimator: [c_uint; 5], + pub ts_periodicity: c_uint, + pub ts_layer_id: [c_uint; 16], // VPX_TS_MAX_PERIODICITY + pub layer_target_bitrate: [c_uint; 12], // VPX_MAX_LAYERS + pub temporal_layering_mode: c_int, + pub use_vizier_rc_params: c_int, + pub active_wq_factor: NdVpxRational, + pub err_per_mb_factor: NdVpxRational, + pub sr_default_decay_limit: NdVpxRational, + pub sr_diff_factor: NdVpxRational, + pub kf_err_per_mb_factor: NdVpxRational, + pub kf_frame_min_boost_factor: NdVpxRational, + pub kf_frame_max_boost_first_factor: NdVpxRational, + pub kf_frame_max_boost_subs_factor: NdVpxRational, + pub kf_max_total_boost_factor: NdVpxRational, + pub gf_max_total_boost_factor: NdVpxRational, + pub gf_frame_max_boost_factor: NdVpxRational, + pub zm_factor: NdVpxRational, + pub rd_mult_inter_qp_fac: NdVpxRational, + pub rd_mult_arf_qp_fac: NdVpxRational, + pub rd_mult_key_qp_fac: NdVpxRational, + } + pub type vpx_codec_enc_cfg_t = vpx_codec_enc_cfg; + + /// Mirror of `vpx_codec_dec_cfg_t` (vpx_decoder.h). + #[repr(C)] + #[derive(Copy, Clone)] + pub struct vpx_codec_dec_cfg { + pub threads: c_uint, + pub w: c_uint, + pub h: c_uint, + } + pub type vpx_codec_dec_cfg_t = vpx_codec_dec_cfg; + + include!(concat!(env!("OUT_DIR"), "/vpx_ffi.rs")); +} + +use std::ffi::CStr; + +/// libvpx version string, e.g. "v1.17.0". +pub fn version() -> String { + unsafe { + let p = ffi::vpx_codec_version_str(); + if p.is_null() { + return String::new(); + } + CStr::from_ptr(p).to_string_lossy().into_owned() + } +} + +/// Which libvpx codec to use. VP9 is the constrained-link default (far better +/// per-bitrate than H.264); VP8 is a fallback for very old decoders. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum VpxCodec { + Vp8, + Vp9, +} + +/// One compressed output packet. +pub struct EncodedFrame { + pub data: Vec, + pub key: bool, +} + +/// One decoded frame, converted to packed BGRA (8-bit, straight alpha = 255). +pub struct DecodedFrame { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +#[inline] +fn ck(code: i32, what: &str) -> Result<()> { + if code == 0 { + Ok(()) + } else { + Err(anyhow!("{what}: vpx error {code}")) + } +} + +fn thread_num() -> c_uint { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .min(16) as c_uint +} + +/// VP8/VP9 encoder (software, libvpx). Input is packed BGRA; dimensions must be +/// even. One keyframe is emitted on the first frame (or on demand). +pub struct Encoder { + ctx: ffi::vpx_codec_ctx_t, + width: u32, + height: u32, + i420: Vec, // reusable packed plane scratch (I420 Y|U|V, or I444 Y|U|V) + i444: bool, // true => VP9 profile-1 4:4:4 chroma (crisp text); else I420 +} + +impl Encoder { + /// `bitrate_kbps` is the CBR target in kilobits/sec. I420 (4:2:0) chroma. + pub fn new(codec: VpxCodec, width: u32, height: u32, fps: u32, bitrate_kbps: u32) -> Result { + Self::new_i444(codec, width, height, fps, bitrate_kbps, false) + } + + /// Like [`Encoder::new`], but `i444` selects VP9 profile-1 4:4:4 chroma + /// (full-resolution U/V) for crisp text and saturated 1px edges that 4:2:0 + /// blurs. VP8 has no such profile, so it is ignored there (stays I420). + pub fn new_i444( + codec: VpxCodec, + width: u32, + height: u32, + fps: u32, + bitrate_kbps: u32, + i444: bool, + ) -> Result { + if width == 0 || height == 0 || width % 2 != 0 || height % 2 != 0 { + bail!("vpx encoder needs non-zero even dimensions, got {width}x{height}"); + } + let _ = fps; // reserved for future rc tuning; ms timebase is fps-independent + unsafe { + let iface = match codec { + VpxCodec::Vp8 => ffi::vpx_codec_vp8_cx(), + VpxCodec::Vp9 => ffi::vpx_codec_vp9_cx(), + }; + if iface.is_null() { + bail!("vpx codec iface null"); + } + let mut cfg: ffi::vpx_codec_enc_cfg_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_enc_config_default(iface, &mut cfg, 0) as i32, + "enc_config_default", + )?; + + // Match RustDesk's VP9 realtime CBR config. + cfg.g_w = width; + cfg.g_h = height; + cfg.g_timebase.num = 1; + cfg.g_timebase.den = 1000; // ms timestamps + cfg.rc_undershoot_pct = 95; + cfg.rc_dropframe_thresh = 25; + cfg.g_threads = thread_num(); + cfg.g_lag_in_frames = 0; // realtime: no lookahead, emit every frame immediately + cfg.g_error_resilient = 1; // VPX_ERROR_RESILIENT_DEFAULT + cfg.rc_end_usage = 1; // VPX_CBR + cfg.kf_mode = 0; // VPX_KF_DISABLED (we force keyframes on demand) + cfg.rc_min_quantizer = 0; + cfg.rc_max_quantizer = 63; + cfg.rc_target_bitrate = bitrate_kbps.max(50); + // VP9 profile 1 carries 8-bit 4:2:2/4:4:4; VP8 has no such profile. + let use_i444 = i444 && codec == VpxCodec::Vp9; + cfg.g_profile = if use_i444 { 1 } else { 0 }; + + let mut ctx: ffi::vpx_codec_ctx_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_enc_init_ver( + &mut ctx, + iface, + &cfg, + 0, + ffi::VPX_ENCODER_ABI_VERSION as c_int, + ) as i32, + "enc_init", + )?; + + if codec == VpxCodec::Vp9 { + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP8E_SET_CPUUSED as c_int, + 7 as c_int, + ) as i32, + "set_cpuused", + )?; + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP9E_SET_ROW_MT as c_int, + 1 as c_int, + ) as i32, + "set_row_mt", + )?; + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP9E_SET_TILE_COLUMNS as c_int, + 4 as c_int, + ) as i32, + "set_tile_columns", + )?; + } else { + ck( + ffi::vpx_codec_control_( + &mut ctx, + ffi::vp8e_enc_control_id::VP8E_SET_CPUUSED as c_int, + 12 as c_int, + ) as i32, + "set_cpuused_vp8", + )?; + } + + Ok(Self { + ctx, + width, + height, + i420: Vec::new(), + i444: use_i444, + }) + } + } + + /// Encode one packed-BGRA frame at `pts_ms`. Returns any compressed packets + /// the encoder produced (usually one). `force_key` requests a keyframe. + pub fn encode_bgra(&mut self, bgra: &[u8], pts_ms: i64, force_key: bool) -> Result> { + let (w, h) = (self.width as usize, self.height as usize); + if bgra.len() != w * h * 4 { + bail!("bgra len {} != {}", bgra.len(), w * h * 4); + } + let fmt = if self.i444 { + bgra_to_i444(bgra, w, h, &mut self.i420); + ffi::vpx_img_fmt::VPX_IMG_FMT_I444 + } else { + bgra_to_i420(bgra, w, h, &mut self.i420); + ffi::vpx_img_fmt::VPX_IMG_FMT_I420 + }; + + unsafe { + let mut img: ffi::vpx_image_t = std::mem::zeroed(); + let wrapped = ffi::vpx_img_wrap( + &mut img, + fmt, + self.width as c_uint, + self.height as c_uint, + 1, + self.i420.as_ptr() as *mut u8, + ); + if wrapped.is_null() { + bail!("vpx_img_wrap failed"); + } + let flags: ffi::vpx_enc_frame_flags_t = if force_key { + ffi::VPX_EFLAG_FORCE_KF as ffi::vpx_enc_frame_flags_t + } else { + 0 + }; + ck( + ffi::vpx_codec_encode( + &mut self.ctx, + &img, + pts_ms as ffi::vpx_codec_pts_t, + 1, + flags, + ffi::VPX_DL_REALTIME as _, + ) as i32, + "encode", + )?; + Ok(self.drain()) + } + } + + unsafe fn drain(&mut self) -> Vec { + let mut iter: ffi::vpx_codec_iter_t = std::ptr::null(); + let mut out = Vec::new(); + loop { + let pkt = ffi::vpx_codec_get_cx_data(&mut self.ctx, &mut iter); + if pkt.is_null() { + break; + } + if (*pkt).kind == ffi::vpx_codec_cx_pkt_kind::VPX_CODEC_CX_FRAME_PKT { + let f = &(*pkt).data.frame; + let data = std::slice::from_raw_parts(f.buf as *const u8, f.sz as usize).to_vec(); + let key = (f.flags & ffi::VPX_FRAME_IS_KEY) != 0; + out.push(EncodedFrame { data, key }); + } + } + out + } +} + +impl Drop for Encoder { + fn drop(&mut self) { + unsafe { + ffi::vpx_codec_destroy(&mut self.ctx); + } + } +} + +// A libvpx context holds raw pointers, so it is not auto-Send. It is never +// shared (no Sync), but a single owner may move it between threads — the host +// capture/encode thread owns the Encoder, the viewer network thread owns the +// Decoder. This matches RustDesk's VpxEncoder/VpxDecoder. +unsafe impl Send for Encoder {} +unsafe impl Send for Decoder {} + +/// VP8/VP9 decoder (software, libvpx). Produces packed BGRA frames. +pub struct Decoder { + ctx: ffi::vpx_codec_ctx_t, +} + +impl Decoder { + pub fn new(codec: VpxCodec) -> Result { + unsafe { + let iface = match codec { + VpxCodec::Vp8 => ffi::vpx_codec_vp8_dx(), + VpxCodec::Vp9 => ffi::vpx_codec_vp9_dx(), + }; + if iface.is_null() { + bail!("vpx decoder iface null"); + } + let cfg = ffi::vpx_codec_dec_cfg_t { + threads: thread_num(), + w: 0, + h: 0, + }; + let mut ctx: ffi::vpx_codec_ctx_t = std::mem::zeroed(); + ck( + ffi::vpx_codec_dec_init_ver( + &mut ctx, + iface, + &cfg, + 0, + ffi::VPX_DECODER_ABI_VERSION as c_int, + ) as i32, + "dec_init", + )?; + Ok(Self { ctx }) + } + } + + /// Decode one compressed packet into zero or more BGRA frames. + pub fn decode_to_bgra(&mut self, data: &[u8]) -> Result> { + unsafe { + ck( + ffi::vpx_codec_decode( + &mut self.ctx, + data.as_ptr(), + data.len() as c_uint, + std::ptr::null_mut(), + 0, + ) as i32, + "decode", + )?; + let mut iter: ffi::vpx_codec_iter_t = std::ptr::null(); + let mut out = Vec::new(); + loop { + let img = ffi::vpx_codec_get_frame(&mut self.ctx, &mut iter); + if img.is_null() { + break; + } + let im = &*img; + let w = im.d_w as usize; + let h = im.d_h as usize; + // The VP9 decoder adapts to the stream's profile: profile-1 + // frames arrive as I444 (full-res chroma), profile-0 as I420. + // One Decoder therefore handles either without reconfiguration. + let bgra = if im.fmt == ffi::vpx_img_fmt::VPX_IMG_FMT_I444 { + i444_to_bgra( + im.planes[0] as *const u8, + im.planes[1] as *const u8, + im.planes[2] as *const u8, + im.stride[0] as usize, + im.stride[1] as usize, + w, + h, + ) + } else { + i420_to_bgra( + im.planes[0] as *const u8, + im.planes[1] as *const u8, + im.planes[2] as *const u8, + im.stride[0] as usize, + im.stride[1] as usize, + w, + h, + ) + }; + out.push(DecodedFrame { + width: im.d_w, + height: im.d_h, + bgra, + }); + } + Ok(out) + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + ffi::vpx_codec_destroy(&mut self.ctx); + } + } +} + +// ---- pure-Rust BT.601 color conversion (no libyuv) ---- + +/// Packed BGRA -> packed I420 (Y plane w*h, then U, then V at cw*ch). `w`,`h` +/// must be even. Output is laid out exactly as vpx_img_wrap(I420, align=1) +/// expects for even dimensions. +fn bgra_to_i420(bgra: &[u8], w: usize, h: usize, out: &mut Vec) { + let cw = w / 2; + let ch = h / 2; + let y_size = w * h; + let c_size = cw * ch; + out.clear(); + out.resize(y_size + 2 * c_size, 0); + let (y_plane, rest) = out.split_at_mut(y_size); + let (u_plane, v_plane) = rest.split_at_mut(c_size); + + for j in 0..h { + for i in 0..w { + let p = (j * w + i) * 4; + let b = bgra[p] as f32; + let g = bgra[p + 1] as f32; + let r = bgra[p + 2] as f32; + let y = 0.257 * r + 0.504 * g + 0.098 * b + 16.0; + y_plane[j * w + i] = clamp8(y); + } + } + for cj in 0..ch { + for ci in 0..cw { + let mut rs = 0.0f32; + let mut gs = 0.0f32; + let mut bs = 0.0f32; + for dy in 0..2 { + for dx in 0..2 { + let p = ((cj * 2 + dy) * w + (ci * 2 + dx)) * 4; + bs += bgra[p] as f32; + gs += bgra[p + 1] as f32; + rs += bgra[p + 2] as f32; + } + } + let (r, g, b) = (rs / 4.0, gs / 4.0, bs / 4.0); + let u = -0.148 * r - 0.291 * g + 0.439 * b + 128.0; + let v = 0.439 * r - 0.368 * g - 0.071 * b + 128.0; + u_plane[cj * cw + ci] = clamp8(u); + v_plane[cj * cw + ci] = clamp8(v); + } + } +} + +/// I420 planes (with strides) -> packed BGRA. +unsafe fn i420_to_bgra( + y: *const u8, + u: *const u8, + v: *const u8, + ys: usize, + cs: usize, + w: usize, + h: usize, +) -> Vec { + let mut out = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let yv = *y.add(j * ys + i) as f32; + let uu = *u.add((j / 2) * cs + i / 2) as f32; + let vv = *v.add((j / 2) * cs + i / 2) as f32; + let c = yv - 16.0; + let d = uu - 128.0; + let e = vv - 128.0; + let r = 1.164 * c + 1.596 * e; + let g = 1.164 * c - 0.391 * d - 0.813 * e; + let b = 1.164 * c + 2.018 * d; + let p = (j * w + i) * 4; + out[p] = clamp8(b); + out[p + 1] = clamp8(g); + out[p + 2] = clamp8(r); + out[p + 3] = 255; + } + } + out +} + +/// Packed BGRA -> packed I444 (Y|U|V, each w*h at full resolution). Unlike +/// I420 the chroma planes keep every pixel's own U/V, so 4:4:4 preserves 1px +/// colored text and hard edges that 4:2:0 averages away. Layout matches +/// vpx_img_wrap(I444, align=1). +fn bgra_to_i444(bgra: &[u8], w: usize, h: usize, out: &mut Vec) { + let n = w * h; + out.clear(); + out.resize(3 * n, 0); + let (y_plane, rest) = out.split_at_mut(n); + let (u_plane, v_plane) = rest.split_at_mut(n); + for idx in 0..n { + let p = idx * 4; + let b = bgra[p] as f32; + let g = bgra[p + 1] as f32; + let r = bgra[p + 2] as f32; + y_plane[idx] = clamp8(0.257 * r + 0.504 * g + 0.098 * b + 16.0); + u_plane[idx] = clamp8(-0.148 * r - 0.291 * g + 0.439 * b + 128.0); + v_plane[idx] = clamp8(0.439 * r - 0.368 * g - 0.071 * b + 128.0); + } +} + +/// I444 planes (full-res chroma, with strides) -> packed BGRA. +unsafe fn i444_to_bgra( + y: *const u8, + u: *const u8, + v: *const u8, + ys: usize, + cs: usize, + w: usize, + h: usize, +) -> Vec { + let mut out = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let yv = *y.add(j * ys + i) as f32; + let uu = *u.add(j * cs + i) as f32; + let vv = *v.add(j * cs + i) as f32; + let c = yv - 16.0; + let d = uu - 128.0; + let e = vv - 128.0; + let p = (j * w + i) * 4; + out[p] = clamp8(1.164 * c + 2.018 * d); + out[p + 1] = clamp8(1.164 * c - 0.391 * d - 0.813 * e); + out[p + 2] = clamp8(1.164 * c + 1.596 * e); + out[p + 3] = 255; + } + } + out +} + +#[inline] +fn clamp8(v: f32) -> u8 { + v.round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindgen_links_libvpx() { + let v = version(); + assert!(v.starts_with('v'), "libvpx version via bindgen FFI: {v}"); + let iface = unsafe { ffi::vpx_codec_vp9_cx() }; + assert!(!iface.is_null(), "vp9 encoder iface"); + println!("VPX_BINDGEN_OK {v}"); + } + + #[test] + fn vp9_roundtrip_bgra() { + let (w, h) = (64usize, 64usize); + let mut bgra = vec![0u8; w * h * 4]; + // four solid quadrants (BGRA order) + for j in 0..h { + for i in 0..w { + let p = (j * w + i) * 4; + let (b, g, r) = if j < h / 2 && i < w / 2 { + (30u8, 30, 200) // red + } else if j < h / 2 { + (30, 200, 30) // green + } else if i < w / 2 { + (200, 30, 30) // blue + } else { + (200, 200, 200) // gray + }; + bgra[p] = b; + bgra[p + 1] = g; + bgra[p + 2] = r; + bgra[p + 3] = 255; + } + } + + let mut enc = Encoder::new(VpxCodec::Vp9, w as u32, h as u32, 30, 2000).unwrap(); + let frames = enc.encode_bgra(&bgra, 0, true).unwrap(); + assert!(!frames.is_empty(), "encoder produced no packets"); + assert!(frames[0].key, "first frame must be a keyframe"); + + let mut dec = Decoder::new(VpxCodec::Vp9).unwrap(); + let mut got = Vec::new(); + for f in &frames { + got.extend(dec.decode_to_bgra(&f.data).unwrap()); + } + assert_eq!(got.len(), 1, "expected exactly one decoded frame"); + let img = &got[0]; + assert_eq!((img.width, img.height), (w as u32, h as u32)); + + let sample = |x: usize, y: usize| { + let p = (y * w + x) * 4; + (img.bgra[p], img.bgra[p + 1], img.bgra[p + 2]) + }; + let approx = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 48; + + let (b, g, r) = sample(w / 4, h / 4); // red quadrant center + assert!( + approx(r, 200) && approx(g, 30) && approx(b, 30), + "red quad decoded as b{b} g{g} r{r}" + ); + let (b, g, r) = sample(3 * w / 4, 3 * h / 4); // gray quadrant center + assert!( + approx(r, 200) && approx(g, 200) && approx(b, 200), + "gray quad decoded as b{b} g{g} r{r}" + ); + + println!( + "VPX_ROUNDTRIP_OK vp9 {w}x{h} key_bytes={}", + frames[0].data.len() + ); + } + + #[test] + fn vp9_i444_beats_i420_on_1px_chroma() { + // 1px-wide vertical stripes of two DIFFERENT-chroma colors (magenta / + // green). 4:2:0 stores one chroma sample per 2x2 block, so it CANNOT + // represent per-column chroma no matter the bitrate; 4:4:4 keeps it. + let (w, h) = (64usize, 64usize); + let mut bgra = vec![0u8; w * h * 4]; + for j in 0..h { + for i in 0..w { + let p = (j * w + i) * 4; + let (b, g, r) = if i % 2 == 0 { + (200u8, 0, 200) // magenta + } else { + (0, 170, 0) // green + }; + bgra[p] = b; + bgra[p + 1] = g; + bgra[p + 2] = r; + bgra[p + 3] = 255; + } + } + let mae = |i444: bool| -> f64 { + let mut enc = + Encoder::new_i444(VpxCodec::Vp9, w as u32, h as u32, 30, 4000, i444).unwrap(); + let frames = enc.encode_bgra(&bgra, 0, true).unwrap(); + let mut dec = Decoder::new(VpxCodec::Vp9).unwrap(); + let mut got = Vec::new(); + for f in &frames { + got.extend(dec.decode_to_bgra(&f.data).unwrap()); + } + assert_eq!(got.len(), 1); + let img = &got[0]; + let mut e = 0f64; + for idx in 0..w * h { + let p = idx * 4; + e += (img.bgra[p] as i32 - bgra[p] as i32).abs() as f64; + e += (img.bgra[p + 1] as i32 - bgra[p + 1] as i32).abs() as f64; + e += (img.bgra[p + 2] as i32 - bgra[p + 2] as i32).abs() as f64; + } + e / (3 * w * h) as f64 + }; + let e420 = mae(false); + let e444 = mae(true); + println!("VPX_I444_OK e420={e420:.2} e444={e444:.2}"); + assert!( + e444 + 1.0 < e420, + "i444 MAE {e444:.2} should clearly beat i420 {e420:.2} on 1px chroma" + ); + } +} diff --git a/native/vpx/wrapper.h b/native/vpx/wrapper.h new file mode 100644 index 0000000..78ba358 --- /dev/null +++ b/native/vpx/wrapper.h @@ -0,0 +1,9 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/publishing/README.md b/publishing/README.md new file mode 100644 index 0000000..33a70da --- /dev/null +++ b/publishing/README.md @@ -0,0 +1,52 @@ +# Publishing the source on netdesk.space + +The full source is downloadable + git-cloneable straight from the website: +- Page: https://netdesk.space/source +- Download: https://netdesk.space/downloads/netdesk-src-latest.tar.gz (+ .zip) +- Clone: git clone https://netdesk.space/git/netdesk.git + +## How it's built (secret-safe) + +1. **Export tracked files only** (never DBs/artifacts/secrets): + `git archive HEAD` from this repo + the sibling `netdesk-native` repo + (extracted into `netdesk/native/`). +2. **Strip internal + sensitive** files: planning/roadmap/queue docs, + `native/certs/`, `native/docs/implementation-specs.md`, dev screenshots. +3. **Secret scan** the export (known secrets + an adversarial multi-scanner + sweep for keys/tokens/PII/stray binaries) and scrub any hit. The real + account password, owner email, account token and live host ID have been + removed from source — keep it that way (they must never be committed). +4. **Package**: `.tar.gz` + `.zip` (top-level `netdesk/`) and a bare repo + (`git clone --bare` + `git update-server-info`) for dumb-HTTP clone. + +## How it's served + +Static nginx locations in `/etc/nginx/conf.d/netdesk.space.conf` (see +`nginx-source.snippet`) serving `/opt/netdesk/public/{source,downloads,git}`. +Zero app-server changes; `nginx -t` + `systemctl reload nginx` (no downtime). + +`source-page.html` is the download/clone landing page (served at /source/). + +## GitHub-style browsing (Gitea) + +The source is also browsable like GitHub — file tree, rendered README, commit +history, language stats, per-branch ZIP/TAR.GZ downloads: + +- **Browse:** https://netdesk.space/git/akronlogic/netdesk +- **Clone:** git clone https://netdesk.space/git/akronlogic/netdesk.git + +Served by **Gitea** (self-hosted, latest stable) running as the systemd service +`gitea` on 127.0.0.1:3030, reverse-proxied by nginx at the `/git/` sub-path +(`ROOT_URL = https://netdesk.space/git/`). SQLite data in `/var/lib/gitea`, +config in `/etc/gitea/app.ini`. Hardened: registration disabled, anonymous +read-only, SSH/Actions/Packages off, offline mode. Admin login is in +`/root/gitea-admin.txt` (root-only) — user `ndadmin`. + +### Publishing an update +Rebuild the secret-scrubbed export (see above), then push it to the Gitea repo: +```bash +cd && git init && git add -A && git commit -m "release" +git push --force https://ndadmin:@netdesk.space/git/akronlogic/netdesk.git master +``` +(Only ever push the SCRUBBED export — never the raw repo, which contains the +owner email in portal.js. Re-run the secret scan first.) diff --git a/publishing/nginx-source.snippet b/publishing/nginx-source.snippet new file mode 100644 index 0000000..30923ac --- /dev/null +++ b/publishing/nginx-source.snippet @@ -0,0 +1,16 @@ + # ---- Public source code: download + git clone (served statically) ---- + location = /source { return 302 /source/; } + location /source/ { + alias /opt/netdesk/public/source/; + index index.html; + add_header Cache-Control "public, max-age=300"; + } + location /downloads/ { + alias /opt/netdesk/public/downloads/; + autoindex on; + add_header Cache-Control "public, max-age=300"; + } + location /git/ { + alias /opt/netdesk/public/git/; + autoindex off; + } diff --git a/publishing/source-page.html b/publishing/source-page.html new file mode 100644 index 0000000..704ac73 --- /dev/null +++ b/publishing/source-page.html @@ -0,0 +1,133 @@ + + + + + +NetDesk — Source Code + + + + + +

+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0748812 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# NetDesk dependencies +# Server (VPS or local): aiohttp, cryptography +# Host (Windows PC): + mss, numpy, av (bundles FFmpeg x264), pillow +# Optional host audio: pip install PyAudioWPatch +aiohttp>=3.9 +cryptography>=42 +mss>=9 +dxcam>=0.3.0 +comtypes>=1.4 +numpy>=1.26 +av>=12 +pillow>=10 +aioice==0.10.2 +aiortc==1.15.0 +pyee==13.0.1 +pylibsrtp==1.0.0 +zeroconf==0.151.5 diff --git a/scripts/build_mac.sh b/scripts/build_mac.sh new file mode 100644 index 0000000..919594a --- /dev/null +++ b/scripts/build_mac.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# NetDesk build for Apple Silicon / Intel Macs. +# Run ON the Mac (PyInstaller cannot cross-compile): +# bash scripts/build_mac.sh +# Produces: +# dist/NetDesk.app - desktop control panel (double-click app) +# dist/netdesk-engine - headless agent binary (LaunchAgent / CLI) +# dist/NetDeskServer - server binary (optional, if the Mac is the VPS) +set -euo pipefail +cd "$(dirname "$0")/.." # netdesk root + +echo "==> creating build venv" +python3 -m venv .venv-build +./.venv-build/bin/pip install --quiet --upgrade pip +./.venv-build/bin/pip install --quiet aiohttp cryptography mss numpy av pillow pyinstaller + +echo "==> building NetDesk.app (desktop control panel, double-click to run)" +./.venv-build/bin/pyinstaller --windowed --name NetDesk \ + --distpath dist --workpath build --specpath build host/netdesk_host.py + +echo "==> building netdesk-engine (headless agent for LaunchAgent/CLI)" +./.venv-build/bin/pyinstaller --onefile --name netdesk-engine \ + --distpath dist --workpath build --specpath build host/netdesk_host.py + +echo "==> building NetDeskServer" +./.venv-build/bin/pyinstaller --onefile --name NetDeskServer \ + --add-data "server/web:web" \ + --distpath dist --workpath build --specpath build server/netdesk_server.py + +rm -rf build .venv-build +echo +echo "==> done:" +ls -lh dist/ +echo +echo "What you got:" +echo " dist/NetDesk.app - the desktop control panel (double-click)" +echo " dist/netdesk-engine - headless agent (used by Start-at-Login and CLI)" +echo " dist/NetDeskServer - the server binary (optional, if the Mac is the VPS)" +echo +echo "First run: macOS asks for Screen Recording and Accessibility" +echo "(System Settings -> Privacy & Security) - both are required for" +echo "remote control, same as RustDesk/TeamViewer." +echo "If Gatekeeper blocks an app: xattr -cr dist/NetDesk.app" +echo +echo "Autostart: open NetDesk.app -> enter server + key -> Start at Login." +echo "This installs ~/Library/LaunchAgents/com.netdesk.host.plist (invisible engine)." diff --git a/scripts/build_windows.bat b/scripts/build_windows.bat new file mode 100755 index 0000000..d889178 --- /dev/null +++ b/scripts/build_windows.bat @@ -0,0 +1,27 @@ +@echo off +REM Build the NetDesk portable Windows binaries (host app + server) with PyInstaller. +REM Run from the repo root: scripts\build_windows.bat +setlocal +cd /d "%~dp0.." +set PY=..\.venv\Scripts\python.exe + +echo === Building NetDeskHost.exe (windowed desktop app + engine) === +"%PY%" -m PyInstaller --noconfirm --clean --onefile --name NetDeskHost --windowed ^ + --icon host\icon.ico ^ + --add-data "host\netdesk_gui.html;." ^ + --paths host ^ + --hidden-import netdesk_gui ^ + --hidden-import pyaudiowpatch ^ + host\netdesk_host.py +if errorlevel 1 exit /b 1 + +echo === Building NetDeskServer.exe (console signaling/relay server) === +"%PY%" -m PyInstaller --noconfirm --clean --onefile --name NetDeskServer --console ^ + --add-data "server\web;web" ^ + server\netdesk_server.py +if errorlevel 1 exit /b 1 + +echo === Done. Binaries in dist\ === +copy /Y dist\NetDeskHost.exe portable\NetDeskHost.exe +copy /Y dist\NetDeskServer.exe portable\NetDeskServer.exe +endlocal diff --git a/scripts/deploy/com.netdesk.host.plist b/scripts/deploy/com.netdesk.host.plist new file mode 100644 index 0000000..1918d6a --- /dev/null +++ b/scripts/deploy/com.netdesk.host.plist @@ -0,0 +1,27 @@ + + + + + Label + com.netdesk.host + + ProgramArguments + + + /Applications/NetDesk/NetDeskHost + + --server + wss://your-vps.example:8443/ws + + + RunAtLoad + + KeepAlive + + + StandardOutPath + /tmp/netdesk-host.log + StandardErrorPath + /tmp/netdesk-host.log + + diff --git a/scripts/deploy/deploy_vps.sh b/scripts/deploy/deploy_vps.sh new file mode 100644 index 0000000..307576e --- /dev/null +++ b/scripts/deploy/deploy_vps.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# NetDesk VPS deployment helper - run as root on a fresh Debian/Ubuntu VPS. +# Reads REG_KEY from the environment or generates one and prints it. +set -euo pipefail + +REG_KEY="${REG_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=')}" +STATUS_KEY="${STATUS_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=')}" +PORT="${PORT:-8443}" +DEST=/opt/netdesk + +echo "==> installing python deps" +apt-get update -qq +apt-get install -y -qq python3 python3-pip +python3 -m pip install --break-system-packages --quiet aiohttp cryptography || \ + python3 -m pip install --quiet aiohttp cryptography + +echo "==> installing files to $DEST" +mkdir -p "$DEST/server" +cp -r "$(dirname "$0")/../server/." "$DEST/server/" + +echo "==> creating service user" +id -u netdesk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin netdesk +chown -R netdesk:netdesk "$DEST" + +echo "==> writing systemd unit" +sed -e "s/CHANGE_ME_STRONG_KEY/$REG_KEY/" \ + -e "s/CHANGE_ME_TOO/$STATUS_KEY/" \ + -e "s/--port 8443/--port $PORT/" \ + "$(dirname "$0")/netdesk.service" > /etc/systemd/system/netdesk.service +systemctl daemon-reload +systemctl enable --now netdesk + +cat <nul + +echo [netdesk] starting host agent (finds server URL from its config) ... +start "NetDesk Host" "%PY%" host\netdesk_host.py --server ws://127.0.0.1:8443/ws + +timeout /t 3 /nobreak >nul +echo [netdesk] opening viewer in browser... +start http://127.0.0.1:8443/ +echo. +echo Your host ID and password are in %%APPDATA%%\NetDesk\config.json +echo (password is printed by the host on its FIRST run only; use --set-password to change) +endlocal diff --git a/scripts/start_host_hidden.vbs b/scripts/start_host_hidden.vbs new file mode 100644 index 0000000..4550f54 --- /dev/null +++ b/scripts/start_host_hidden.vbs @@ -0,0 +1,6 @@ +' Runs the NetDesk host agent with no console window. +Set sh = CreateObject("WScript.Shell") +repo = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\scripts\") - 1) +py = repo & "\.venv\Scripts\pythonw.exe" +If Not CreateObject("Scripting.FileSystemObject").FileExists(py) Then py = "pythonw.exe" +sh.Run """" & py & """ """ & repo & "\host\netdesk_host.py""", 0, False diff --git a/scripts/uninstall_autostart.bat b/scripts/uninstall_autostart.bat new file mode 100755 index 0000000..cceb593 --- /dev/null +++ b/scripts/uninstall_autostart.bat @@ -0,0 +1,8 @@ +@echo off +rem Removes the NetDesk boot-time service. +setlocal +cd /d "%~dp0.." +set PY=%~dp0..\.venv\Scripts\python.exe +if not exist "%PY%" set PY=python +"%PY%" host\netdesk_host.py --uninstall +pause diff --git a/server/netdesk_server.py b/server/netdesk_server.py new file mode 100644 index 0000000..e2a6ccc --- /dev/null +++ b/server/netdesk_server.py @@ -0,0 +1,4263 @@ +#!/usr/bin/env python3 +""" +NetDesk Server - signaling + relay + web viewer host. + +Runs on a VPS (Linux/Windows) or locally. Stateless: nothing is stored on disk. +- Hosts register by ID and keep one WebSocket open (outbound only, NAT-friendly). +- Viewers load the web client from this server, authenticate through a + host-generated challenge/response (the server never sees the password), + then audio/video/input/file traffic is relayed opaquely between the two. +- With end-to-end encryption enabled (default), even this server cannot see + the screen: media is encrypted host->viewer with an ephemeral E2E key. + +Usage: + python netdesk_server.py [--port 8443] [--cert cert.pem --key key.pem] + [--reg-key SECRET] [--status-key SECRET] + + Without --cert the server runs plain HTTP/WS (fine for localhost/LAN). + For internet use, put a real certificate behind it (Let's Encrypt) or use + --gen-cert --domain your.domain for a self-signed one (browser warning). +""" + +import argparse +import asyncio +import json +import logging +import os +import ssl +import time +import uuid + +from aiohttp import web, WSMsgType + +log = logging.getLogger("netdesk.server") + +VERSION = "1.5.0-netdesk" +import sys +_web_base = getattr(sys, "_MEIPASS", None) or os.path.dirname(os.path.abspath(__file__)) +WEB_DIR = os.path.join(_web_base, "web") + +# ---------------------------------------------------------------- metrics + +class Stats: + def __init__(self): + self.started = time.time() + self.bytes_relayed = 0 + self.sessions_total = 0 + +STATS = Stats() + + +class Host: + """A registered host connection.""" + + def __init__(self, ws, hid, name, ver): + self.ws = ws + self.id = hid + self.name = name + self.ver = ver + self.connected_at = time.time() + self.session = None # active/pending Session + self.telemetry = {} # latest CPU/RAM/disk/battery sample (portal) + + +class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False + self.plan = "guest" + self.vlimit = None + self.vemail = "" + self.vip = "" + self.vgeo = {} + self.started = time.time() + + def other(self, ws): + return self.viewer_ws if ws is self.host.ws else self.host.ws + + +HOSTS: dict[str, Host] = {} # id -> Host (online right now) + +# ---------------------------------------------------------------- portal state + +import secrets as _secrets +import collections + +KNOWN_HOSTS: dict[str, dict] = {} # id -> registry entry (survives disconnects, in-memory) +EVENTS = collections.deque(maxlen=200) +PORTAL_SESSIONS: set[str] = set() + + +# ---------------------------------------------------------------- brute-force throttle +# Per-(scope, source-IP) failed-attempt counter with decay + lockout, mirroring +# RustDesk's LOGIN_FAILURES model (MAX_CONSECUTIVE_FAILURES=10). A failed +# password/portal login records a failure; too many within the window locks the +# IP out for a cooldown; a success clears it. In-memory (resets on restart), +# which is fine for an abuse brake. Scopes keep account- and portal-login +# counters independent so one can't lock the other out. +_LOGIN_FAILS: dict[tuple, list] = {} # (scope, ip) -> [count, first_ts, locked_until] +LOGIN_MAX_FAILS = 10 # failures before lockout +LOGIN_WINDOW = 300.0 # seconds the count accumulates over +LOGIN_LOCKOUT = 300.0 # seconds locked out once tripped + + +def login_locked(scope: str, ip: str) -> float: + """Seconds remaining on an active lockout for (scope, ip), else 0.0.""" + rec = _LOGIN_FAILS.get((scope, ip)) + if not rec: + return 0.0 + now = time.time() + if rec[2] > now: + return rec[2] - now + # expired lockout, or a stale window → clear it + if rec[2] and rec[2] <= now: + _LOGIN_FAILS.pop((scope, ip), None) + elif now - rec[1] > LOGIN_WINDOW: + _LOGIN_FAILS.pop((scope, ip), None) + return 0.0 + + +def login_fail(scope: str, ip: str) -> None: + """Record one failed attempt; trip a lockout at LOGIN_MAX_FAILS in-window.""" + now = time.time() + rec = _LOGIN_FAILS.get((scope, ip)) + if not rec or (now - rec[1] > LOGIN_WINDOW and rec[2] <= now): + _LOGIN_FAILS[(scope, ip)] = [1, now, 0.0] + return + rec[0] += 1 + if rec[0] >= LOGIN_MAX_FAILS: + rec[2] = now + LOGIN_LOCKOUT + log_event("auth", f"lockout: {scope} from {ip} after {rec[0]} failures") + + +def login_ok(scope: str, ip: str) -> None: + """Clear the counter for (scope, ip) after a successful login.""" + _LOGIN_FAILS.pop((scope, ip), None) + + +def req_ip(req) -> str: + """Client IP, preferring the forwarded header (behind nginx req.remote is + 127.0.0.1).""" + return ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or req.remote or "?") + +# ---------------------------------------------------------------- user accounts (email sign-in for viewers) +import sqlite3 +import smtplib +from email.message import EmailMessage as _MailMsg + +def _nd_data_root(): + """Directory holding persistent state (accounts.db, audit.log, files/). + `ND_DATA_DIR` overrides it — used by the Docker image to point at a mounted + volume — otherwise it's the executable/script dir, exactly as before, so the + systemd deployment is unaffected.""" + d = os.environ.get("ND_DATA_DIR") + if d: + os.makedirs(d, exist_ok=True) + return d + return (os.path.dirname(sys.executable) if getattr(sys, "frozen", False) + else os.path.dirname(os.path.abspath(__file__))) + +ACCOUNTS_DB = os.path.join(_nd_data_root(), "accounts.db") +USER_SESSIONS = {} # token -> email (cache; tokens persist in the db) + +def _acct_db(): + conn = sqlite3.connect(ACCOUNTS_DB) + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + # login tokens survive server restarts (browser cookie + desktop app bearer) + conn.execute( + "CREATE TABLE IF NOT EXISTS tokens(tok TEXT PRIMARY KEY, email TEXT NOT NULL," + " created INTEGER NOT NULL)") + # computers bound to an account (like RustDesk's address book / device list) + conn.execute( + "CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY, owner TEXT NOT NULL," + " name TEXT, ver TEXT, last_seen INTEGER)") + try: # WoL support (added 1.5) - MAC of the host's primary NIC + conn.execute("ALTER TABLE devices ADD COLUMN mac TEXT DEFAULT ''") + except Exception: + pass + try: # portal: hide a host from the guest/public surface + conn.execute("ALTER TABLE devices ADD COLUMN hidden INTEGER DEFAULT 0") + except Exception: + pass + try: # address book: comma-separated tags (e.g. "prod,linux") + conn.execute("ALTER TABLE devices ADD COLUMN tags TEXT DEFAULT ''") + except Exception: + pass + try: # address book: a group / folder name + conn.execute("ALTER TABLE devices ADD COLUMN grp TEXT DEFAULT ''") + except Exception: + pass + try: # RBAC: 'admin' sees/manages all devices; default 'member' + conn.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'member'") + except Exception: + pass + try: # account 2FA: base32 TOTP secret (empty = disabled) + conn.execute("ALTER TABLE users ADD COLUMN totp TEXT DEFAULT ''") + except Exception: + pass + try: # SCIM: IdP-deactivated accounts cannot sign in + conn.execute("ALTER TABLE users ADD COLUMN disabled INTEGER DEFAULT 0") + except Exception: + pass + try: # SCIM externalId (IdP correlation) + conn.execute("ALTER TABLE users ADD COLUMN scim_ext TEXT DEFAULT ''") + except Exception: + pass + # REST API keys (X-API-Key header -> owner account) + outbound webhooks + conn.execute( + "CREATE TABLE IF NOT EXISTS api_keys(key TEXT PRIMARY KEY, owner TEXT NOT NULL," + " name TEXT, created INTEGER NOT NULL, last_used INTEGER DEFAULT 0)") + conn.execute( + "CREATE TABLE IF NOT EXISTS webhooks(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT NOT NULL, url TEXT NOT NULL, events TEXT DEFAULT '*', created INTEGER NOT NULL)") + # seat/licensing: per-account cap on concurrently-registered hosts (0 = unlimited) + conn.execute( + "CREATE TABLE IF NOT EXISTS licenses(owner TEXT PRIMARY KEY, seats INTEGER NOT NULL DEFAULT 0," + " plan TEXT DEFAULT '', updated INTEGER NOT NULL DEFAULT 0)") + # MDM-lite: per-account managed policy pushed to hosts at registration + conn.execute( + "CREATE TABLE IF NOT EXISTS account_policy(owner TEXT PRIMARY KEY," + " policy TEXT NOT NULL DEFAULT '{}', updated INTEGER NOT NULL DEFAULT 0)") + # teams: shared address books / device lists across multiple operators + conn.execute( + "CREATE TABLE IF NOT EXISTS teams(id INTEGER PRIMARY KEY AUTOINCREMENT," + " name TEXT NOT NULL, created_by TEXT NOT NULL, created INTEGER NOT NULL)") + conn.execute( + "CREATE TABLE IF NOT EXISTS team_members(team_id INTEGER NOT NULL, email TEXT NOT NULL," + " role TEXT NOT NULL DEFAULT 'member', added INTEGER NOT NULL," + " PRIMARY KEY(team_id,email))") + conn.execute( + "CREATE TABLE IF NOT EXISTS team_devices(team_id INTEGER NOT NULL, device_id TEXT NOT NULL," + " added_by TEXT, added INTEGER NOT NULL, PRIMARY KEY(team_id,device_id))") + # conditional-access rules: first-match-wins allow/deny, evaluated at session.begin + conn.execute( + "CREATE TABLE IF NOT EXISTS access_rules(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100," + " action TEXT NOT NULL DEFAULT 'deny', subject_type TEXT NOT NULL DEFAULT 'any'," + " subject TEXT DEFAULT '', cidr TEXT DEFAULT '', weekdays TEXT DEFAULT ''," + " time_start INTEGER, time_end INTEGER, tz_offset INTEGER DEFAULT 0," + " reason TEXT DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1," + " created_by TEXT, created INTEGER NOT NULL DEFAULT 0)") + # SCIM 2.0 provisioning tokens (Bearer) minted by an admin + conn.execute( + "CREATE TABLE IF NOT EXISTS scim_tokens(token TEXT PRIMARY KEY, label TEXT," + " created_by TEXT, created INTEGER NOT NULL DEFAULT 0, last_used INTEGER DEFAULT 0)") + # central session recording: stored files + short-lived upload tokens + conn.execute( + "CREATE TABLE IF NOT EXISTS recordings(id INTEGER PRIMARY KEY AUTOINCREMENT," + " owner TEXT, host_id TEXT, session_id TEXT, name TEXT, path TEXT, size INTEGER DEFAULT 0," + " duration INTEGER DEFAULT 0, viewer TEXT, mime TEXT, created INTEGER NOT NULL DEFAULT 0)") + conn.execute( + "CREATE TABLE IF NOT EXISTS rec_tokens(token TEXT PRIMARY KEY, host_id TEXT, owner TEXT," + " session_id TEXT, created INTEGER, exp INTEGER)") + return conn + + +# ---------------------------------------------------------------- portal intel +RECENT_VISITORS = collections.deque(maxlen=120) # recent viewer sessions (+geo) +GEO_CACHE: dict[str, dict] = {} # ip -> geo (best-effort, cached) + + +async def geo_lookup(ip: str) -> dict: + """Best-effort IP geolocation via ip-api.com, cached. Local/failed -> {}.""" + try: + import ipaddress + a = ipaddress.ip_address(ip) + if a.is_private or a.is_loopback or a.is_link_local: + return {} + except Exception: + if not ip: + return {} + if ip in GEO_CACHE: + return GEO_CACHE[ip] + GEO_CACHE[ip] = {} # mark in-flight so we don't hammer the API + try: + import aiohttp + url = ("http://ip-api.com/json/" + ip + + "?fields=status,country,countryCode,regionName,city,isp,lat,lon") + async with aiohttp.ClientSession() as s: + async with s.get(url, timeout=aiohttp.ClientTimeout(total=5)) as r: + d = await r.json() + if isinstance(d, dict) and d.get("status") == "success": + GEO_CACHE[ip] = { + "country": d.get("country", ""), "cc": d.get("countryCode", ""), + "region": d.get("regionName", ""), "city": d.get("city", ""), + "isp": d.get("isp", ""), "lat": d.get("lat"), "lon": d.get("lon"), + } + except Exception: + pass + return GEO_CACHE.get(ip, {}) + + +# ---------------------------------------------- relay tiers / nearest-relay geo +_RELAYS_CACHE = None + + +def _relays(): + """Configured relay nodes [{id,region,url,lat,lon,tier}] for nearest-relay + geo selection. Falls back to a single self-entry (this server) when --relays + is unset, so selection always returns something on a one-node deployment.""" + global _RELAYS_CACHE + if _RELAYS_CACHE is not None: + return _RELAYS_CACHE + out = [] + raw = (ARGS.relays or "").strip() if ARGS else "" + if raw: + try: + arr = json.loads(raw) + if isinstance(arr, list): + for r in arr: + if not isinstance(r, dict) or not r.get("url"): + continue + out.append({ + "id": str(r.get("id", r.get("region", "relay"))), + "region": str(r.get("region", "")), + "url": str(r["url"]), + "lat": float(r.get("lat", 0.0)), + "lon": float(r.get("lon", 0.0)), + "tier": str(r.get("tier", "")), + }) + except Exception as e: + log.warning("relays: bad --relays JSON: %s", e) + if not out: + base = ARGS.public_url.rstrip("/") if (ARGS and ARGS.public_url) else "https://netdesk.space" + ws = base.replace("https://", "wss://").replace("http://", "ws://") + "/ws" + out = [{"id": "default", "region": "eu-central", "url": ws, + "lat": 50.11, "lon": 8.68, "tier": ""}] # Frankfurt VPS + _RELAYS_CACHE = out + return out + + +def _haversine_km(lat1, lon1, lat2, lon2): + import math + r = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlmb = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 + return 2 * r * math.asin(min(1.0, math.sqrt(a))) + + +def _nearest_relay(lat, lon, tier=None): + cands = _relays() + if tier: + filtered = [r for r in cands if not r.get("tier") or r["tier"] == tier] + if filtered: + cands = filtered + best, bestd = None, None + for r in cands: + d = _haversine_km(lat, lon, r["lat"], r["lon"]) + if bestd is None or d < bestd: + best, bestd = r, d + return best, bestd + + +async def api_relays(req): + """Public: the configured relay nodes (clients cache + may choose).""" + return web.json_response({"relays": _relays()}) + + +async def api_relay_best(req): + """Nearest relay for the caller. Explicit ?lat=&lon= wins; else geo-locate + the client IP; else the first/default relay. ?tier= filters by tier.""" + tier = req.query.get("tier") or None + lat = lon = None + via = "default" + try: + if "lat" in req.query and "lon" in req.query: + lat, lon = float(req.query["lat"]), float(req.query["lon"]) + via = "param" + except (TypeError, ValueError): + lat = lon = None + if lat is None: + ip = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or (req.remote or "")) + geo = await geo_lookup(ip) + if geo.get("lat") is not None and geo.get("lon") is not None: + lat, lon = float(geo["lat"]), float(geo["lon"]) + via = "geo" + relays = _relays() + if lat is None: + return web.json_response({"relay": relays[0] if relays else None, + "via": "default", "dist_km": None, "count": len(relays)}) + best, dist = _nearest_relay(lat, lon, tier) + return web.json_response({"relay": best, "via": via, + "dist_km": round(dist, 1) if dist is not None else None, + "count": len(relays)}) + + +def is_hidden(hid: str) -> bool: + try: + with _acct_db() as conn: + row = conn.execute("SELECT hidden FROM devices WHERE id=?", (hid,)).fetchone() + return bool(row and row[0]) + except Exception: + return False + + +def device_owner(hid: str) -> str: + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return (row[0] or "") if row else "" + except Exception: + return "" + + +def set_hidden(hid: str, hidden: bool): + with _acct_db() as conn: + conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen) VALUES(?,?,?,?,?) " + "ON CONFLICT(id) DO NOTHING", (hid, "", "", "", int(time.time()))) + conn.execute("UPDATE devices SET hidden=? WHERE id=?", (1 if hidden else 0, hid)) + conn.commit() + + +def _new_token(email): + tok = _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO tokens(tok,email,created) VALUES(?,?,?)", + (tok, email, int(time.time()))) + USER_SESSIONS[tok] = email + return tok + + +def email_for_token(tok): + if not tok: + return None + if tok in USER_SESSIONS: + return USER_SESSIONS[tok] + with _acct_db() as conn: + row = conn.execute("SELECT email FROM tokens WHERE tok=?", (tok,)).fetchone() + if row: + USER_SESSIONS[tok] = row[0] + return row[0] + return None + + +def _drop_token(tok): + USER_SESSIONS.pop(tok, None) + with _acct_db() as conn: + conn.execute("DELETE FROM tokens WHERE tok=?", (tok,)) + + +def _req_token(req): + auth = req.headers.get("Authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return req.cookies.get("nd_user", "") + +def _pbkdf(password, salt): + import hashlib as _h + return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex() + +def _mail_html(title, body_html, link=None, link_text=None): + link_html = "" + if link: + link_html = ( + '

' + '' + link_text + "

" + '

Or paste this link: ' + link + "

") + logo = ('' + '' + '' + '') + return ( + '
' + '
' + logo + + 'NetDesk
' + '

' + title + "

" + '
' + body_html + "
" + + link_html + + '

' + "If you did not request this, ignore this email.

") + +def send_mail(to, subject, html): + if not ARGS.mail_from: + raise RuntimeError("email not configured on this server") + msg = _MailMsg() + msg["From"] = "NetDesk <" + ARGS.mail_from + ">" + msg["To"] = to + msg["Subject"] = subject + msg.set_content("Your mail client does not support HTML.") + msg.add_alternative(html, subtype="html") + host = ARGS.smtp_host or "127.0.0.1" + with smtplib.SMTP(host, 25, timeout=20) as smtp: + smtp.send_message(msg) + +def user_from_cookie(req): + """Signed-in account email from the nd_user cookie or a Bearer token.""" + return email_for_token(_req_token(req)) + + +def public_base(req): + if ARGS.public_url: + return ARGS.public_url.rstrip("/") + proto = req.headers.get("X-Forwarded-Proto") or req.scheme + return f"{proto}://{req.host}" + + +async def _json_body(req): + try: + body = await req.json() + return body if isinstance(body, dict) else {} + except Exception: + return {} + + +def _login_response(email, extra=None): + tok = _new_token(email) + d = {"ok": True, "email": email, "token": tok, + "owner": bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower())} + d.update(extra or {}) + resp = web.json_response(d) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +# ------------------------------------------------- TOTP (account + portal 2FA) +# RFC 6238 (HMAC-SHA1, 30s step, 6 digits), pure-stdlib so there is no pyotp dep. +def _totp_new_secret(): + import base64 as _b64 + return _b64.b32encode(os.urandom(20)).decode("ascii").rstrip("=") + + +def _b32_decode(x): + import base64 as _b64 + x = (x or "").strip().replace(" ", "").upper() + x += "=" * ((8 - len(x) % 8) % 8) + return _b64.b32decode(x) + + +def _totp_at(secret_b32, counter): + import hmac as _hm, hashlib as _hl, struct as _st + key = _b32_decode(secret_b32) + h = _hm.new(key, _st.pack(">Q", counter), _hl.sha1).digest() + o = h[-1] & 0x0F + num = _st.unpack(">I", h[o:o + 4])[0] & 0x7FFF_FFFF + return "%06d" % (num % 1_000_000) + + +def _totp_verify(secret_b32, code, step=30, skew=1): + code = (code or "").strip() + if not secret_b32 or not code.isdigit() or len(code) != 6: + return False + counter = int(time.time()) // step + for d in range(-skew, skew + 1): + try: + if _secrets.compare_digest(_totp_at(secret_b32, counter + d), code): + return True + except Exception: + return False + return False + + +def _otpauth_uri(account, secret_b32, issuer="NetDesk"): + from urllib.parse import quote + label = quote(issuer + ":" + account) + return ("otpauth://totp/" + label + "?secret=" + secret_b32 + + "&issuer=" + quote(issuer) + "&algorithm=SHA1&digits=6&period=30") + + +async def auth_signup(req): + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + if "@" not in email or "." not in email.split("@")[-1] or len(email) < 5: + return web.json_response({"error": "enter a valid email"}, status=400) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + salt = _secrets.token_bytes(16) + vtok = _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + try: + conn.execute( + "INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) " + "VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now)) + except sqlite3.IntegrityError: + return web.json_response( + {"error": "email already registered - sign in or reset password"}, status=409) + log_event("auth", "signup: " + email) + # verification mail is best-effort and never blocks sign-up (RustDesk-style: + # the account works immediately; verifying only confirms the address) + if ARGS.mail_from: + link = public_base(req) + "/api/auth/verify?token=" + vtok + + def _send(): + try: + send_mail(email, "Verify your NetDesk account", + _mail_html("Confirm your email", + "Welcome to NetDesk. Your account is active - click below " + "to confirm this email address.", link, "Verify my email")) + except Exception as e: + log.warning("verify mail to %s failed: %s", email, e) + asyncio.get_running_loop().run_in_executor(None, _send) + return _login_response(email, {"msg": "account created - you are signed in"}) + +async def auth_verify(req): + tok = req.query.get("token", "") + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone() + if not row: + return web.Response(status=400, text="invalid or used link") + conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],)) + log_event("auth", "email verified") + raise web.HTTPFound(public_base(req) + "/app?verified=1") + +# ---------------------------------------------------------------- LDAP / AD +# Minimal pure-stdlib LDAPv3 simple-bind authentication (no ldap3 dependency). +# Enable with --ldap-url; the user's own credentials are bound against the +# directory, and a local account is auto-provisioned on first success. +def _ber_len(n): + if n < 0x80: + return bytes([n]) + out = b"" + while n: + out = bytes([n & 0xFF]) + out + n >>= 8 + return bytes([0x80 | len(out)]) + out + + +def _ber_tlv(tag, value): + return bytes([tag]) + _ber_len(len(value)) + value + + +def _ber_read_tlv(buf, off=0): + """Read one TLV at buf[off:] -> (tag, value_bytes, next_off).""" + tag = buf[off] + off += 1 + l0 = buf[off] + off += 1 + if l0 < 0x80: + length = l0 + else: + nb = l0 & 0x7F + length = int.from_bytes(buf[off:off + nb], "big") + off += nb + return tag, buf[off:off + length], off + length + + +def _recvn(sock, n): + out = b"" + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise IOError("LDAP connection closed") + out += chunk + return out + + +def _ldap_read_result_code(sock): + """Read one LDAPMessage, return the BindResponse resultCode (int).""" + tag = _recvn(sock, 1)[0] + l0 = _recvn(sock, 1)[0] + if l0 < 0x80: + length = l0 + else: + nb = l0 & 0x7F + length = int.from_bytes(_recvn(sock, nb), "big") + body = _recvn(sock, length) # SEQUENCE contents: messageID + protocolOp + _t, _mid, off = _ber_read_tlv(body, 0) # messageID INTEGER + optag, opval, _ = _ber_read_tlv(body, off) # protocolOp (BindResponse 0x61) + rtag, rval, _ = _ber_read_tlv(opval, 0) # resultCode ENUMERATED (0x0a) + return rval[0] if rval else -1 + + +def _ldap_bind_dn(email): + tmpl = (ARGS.ldap_bind_template or "{email}") + local = email.split("@")[0] + domain = email.split("@", 1)[1] if "@" in email else "" + return tmpl.replace("{user}", local).replace("{email}", email).replace("{domain}", domain) + + +def _ldap_simple_bind(host, port, use_tls, dn, password, timeout=6, insecure=False): + """Perform an LDAPv3 simple bind. Returns True iff resultCode == success(0).""" + import socket, ssl + version = _ber_tlv(0x02, bytes([3])) # version INTEGER 3 + name = _ber_tlv(0x04, dn.encode("utf-8")) # LDAPDN + auth = _ber_tlv(0x80, password.encode("utf-8")) # [0] simple + bindreq = _ber_tlv(0x60, version + name + auth) # [APPLICATION 0] + msg = _ber_tlv(0x30, _ber_tlv(0x02, bytes([1])) + bindreq) + s = socket.create_connection((host, port), timeout=timeout) + try: + if use_tls: + ctx = ssl.create_default_context() + if insecure: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + s = ctx.wrap_socket(s, server_hostname=host) + s.settimeout(timeout) + s.sendall(msg) + return _ldap_read_result_code(s) == 0 + finally: + try: + s.close() + except Exception: + pass + + +def _ldap_login_sync(email, pw): + """Blocking LDAP auth (run in an executor). Empty password is always + rejected so an LDAP 'unauthenticated bind' can never pass as success.""" + if not ARGS.ldap_url or not email or not pw: + return False + try: + import urllib.parse + u = urllib.parse.urlparse(ARGS.ldap_url) + use_tls = (u.scheme == "ldaps") + host = u.hostname + port = u.port or (636 if use_tls else 389) + if not host: + return False + dn = _ldap_bind_dn(email) + return _ldap_simple_bind(host, port, use_tls, dn, pw, + ARGS.ldap_timeout or 6, bool(ARGS.ldap_insecure)) + except Exception as e: + log.warning("LDAP bind error for %s: %s", email, e) + return False + + +def _ldap_provision(email): + """Ensure a local shadow account exists for an LDAP-authenticated user.""" + now = int(time.time()) + with _acct_db() as conn: + if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (email,)).fetchone(): + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # unused; LDAP is the authority + role = (ARGS.ldap_default_role or "member") + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " + "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) + conn.commit() + log_event("auth", f"LDAP provisioned account {email}") + + +async def _ldap_try(email, pw): + """True if LDAP is configured and the credentials bind successfully.""" + if not ARGS.ldap_url: + return False + ok = await asyncio.get_running_loop().run_in_executor(None, _ldap_login_sync, email, pw) + if ok: + _ldap_provision(email) + return ok + + +async def auth_login(req): + ip = req_ip(req) + locked = login_locked("account", ip) + if locked: + return web.json_response( + {"error": f"too many attempts — try again in {int(locked) + 1}s"}, + status=429) + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," + "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() + local_ok = bool(row) and _secrets.compare_digest(_pbkdf(pw, bytes.fromhex(row[0])), row[1]) + ldap_ok = False + if not local_ok: + # LDAP / Active Directory: bind the user's own credentials; on success + # a local shadow account is auto-provisioned and we re-read the row. + ldap_ok = await _ldap_try(email, pw) + if ldap_ok: + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," + "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() + if not (local_ok or ldap_ok): + login_fail("account", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong email or password"}, status=401) + if row and row[4]: # SCIM/IdP-deactivated account + return web.json_response({"error": "this account has been deactivated"}, status=403) + # Account 2FA: when enrolled, a valid TOTP code is also required. The client + # re-POSTs with "totp" after seeing the twofa flag. + sec = (row[3] if len(row) > 3 else "") or "" + if sec: + code = str(body.get("totp", "")).strip() + if not code: + return web.json_response({"error": "2FA code required", "twofa": True}, status=401) + if not _totp_verify(sec, code): + login_fail("account", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "invalid 2FA code", "twofa": True}, status=401) + login_ok("account", ip) + log_event("auth", "signed in: " + email + (" (ldap)" if ldap_ok else "") + (" (app)" if body.get("app") else "")) + return _login_response(email) + +async def auth_me(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + owner = bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + return web.json_response({"email": email, "owner": owner, "plan": plan_for(email)[0], + "limit": plan_for(email)[1] or 0}) + +async def auth_logout(req): + _drop_token(_req_token(req)) + resp = web.json_response({"ok": True}) + resp.del_cookie("nd_user") + return resp + + +# ------------------------------------------------- account 2FA (TOTP) endpoints +async def auth_2fa_status(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() + return web.json_response({"enabled": bool(row and row[0])}) + + +async def auth_2fa_setup(req): + """Generate a NEW (pending) secret + otpauth URI. Not saved until enable + confirms a code, so the client holds the secret between setup and enable.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + secret = _totp_new_secret() + return web.json_response({"secret": secret, "otpauth": _otpauth_uri(email, secret)}) + + +async def auth_2fa_enable(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + secret = str(body.get("secret", "")).strip() + code = str(body.get("code", "")).strip() + if not secret or not _totp_verify(secret, code): + return web.json_response({"error": "invalid code"}, status=400) + with _acct_db() as conn: + conn.execute("UPDATE users SET totp=? WHERE email=?", (secret, email)) + conn.commit() + log_event("auth", "2FA enabled: " + email) + return web.json_response({"ok": True, "enabled": True}) + + +async def auth_2fa_disable(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + with _acct_db() as conn: + row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() + sec = (row[0] if row else "") or "" + if sec and not _totp_verify(sec, str(body.get("code", "")).strip()): + return web.json_response({"error": "invalid code"}, status=400) + with _acct_db() as conn: + conn.execute("UPDATE users SET totp='' WHERE email=?", (email,)) + conn.commit() + log_event("auth", "2FA disabled: " + email) + return web.json_response({"ok": True, "enabled": False}) + + +# ------------------------------------------------- SSO: OpenID Connect (OIDC) +# Google / Azure / Okta / any OIDC provider. Configure with --oidc (or ND_OIDC): +# {"google":{"issuer":"https://accounts.google.com","client_id":"...","client_secret":"..."}} +# Discovery (/.well-known/openid-configuration) gives the authorize/token/jwks +# endpoints; the ID token is verified (RS256 via JWKS + iss/aud/exp/nonce), then +# the account is linked by email. PKCE (S256) + state/nonce guard the flow. +_OIDC_STATE = {} # state -> (provider, nonce, pkce_verifier, ts) +_OIDC_DISCO = {} # issuer -> (doc, ts) +_OIDC_JWKS = {} # jwks_uri -> (keys_by_kid, ts) + + +def _oidc_providers(): + raw = getattr(ARGS, "oidc", "") or os.environ.get("ND_OIDC", "") + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def _b64url_decode(s): + import base64 + s += "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s) + + +async def _http_json(url, method="GET", data=None): + import aiohttp + async with aiohttp.ClientSession() as s: + if method == "POST": + cm = s.post(url, data=data, timeout=aiohttp.ClientTimeout(total=8)) + else: + cm = s.get(url, timeout=aiohttp.ClientTimeout(total=8)) + async with cm as r: + return await r.json(content_type=None) + + +async def _oidc_discovery(issuer): + now = time.time() + c = _OIDC_DISCO.get(issuer) + if c and now - c[1] < 3600: + return c[0] + doc = await _http_json(issuer.rstrip("/") + "/.well-known/openid-configuration") + _OIDC_DISCO[issuer] = (doc, now) + return doc + + +async def _oidc_jwks(jwks_uri): + now = time.time() + c = _OIDC_JWKS.get(jwks_uri) + if c and now - c[1] < 3600: + return c[0] + doc = await _http_json(jwks_uri) + keys = {k.get("kid"): k for k in doc.get("keys", [])} + _OIDC_JWKS[jwks_uri] = (keys, now) + return keys + + +def _verify_oidc_jwt(id_token, jwks, issuer, client_id, nonce): + """Verify an OIDC ID token: RS256 signature (via JWKS) + iss/aud/exp/nonce. + Returns the claims dict, or raises ValueError.""" + from cryptography.hazmat.primitives.asymmetric import rsa, padding + from cryptography.hazmat.primitives import hashes + parts = id_token.split(".") + if len(parts) != 3: + raise ValueError("malformed") + header = json.loads(_b64url_decode(parts[0])) + payload = json.loads(_b64url_decode(parts[1])) + sig = _b64url_decode(parts[2]) + kid = header.get("kid") + jwk = jwks.get(kid) or (next(iter(jwks.values())) if len(jwks) == 1 else None) + if not jwk: + raise ValueError("no JWKS key") + n = int.from_bytes(_b64url_decode(jwk["n"]), "big") + e = int.from_bytes(_b64url_decode(jwk["e"]), "big") + pub = rsa.RSAPublicNumbers(e, n).public_key() + try: + pub.verify(sig, (parts[0] + "." + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256()) + except Exception: + raise ValueError("bad signature") + if issuer and payload.get("iss", "").rstrip("/") != issuer.rstrip("/"): + raise ValueError("bad iss") + aud = payload.get("aud") + if client_id and client_id not in (aud if isinstance(aud, list) else [aud]): + raise ValueError("bad aud") + if float(payload.get("exp", 0)) < time.time(): + raise ValueError("expired") + if nonce and payload.get("nonce") != nonce: + raise ValueError("bad nonce") + return payload + + +async def auth_oidc_start(req): + """Begin SSO: redirect the browser to the provider's authorize endpoint.""" + provider = req.match_info.get("provider", "") + cfg = _oidc_providers().get(provider) + if not cfg: + return web.json_response({"error": "SSO provider not configured"}, status=404) + import hashlib + import base64 as _b + from urllib.parse import urlencode + disco = await _oidc_discovery(cfg["issuer"]) + state = _secrets.token_urlsafe(24) + nonce = _secrets.token_urlsafe(16) + verifier = _secrets.token_urlsafe(48) + challenge = _b.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + now = time.time() + _OIDC_STATE[state] = (provider, nonce, verifier, now) + for k in [k for k, v in _OIDC_STATE.items() if now - v[3] > 600]: + _OIDC_STATE.pop(k, None) + redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" + url = disco["authorization_endpoint"] + "?" + urlencode({ + "client_id": cfg["client_id"], "redirect_uri": redirect_uri, + "response_type": "code", "scope": "openid email profile", + "state": state, "nonce": nonce, + "code_challenge": challenge, "code_challenge_method": "S256", + }) + raise web.HTTPFound(url) + + +async def auth_oidc_callback(req): + """Finish SSO: exchange the code, verify the ID token, link the account.""" + provider = req.match_info.get("provider", "") + cfg = _oidc_providers().get(provider) + if not cfg: + return web.json_response({"error": "SSO provider not configured"}, status=404) + code = req.query.get("code", "") + state = req.query.get("state", "") + st = _OIDC_STATE.pop(state, None) + if not st or st[0] != provider: + return web.json_response({"error": "invalid state"}, status=400) + _, nonce, verifier, _ = st + disco = await _oidc_discovery(cfg["issuer"]) + redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" + tok = await _http_json(disco["token_endpoint"], "POST", { + "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, + "client_id": cfg["client_id"], "client_secret": cfg.get("client_secret", ""), + "code_verifier": verifier, + }) + id_token = tok.get("id_token") + if not id_token: + return web.json_response({"error": "no id_token from provider"}, status=502) + jwks = await _oidc_jwks(disco["jwks_uri"]) + try: + payload = _verify_oidc_jwt(id_token, jwks, cfg["issuer"], cfg["client_id"], nonce) + except ValueError as ex: + return web.json_response({"error": f"id token invalid: {ex}"}, status=401) + email = (payload.get("email") or "").strip().lower() + if not email: + return web.json_response({"error": "no email in id token"}, status=401) + with _acct_db() as conn: + row = conn.execute("SELECT email FROM users WHERE email=?", (email,)).fetchone() + if not row: + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # random; SSO users never use a password + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created) VALUES(?,?,?,1,?)", + (email, salt.hex(), _pbkdf(pw, salt), int(time.time()))) + log_event("auth", f"SSO sign-in ({provider}): {email}") + tokv = _new_token(email) + resp = web.HTTPFound(public_base(req) + "/app") + resp.set_cookie("nd_user", tokv, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +# ------------------------------------------- REST API (X-API-Key) + webhooks +def owner_from_api_key(req): + """Account email behind an X-API-Key header, or None. Touches last_used.""" + key = req.headers.get("X-API-Key", "").strip() + if not key: + return None + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM api_keys WHERE key=?", (key,)).fetchone() + if row: + conn.execute("UPDATE api_keys SET last_used=? WHERE key=?", (int(time.time()), key)) + return row[0] + return None + + +async def _post_webhook(url, body): + try: + import aiohttp + async with aiohttp.ClientSession() as s: + await s.post(url, data=body, headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=5)) + except Exception: + pass + + +def fire_webhook(owner, event, payload): + """Deliver an event (connect/disconnect/file/test) to the owner's webhooks + that subscribe to it. Fire-and-forget on the running loop.""" + if not owner: + return + try: + with _acct_db() as conn: + hooks = conn.execute("SELECT url,events FROM webhooks WHERE owner=?", (owner,)).fetchall() + except Exception: + return + urls = [u for (u, ev) in hooks + if not ev or ev == "*" or event in [e.strip() for e in ev.split(",")]] + if not urls: + return + body = json.dumps({"event": event, "ts": int(time.time()), **payload}) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + for u in urls: + loop.create_task(_post_webhook(u, body)) + + +def _device_owner(hid): + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return row[0] if row else None + + +# ---- seat/licensing + MDM-lite policy (relay admission + central push) ---- +# Known policy keys the native host honors (viewer/src/host_ctl.rs apply_policy): +# server, reg_key, allow_ips, totp (strings), lock_on_end, approve_click (bools), +# perms{input,clipboard,files,audio}. The server stores + pushes the SAME json an +# admin would otherwise drop at %ProgramData%/NetDesk/policy.json on each machine. +_POLICY_KEYS = {"server", "reg_key", "allow_ips", "totp", + "lock_on_end", "approve_click", "perms", "record_required"} + + +def _owner_seats(owner): + """Max concurrently-registered hosts for an account; 0 = unlimited.""" + if not owner: + return 0 + try: + with _acct_db() as conn: + row = conn.execute("SELECT seats FROM licenses WHERE lower(owner)=lower(?)", + (owner,)).fetchone() + return int(row[0]) if row and row[0] else 0 + except Exception: + return 0 + + +def _owner_online_hosts(owner, exclude=None): + """Set of currently-connected host IDs owned by `owner` (minus `exclude`).""" + if not owner: + return set() + try: + with _acct_db() as conn: + rows = conn.execute("SELECT id FROM devices WHERE lower(owner)=lower(?)", + (owner,)).fetchall() + except Exception: + return set() + owned = {r[0] for r in rows} + out = set() + for hid in list(HOSTS.keys()): + if hid == exclude or hid not in owned: + continue + h = HOSTS.get(hid) + if h is None: + continue + try: + if h.ws.closed: + continue + except Exception: + pass + out.add(hid) + return out + + +def _seat_ok(owner, hid): + """True if admitting host `hid` for `owner` stays within the seat limit. + Re-registration of an already-online hid keeps its own seat (exclude=hid).""" + seats = _owner_seats(owner) + if seats <= 0: + return True + return len(_owner_online_hosts(owner, exclude=hid)) < seats + + +def _valid_policy(obj): + """Drop unrecognized keys so a push can't inject junk the host ignores anyway.""" + if not isinstance(obj, dict): + return {} + return {k: v for k, v in obj.items() if k in _POLICY_KEYS} + + +def _account_policy(owner): + """Owner's MDM-lite policy dict (empty if unset).""" + if not owner: + return {} + try: + with _acct_db() as conn: + row = conn.execute("SELECT policy FROM account_policy WHERE lower(owner)=lower(?)", + (owner,)).fetchone() + return json.loads(row[0]) if row and row[0] else {} + except Exception: + return {} + + +def _set_account_policy(owner, policy): + """Store a validated policy for `owner`; returns what was saved.""" + pj = json.dumps(_valid_policy(policy)) + with _acct_db() as conn: + conn.execute("INSERT INTO account_policy(owner,policy,updated) VALUES(?,?,?) " + "ON CONFLICT(owner) DO UPDATE SET policy=excluded.policy," + " updated=excluded.updated", + ((owner or "").strip().lower(), pj, int(time.time()))) + conn.commit() + return json.loads(pj) + + +# ---- API-key management (session-authed, from the logged-in portal) ---- +async def api_keys_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:64] or "key" + key = "ndk_" + _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO api_keys(key,owner,name,created) VALUES(?,?,?,?)", + (key, email, name, int(time.time()))) + log_event("apikey", f"{email} created API key '{name}'") + return web.json_response({"ok": True, "key": key, "name": name}) # shown once + + +async def api_keys_list(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT key,name,created,last_used FROM api_keys WHERE owner=? " + "ORDER BY created DESC", (email,)).fetchall() + out = [{"name": n, "created": c, "last_used": lu, "key_preview": k[:12] + "…" + k[-4:]} + for (k, n, c, lu) in rows] + return web.json_response({"keys": out}) + + +async def api_keys_revoke(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + k = str(body.get("key", "")).strip() + with _acct_db() as conn: + if k.startswith("ndk_") and "…" not in k: + cur = conn.execute("DELETE FROM api_keys WHERE key=? AND owner=?", (k, email)) + else: + pref = k.split("…")[0] + cur = conn.execute("DELETE FROM api_keys WHERE owner=? AND key LIKE ?", (email, pref + "%")) + return web.json_response({"ok": True, "revoked": cur.rowcount}) + + +# ---- /api/v1 (X-API-Key authed REST API) ---- +async def v1_devices(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT id,name,ver,last_seen,tags,grp FROM devices WHERE owner=? " + "ORDER BY name", (owner,)).fetchall() + out = [] + for hid, name, ver, seen, tags, grp in rows: + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], + "group": grp or "", "last_seen": seen or 0}) + return web.json_response({"devices": out}) + + +async def v1_webhooks_list(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + with _acct_db() as conn: + rows = conn.execute("SELECT id,url,events,created FROM webhooks WHERE owner=? ORDER BY id", + (owner,)).fetchall() + return web.json_response({"webhooks": [{"id": i, "url": u, "events": ev, "created": c} + for (i, u, ev, c) in rows]}) + + +async def v1_webhooks_add(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + body = await _json_body(req) + url = str(body.get("url", "")).strip() + if not (url.startswith("http://") or url.startswith("https://")): + return web.json_response({"error": "url must be http(s)"}, status=400) + events = str(body.get("events", "*")).strip() or "*" + with _acct_db() as conn: + cur = conn.execute("INSERT INTO webhooks(owner,url,events,created) VALUES(?,?,?,?)", + (owner, url, events, int(time.time()))) + wid = cur.lastrowid + log_event("webhook", f"{owner} added webhook {url} [{events}]") + return web.json_response({"ok": True, "id": wid, "url": url, "events": events}) + + +async def v1_webhooks_remove(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + body = await _json_body(req) + with _acct_db() as conn: + cur = conn.execute("DELETE FROM webhooks WHERE id=? AND owner=?", (body.get("id"), owner)) + return web.json_response({"ok": True, "removed": cur.rowcount}) + + +async def v1_webhooks_test(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) + fire_webhook(owner, "test", {"message": "NetDesk webhook test", "owner": owner}) + return web.json_response({"ok": True, "fired": "test"}) + +def _user_role(conn, email): + """RBAC role of an account ('admin' or 'member').""" + row = conn.execute("SELECT role FROM users WHERE email=?", (email,)).fetchone() + return row[0] if row and row[0] else "member" + + +async def api_devices(req): + """Computers bound to the signed-in account (address book), with live online + state + tags/group. Filters: ?tag= / ?group=. An admin may pass + ?scope=all to list every account's devices (RBAC).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tag = req.query.get("tag", "").strip().lower() + group = req.query.get("group", "").strip() + scope_all = req.query.get("scope", "") == "all" + with _acct_db() as conn: + role = _user_role(conn, email) + admin_all = scope_all and role == "admin" + if admin_all: + rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " + "ORDER BY grp,name").fetchall() + else: + rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " + "WHERE owner=? ORDER BY grp,name", (email,)).fetchall() + out = [] + groups = set() + for hid, name, ver, seen, mac, tags, grp, owner in rows: + tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] + if tag and tag not in [t.lower() for t in tag_list]: + continue + if group and (grp or "") != group: + continue + groups.add(grp or "") + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "mac": mac or "", "tags": tag_list, "group": grp or "", + "owner": owner if admin_all else None, + "last_seen": seen or 0}) + # also surface devices shared into any team the user belongs to (shared + # address book) - deduped vs the user's own devices, flagged shared + team. + if not admin_all: + have = {d["id"] for d in out} + with _acct_db() as tconn: + trows = tconn.execute( + "SELECT d.id,d.name,d.ver,d.last_seen,d.mac,d.tags,d.grp,d.owner,t.name " + "FROM team_members m JOIN team_devices td ON td.team_id=m.team_id " + "JOIN devices d ON d.id=td.device_id JOIN teams t ON t.id=m.team_id " + "WHERE lower(m.email)=lower(?) ORDER BY t.name,d.grp,d.name", (email,)).fetchall() + for hid, name, ver, seen, mac, tags, grp, owner, tname in trows: + if hid in have: + continue + have.add(hid) + tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] + if tag and tag not in [t.lower() for t in tag_list]: + continue + if group and (grp or "") != group: + continue + groups.add(grp or "") + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "mac": mac or "", "tags": tag_list, "group": grp or "", + "owner": owner, "shared": True, "team": tname, + "last_seen": seen or 0}) + return web.json_response({"devices": out, "role": role, + "groups": sorted(g for g in groups if g)}) + + +# ===================== teams (shared address books) ===================== +def _user_teams(conn, email): + """[(team_id, team_name, my_role)] for every team the account belongs to.""" + return conn.execute( + "SELECT t.id, t.name, m.role FROM teams t JOIN team_members m ON m.team_id=t.id " + "WHERE lower(m.email)=lower(?) ORDER BY t.name", (email,)).fetchall() + + +def _team_role(conn, team_id, email): + row = conn.execute("SELECT role FROM team_members WHERE team_id=? AND lower(email)=lower(?)", + (team_id, email)).fetchone() + return row[0] if row else None + + +def _can_manage_team(conn, team_id, email): + """True if the caller may administer the team: its owner, or an account admin.""" + if _user_role(conn, email) == "admin": + return True + return _team_role(conn, team_id, email) == "owner" + + +def _team_id_arg(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +async def api_teams(req): + """Teams the signed-in account belongs to (shared address books).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + rows = _user_teams(conn, email) + out = [] + for tid, name, myrole in rows: + nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] + nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] + cb = conn.execute("SELECT created_by, created FROM teams WHERE id=?", (tid,)).fetchone() + out.append({"id": tid, "name": name, "role": myrole, "members": nm, "devices": nd, + "created_by": cb[0] if cb else "", "created": cb[1] if cb else 0}) + return web.json_response({"teams": out}) + + +async def api_team_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:80] + if not name: + return web.json_response({"error": "team name required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", (name, email, now)) + tid = cur.lastrowid + conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, email, "owner", now)) + conn.commit() + log_event("team", f"{email} created team '{name}' (#{tid})", email) + return web.json_response({"ok": True, "id": tid, "name": name}) + + +async def api_team_rename(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + name = str(body.get("name", "")).strip()[:80] + if tid is None or not name: + return web.json_response({"error": "team_id + name required"}, status=400) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) + conn.commit() + return web.json_response({"ok": True, "id": tid, "name": name}) + + +async def api_team_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM teams WHERE id=?", (tid,)) + conn.commit() + log_event("team", f"{email} deleted team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_members(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tid = _team_id_arg(req.query.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": + return web.json_response({"error": "not a team member"}, status=403) + rows = conn.execute("SELECT email, role, added FROM team_members WHERE team_id=? " + "ORDER BY role DESC, email", (tid,)).fetchall() + can = _can_manage_team(conn, tid, email) + return web.json_response({"members": [{"email": e, "role": r, "added": a} for (e, r, a) in rows], + "can_manage": can}) + + +async def api_team_member_add(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + target = str(body.get("email", "")).strip().lower() + role = str(body.get("role", "member")).strip().lower() + if role not in ("owner", "member"): + role = "member" + if tid is None or not target: + return web.json_response({"error": "team_id + email required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + if not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (target,)).fetchone(): + return web.json_response({"error": "no NetDesk account with that email"}, status=404) + conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?) " + "ON CONFLICT(team_id,email) DO UPDATE SET role=excluded.role", + (tid, target, role, now)) + conn.commit() + log_event("team", f"{email} added {target} to team #{tid} as {role}", email) + return web.json_response({"ok": True}) + + +async def api_team_member_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + target = str(body.get("email", "")).strip().lower() + if tid is None or not target: + return web.json_response({"error": "team_id + email required"}, status=400) + with _acct_db() as conn: + # a member can remove themselves (leave); an owner/admin can remove anyone + if target != email.lower() and not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not a team owner"}, status=403) + owners = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=? AND role='owner'", + (tid,)).fetchone()[0] + if _team_role(conn, tid, target) == "owner" and owners <= 1: + return web.json_response({"error": "a team needs at least one owner"}, status=400) + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?)", (tid, target)) + conn.commit() + log_event("team", f"{email} removed {target} from team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_devices(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + tid = _team_id_arg(req.query.get("team_id")) + if tid is None: + return web.json_response({"error": "team_id required"}, status=400) + with _acct_db() as conn: + if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": + return web.json_response({"error": "not a team member"}, status=403) + rows = conn.execute( + "SELECT d.id,d.name,d.ver,d.last_seen,d.tags,d.grp,d.owner FROM team_devices td " + "JOIN devices d ON d.id=td.device_id WHERE td.team_id=? ORDER BY d.grp,d.name", (tid,)).fetchall() + can = _can_manage_team(conn, tid, email) + out = [] + for hid, name, ver, seen, tags, grp, owner in rows: + h = HOSTS.get(hid) + out.append({"id": hid, "name": name or hid, "ver": ver or "", + "online": h is not None and not h.ws.closed, + "busy": bool(h and h.session is not None), + "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], + "group": grp or "", "owner": owner, "last_seen": seen or 0}) + return web.json_response({"devices": out, "can_manage": can}) + + +async def api_team_device_add(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + hid = str(body.get("device_id", "")).strip() + if tid is None or not hid: + return web.json_response({"error": "team_id + device_id required"}, status=400) + now = int(time.time()) + with _acct_db() as conn: + role = _user_role(conn, email) + if _team_role(conn, tid, email) is None and role != "admin": + return web.json_response({"error": "not a team member"}, status=403) + drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + if not drow: + return web.json_response({"error": "unknown device"}, status=404) + if drow[0] != email and role != "admin": + return web.json_response({"error": "not your device"}, status=403) + conn.execute("INSERT OR IGNORE INTO team_devices(team_id,device_id,added_by,added) " + "VALUES(?,?,?,?)", (tid, hid, email, now)) + conn.commit() + log_event("team", f"{email} shared device {hid} into team #{tid}", email) + return web.json_response({"ok": True}) + + +async def api_team_device_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + tid = _team_id_arg(body.get("team_id")) + hid = str(body.get("device_id", "")).strip() + if tid is None or not hid: + return web.json_response({"error": "team_id + device_id required"}, status=400) + with _acct_db() as conn: + drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + owns = bool(drow) and drow[0] == email + if not owns and not _can_manage_team(conn, tid, email): + return web.json_response({"error": "not allowed"}, status=403) + conn.execute("DELETE FROM team_devices WHERE team_id=? AND device_id=?", (tid, hid)) + conn.commit() + return web.json_response({"ok": True}) + + +# ===================== conditional-access rule engine ===================== +def _device_owner_group(hid): + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner, grp FROM devices WHERE id=?", (hid,)).fetchone() + if row: + return (row[0] or ""), (row[1] or "") + except Exception: + pass + return "", "" + + +def _access_rules_for(conn, owner): + rows = conn.execute( + "SELECT id,priority,action,subject_type,subject,cidr,weekdays,time_start,time_end," + "tz_offset,reason,enabled,created,created_by FROM access_rules " + "WHERE lower(owner)=lower(?) ORDER BY priority, id", (owner,)).fetchall() + out = [] + for r in rows: + out.append({"id": r[0], "priority": r[1], "action": r[2], "subject_type": r[3], + "subject": r[4] or "", "cidr": r[5] or "", "weekdays": r[6] or "", + "time_start": r[7], "time_end": r[8], "tz_offset": r[9] or 0, + "reason": r[10] or "", "enabled": bool(r[11]), + "created": r[12] or 0, "created_by": r[13] or ""}) + return out + + +def _ip_in_cidr(ip, cidr): + try: + import ipaddress + return ipaddress.ip_address(ip) in ipaddress.ip_network(cidr, strict=False) + except Exception: + return False + + +def _rule_matches(rule, viewer_email, host_id, host_group, ip, now_ts): + st = rule["subject_type"] + sub = (rule["subject"] or "").strip() + if st == "user": + if not viewer_email or viewer_email.lower() != sub.lower(): + return False + elif st == "device": + if host_id != sub: + return False + elif st == "group": + if (host_group or "") != sub: + return False + if rule["cidr"]: + if not ip or not _ip_in_cidr(ip, rule["cidr"]): + return False + wd = (rule["weekdays"] or "").strip() + ts, te = rule["time_start"], rule["time_end"] + if wd or (ts is not None and te is not None): + import datetime + local = datetime.datetime.utcfromtimestamp(now_ts + (rule["tz_offset"] or 0) * 60) + if wd: + days = set(int(x) for x in wd.split(",") if x.strip().isdigit()) + if local.weekday() not in days: # Monday=0 .. Sunday=6 + return False + if ts is not None and te is not None: + minute = local.hour * 60 + local.minute + if ts <= te: + if not (ts <= minute <= te): + return False + else: # overnight window, e.g. 22:00-06:00 + if not (minute >= ts or minute <= te): + return False + return True + + +def _eval_access(owner, viewer_email, host_id, host_group, ip, now_ts): + """First-match-wins, default ALLOW. Returns (allowed, reason, rule_id).""" + if not owner: + return True, "", None + try: + with _acct_db() as conn: + rules = _access_rules_for(conn, owner) + except Exception: + return True, "", None + for r in rules: + if not r["enabled"]: + continue + if _rule_matches(r, viewer_email, host_id, host_group, ip, now_ts): + if r["action"] == "deny": + return False, (r["reason"] or "Access denied by policy"), r["id"] + return True, "", r["id"] + return True, "", None + + +def _rule_int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +async def api_access_rules(req): + """List the conditional-access rules protecting an account's hosts.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + target = (req.query.get("owner", "").strip().lower() or email.lower()) + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + with _acct_db() as conn: + rules = _access_rules_for(conn, target) + return web.json_response({"owner": target, "rules": rules}) + + +async def api_access_rule_set(req): + """Create (no id) or update (id) an access rule. Owner-self, or admin for any.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + target = str(body.get("owner", "")).strip().lower() or email.lower() + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + action = str(body.get("action", "deny")).strip().lower() + if action not in ("allow", "deny"): + return web.json_response({"error": "action must be allow|deny"}, status=400) + st = str(body.get("subject_type", "any")).strip().lower() + if st not in ("any", "user", "device", "group"): + return web.json_response({"error": "bad subject_type"}, status=400) + prio = _rule_int(body.get("priority")) + if prio is None: + prio = 100 + ts = _rule_int(body.get("time_start")) + te = _rule_int(body.get("time_end")) + tz = _rule_int(body.get("tz_offset")) or 0 + cidr = str(body.get("cidr", "")).strip() + if cidr: + try: + import ipaddress + ipaddress.ip_network(cidr, strict=False) + except Exception: + return web.json_response({"error": "invalid CIDR"}, status=400) + wd = str(body.get("weekdays", "")).strip() + subject = str(body.get("subject", "")).strip() + reason = str(body.get("reason", "")).strip()[:200] + enabled = 1 if body.get("enabled", True) else 0 + rid = _rule_int(body.get("id")) + now = int(time.time()) + with _acct_db() as conn: + if rid: + row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() + if not row or (row[0] or "").lower() != target: + return web.json_response({"error": "unknown rule"}, status=404) + conn.execute("UPDATE access_rules SET priority=?,action=?,subject_type=?,subject=?,cidr=?," + "weekdays=?,time_start=?,time_end=?,tz_offset=?,reason=?,enabled=? WHERE id=?", + (prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, rid)) + else: + cur = conn.execute("INSERT INTO access_rules(owner,priority,action,subject_type,subject," + "cidr,weekdays,time_start,time_end,tz_offset,reason,enabled,created_by,created) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (target, prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, email, now)) + rid = cur.lastrowid + conn.commit() + log_event("access", f"{email} {'updated' if body.get('id') else 'added'} rule #{rid} " + f"({action} {st} {subject or '*'}) for {target}", email) + return web.json_response({"ok": True, "id": rid}) + + +async def api_access_rule_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + rid = _rule_int(body.get("id")) + if rid is None: + return web.json_response({"error": "id required"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() + if not row: + return web.json_response({"error": "unknown rule"}, status=404) + if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": + return web.json_response({"error": "not your rule"}, status=403) + conn.execute("DELETE FROM access_rules WHERE id=?", (rid,)) + conn.commit() + log_event("access", f"{email} deleted access rule #{rid}", email) + return web.json_response({"ok": True}) + + +async def api_access_test(req): + """Dry-run the access decision for a hypothetical connection (rule tester).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + target = (req.query.get("owner", "").strip().lower() or email.lower()) + if target != email.lower(): + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only for other accounts"}, status=403) + viewer = req.query.get("viewer", "").strip() + host_id = req.query.get("host", "").strip() + group = req.query.get("group", "").strip() + ip = req.query.get("ip", "").strip() + now_ts = _rule_int(req.query.get("at")) or int(time.time()) + ok, reason, rid = _eval_access(target, viewer, host_id, group, ip, now_ts) + return web.json_response({"allowed": ok, "reason": reason, "rule_id": rid}) + + +async def api_device_meta(req): + """Set a device's address-book tags / group (and optional rename). Owner-only, + or an admin for any device.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + try: + data = await req.json() + except Exception: + data = dict(await req.post()) + hid = (data.get("id") or "").strip() + if not hid: + return web.json_response({"error": "missing id"}, status=400) + tags = (data.get("tags") or "").strip() + grp = (data.get("group") or data.get("grp") or "").strip() + name = data.get("name") + with _acct_db() as conn: + role = _user_role(conn, email) + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + if not row: + return web.json_response({"error": "unknown device"}, status=404) + if row[0] != email and role != "admin": + return web.json_response({"error": "not your device"}, status=403) + sets, vals = ["tags=?", "grp=?"], [tags, grp] + if name is not None: + sets.append("name=?") + vals.append(str(name)) + vals.append(hid) + conn.execute("UPDATE devices SET " + ",".join(sets) + " WHERE id=?", vals) + log_event("devmeta", f"{email} set {hid} tags='{tags}' group='{grp}'") + return web.json_response({"ok": True, "id": hid, "tags": tags, "group": grp}) + + +async def api_admin_role(req): + """RBAC: an admin sets another account's role ('admin' | 'member').""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + try: + data = await req.json() + except Exception: + data = dict(await req.post()) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + target = (data.get("email") or "").strip().lower() + role = (data.get("role") or "").strip().lower() + if role not in ("admin", "member"): + return web.json_response({"error": "role must be admin|member"}, status=400) + cur = conn.execute("UPDATE users SET role=? WHERE email=?", (role, target)) + if cur.rowcount == 0: + return web.json_response({"error": "unknown user"}, status=404) + log_event("rbac", f"{email} set role of {target} -> {role}") + return web.json_response({"ok": True, "email": target, "role": role}) + + +# ---- MDM-lite policy + seat/licensing (REST, X-API-Key / admin session) ---- +async def v1_policy_get(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "valid X-API-Key required"}, status=401) + return web.json_response({ + "policy": _account_policy(owner), + "seats": _owner_seats(owner), + "seats_in_use": len(_owner_online_hosts(owner)), + "policy_keys": sorted(_POLICY_KEYS), + }) + + +async def v1_policy_set(req): + owner = owner_from_api_key(req) + if not owner: + return web.json_response({"error": "valid X-API-Key required"}, status=401) + body = await _json_body(req) + pol = body.get("policy", body) # accept {"policy": {...}} or a bare object + saved = _set_account_policy(owner, pol) + log_event("policy", f"{owner} set MDM policy ({len(saved)} keys)") + return web.json_response({"ok": True, "policy": saved}) + + +async def api_admin_license(req): + """Admin sets an account's seat limit (0 = unlimited).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + data = await _json_body(req) + target = str(data.get("email", "")).strip().lower() + try: + seats = int(data.get("seats")) + except (TypeError, ValueError): + return web.json_response({"error": "seats must be an integer (0 = unlimited)"}, + status=400) + if not target or seats < 0: + return web.json_response({"error": "email + seats>=0 required"}, status=400) + with _acct_db() as conn: + conn.execute("INSERT INTO licenses(owner,seats,updated) VALUES(?,?,?) " + "ON CONFLICT(owner) DO UPDATE SET seats=excluded.seats," + " updated=excluded.updated", + (target, seats, int(time.time()))) + conn.commit() + log_event("license", f"{email} set seats of {target} -> {seats}") + return web.json_response({"ok": True, "email": target, "seats": seats}) + + +async def api_wake(req): + """Wake-on-LAN: relay a wake request for an offline computer through any + other ONLINE computer on the same account (which broadcasts the magic + packet on its local network - works when both are on the same LAN).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + hid = str((await _json_body(req)).get("id", "")) + with _acct_db() as conn: + row = conn.execute("SELECT mac FROM devices WHERE id=? AND owner=?", + (hid, email)).fetchone() + others = conn.execute("SELECT id FROM devices WHERE owner=? AND id<>?", + (email, hid)).fetchall() + if row is None: + return web.json_response({"error": "not one of your computers"}, status=404) + h = HOSTS.get(hid) + if h is not None and not h.ws.closed: + return web.json_response({"ok": True, "already_online": True}) + mac = row[0] or "" + if not mac: + return web.json_response({"error": "no MAC recorded for this computer yet " + "(it is stored the next time it comes online)"}) + sent = 0 + for (oid,) in others: + oh = HOSTS.get(oid) + if oh is not None and not oh.ws.closed: + try: + await send_json(oh.ws, {"t": "wol", "mac": mac}) + sent += 1 + except Exception: + pass + if not sent: + return web.json_response({"error": "no other online computer on your account " + "can broadcast the wake packet"}) + log_event("wake", f"WoL for {hid} relayed via {sent} computer(s)") + return web.json_response({"ok": True, "relayed": sent}) + +async def api_admin_users(req): + """All accounts (admin console): role, verified, 2FA, seat limit, device count.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute( + "SELECT email, role, verified, created, " + "CASE WHEN totp IS NOT NULL AND totp<>'' THEN 1 ELSE 0 END, " + "(SELECT seats FROM licenses WHERE lower(owner)=lower(users.email)) " + "FROM users ORDER BY created DESC").fetchall() + devc = dict(conn.execute("SELECT owner, COUNT(*) FROM devices GROUP BY owner").fetchall()) + out = [{"email": em, "role": role or "member", "verified": bool(ver), "created": created or 0, + "twofa": bool(t), "seats": se or 0, "devices": devc.get(em, 0)} + for (em, role, ver, created, t, se) in rows] + return web.json_response({"users": out, "me": email}) + + +async def api_admin_overview(req): + """Org counts for the admin console dashboard.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + nusers = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + nadmins = conn.execute("SELECT COUNT(*) FROM users WHERE role='admin'").fetchone()[0] + ndevices = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] + nkeys = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] + online = sum(1 for h in HOSTS.values() if not h.ws.closed) + insess = sum(1 for h in HOSTS.values() if h.session is not None) + return web.json_response({"users": nusers, "admins": nadmins, "devices": ndevices, + "api_keys": nkeys, "hosts_online": online, "in_session": insess, + "relays": len(_relays())}) + + +async def api_admin_teams(req): + """All teams in the org (admin console). Any team can then be managed via the + team endpoints, which honour an account-admin override.""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute("SELECT id,name,created_by,created FROM teams ORDER BY name").fetchall() + out = [] + for tid, name, cb, created in rows: + nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] + nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] + out.append({"id": tid, "name": name, "created_by": cb, "created": created, + "members": nm, "devices": nd}) + return web.json_response({"teams": out}) + + +async def api_admin_policy(req): + """Session-admin view/set of an account's MDM-lite policy (the operator + equivalent of the X-API-Key /api/v1/policy, for the admin console UI).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + if req.method == "GET": + target = (req.query.get("email", "").strip().lower() or email) + return web.json_response({"email": target, "policy": _account_policy(target), + "seats": _owner_seats(target), "keys": sorted(_POLICY_KEYS)}) + body = await _json_body(req) + target = str(body.get("email", "")).strip().lower() + if not target: + return web.json_response({"error": "email required"}, status=400) + saved = _set_account_policy(target, body.get("policy", {})) + log_event("policy", f"{email} set MDM policy for {target} ({len(saved)} keys)") + return web.json_response({"ok": True, "email": target, "policy": saved}) + + +async def api_device_remove(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + hid = str((await _json_body(req)).get("id", "")) + with _acct_db() as conn: + conn.execute("DELETE FROM devices WHERE id=? AND owner=?", (hid, email)) + return web.json_response({"ok": True}) + +def bind_device(hid, email, name, ver, mac=""): + with _acct_db() as conn: + if email: + conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen,mac) VALUES(?,?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, name=excluded.name," + " ver=excluded.ver, last_seen=excluded.last_seen," + " mac=CASE WHEN excluded.mac<>'' THEN excluded.mac ELSE devices.mac END", + (hid, email, name, ver, int(time.time()), mac)) + else: + conn.execute("DELETE FROM devices WHERE id=?", (hid,)) + +def touch_device(hid): + try: + with _acct_db() as conn: + conn.execute("UPDATE devices SET last_seen=? WHERE id=?", (int(time.time()), hid)) + except Exception: + pass + +async def api_info(req): + """Public server info - lets clients auto-configure (ws URL, features).""" + base = public_base(req) + return web.json_response({ + "name": "NetDesk", "version": VERSION, + "ws": base.replace("https://", "wss://").replace("http://", "ws://") + "/ws", + "web": base, "reg_key_required": bool(ARGS.reg_key), + "accounts": True, "mail": bool(ARGS.mail_from)}) + + +async def api_contact(req): + """Website contact form -> email + audit log.""" + body = await _json_body(req) + name = str(body.get("name", "")).strip()[:120] + email = str(body.get("email", "")).strip()[:160] + topic = str(body.get("topic", "General")).strip()[:60] + message = str(body.get("message", "")).strip()[:4000] + if not name or "@" not in email or len(message) < 2: + return web.json_response({"error": "please fill in every field"}, status=400) + log_event("contact", f"{topic}: {name} <{email}>") + dest = ARGS.owner_email or ARGS.mail_from + if ARGS.mail_from and dest: + def _send(): + try: + send_mail(dest, f"[NetDesk contact] {topic} — {name}", + _mail_html("New contact message", + f"From: {name} <{email}>
Topic: {topic}" + f"

{message}".replace(chr(10), '
'))) + except Exception as e: + log.warning("contact mail failed: %s", e) + try: + asyncio.get_running_loop().run_in_executor(None, _send) + except Exception: + pass + return web.json_response({"ok": True}) + +async def auth_forgot(req): + body = await _json_body(req) + email = str(body.get("email", "")).strip().lower() + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone() + if row: + tok = _secrets.token_urlsafe(24) + conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?", + (tok, int(time.time()) + 1800, row[0])) + link = public_base(req) + "/app?reset=" + tok + try: + send_mail(email, "Reset your NetDesk password", + _mail_html("Password reset", + "Click below to choose a new password. " + "The link expires in 30 minutes.", link, "Reset password")) + except Exception as e: + log.warning("reset mail to %s failed: %s", email, e) + return web.json_response( + {"ok": True, "msg": "if that account exists, a reset email was sent"}) + +async def auth_reset(req): + body = await _json_body(req) + tok, pw = str(body.get("token", "")), str(body.get("password", "")) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?", + (tok, int(time.time()))).fetchone() + if not row: + return web.json_response({"error": "link expired or invalid"}, status=400) + salt = _secrets.token_bytes(16) + conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL " + "WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0])) + log_event("auth", "password reset") + return web.json_response({"ok": True}) + +TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket +TUNNEL_TID = [1] + +# session plans: guests 20 min, verified users 45 min, owner unlimited +PLAN_LIMITS = {"owner": None, "user": 45 * 60, "guest": 20 * 60} + +def plan_for(email): + if email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower(): + return "owner", None + if email: + return "user", PLAN_LIMITS["user"] + return "guest", PLAN_LIMITS["guest"] + + +def host_is_owners(hid): + """The server owner's OWN computers are never time-limited - whoever + holds the unattended password already has full control; the limit is a + lever for third parties using the free relay, not a security control.""" + if not ARGS.owner_email: + return False + try: + with _acct_db() as conn: + row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() + return bool(row and row[0] and row[0].lower() == ARGS.owner_email.lower()) + except Exception: + return False + + +# ---- ICE servers for the hybrid transport (cached Cloudflare TURN creds) ---- +_ICE_CACHE = {"servers": None, "exp": 0.0} +_DEFAULT_ICE = [{"urls": ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"]}] + + +def _mint_cloudflare_ice(): + import urllib.request + req = urllib.request.Request( + "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers" + % ARGS.turn_key_id, + data=json.dumps({"ttl": 86400}).encode(), + headers={"Authorization": "Bearer " + ARGS.turn_token, + "Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=8) as r: + d = json.loads(r.read().decode()) + servers = d.get("iceServers") + if isinstance(servers, dict): # older response shape + servers = [servers] + return servers or None + + +def _mint_secret_ice(): + """Our own coturn (use-auth-secret): TURN REST creds = 'expiry:nd' + + base64(HMAC-SHA1(secret, username)). Valid 12h, no API call needed.""" + import hmac, hashlib, base64 + user = "%d:nd" % (int(time.time()) + 12 * 3600) + cred = base64.b64encode(hmac.new(ARGS.turn_secret.encode(), user.encode(), + hashlib.sha1).digest()).decode() + host = ARGS.turn_host + return [{"urls": ["stun:%s:3478" % host]}, + {"urls": ["turn:%s:3478?transport=udp" % host, + "turn:%s:3478?transport=tcp" % host, + "turns:%s:5349?transport=tcp" % host], + "username": user, "credential": cred}] + + +async def ice_servers(): + """STUN always; TURN when configured. Cloudflare creds are minted once + and reused for ~20h (they live 24h) - one API call per day, not per call. + + With --always-use-relay the server hands out NO ICE servers, so clients + cannot gather P2P candidates and every session stays on the WSS relay — a + privacy/compliance control (no peer IP exposure, all traffic through the + operator's server), matching RustDesk's ALWAYS_USE_RELAY.""" + if getattr(ARGS, "always_use_relay", False): + return [] + now_t = time.time() + if _ICE_CACHE["servers"] and now_t < _ICE_CACHE["exp"]: + return _ICE_CACHE["servers"] + servers = None + if ARGS.turn_secret and ARGS.turn_host: + servers = _mint_secret_ice() + _ICE_CACHE.update(servers=servers, exp=now_t + 6 * 3600) + return servers + if ARGS.turn_key_id and ARGS.turn_token: + try: + servers = await asyncio.get_event_loop().run_in_executor(None, _mint_cloudflare_ice) + except Exception as e: + log.warning("cloudflare TURN mint failed: %s", e) + elif ARGS.turn_urls: + servers = _DEFAULT_ICE + [{"urls": [u.strip() for u in ARGS.turn_urls.split(",") if u.strip()], + "username": ARGS.turn_user, "credential": ARGS.turn_pass}] + if not servers: + return _DEFAULT_ICE + _ICE_CACHE.update(servers=servers, exp=now_t + 20 * 3600) + return servers + +# binaries offered for download from the portal (put e.g. NetDeskHost.exe here) +_server_root = _nd_data_root() +FILES_DIR = os.path.join(_server_root, "files") + + +AUDIT_LOG = os.path.join(_server_root, "audit.log") +NL = chr(10) + +def log_event(kind: str, msg: str, actor: str = ""): + """In-memory ring for the portal + persistent JSONL audit file + (survives restarts, like RustDesk 1.4.9 audit logs).""" + EVENTS.appendleft({"ts": int(time.time()), "kind": kind, "msg": msg, "actor": actor}) + try: + with open(AUDIT_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps({"ts": int(time.time()), "kind": kind, + "msg": msg, "actor": actor}) + NL) + except Exception: + pass + + +def owner_from_cookie(req) -> bool: + email = user_from_cookie(req) + return bool(email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + + +def _load_audit(): + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + for line in f.readlines()[-200:]: + try: + EVENTS.appendleft(json.loads(line)) + except Exception: + pass + except FileNotFoundError: + pass + + +def _read_audit(kind=None, q=None, actor=None, since=None, until=None, limit=500): + """Full persistent audit.log (JSONL), newest first, filtered. This is the + enterprise/compliance report source (RustDesk/TeamViewer-style connection + + event history), beyond the 200-entry in-memory ring the overview shows.""" + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + lines = f.readlines() + except Exception: + lines = [] + ql = q.lower() if q else None + al = actor.lower() if actor else None + out = [] + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except Exception: + continue + ts = e.get("ts", 0) + if since and ts < since: + continue + if until and ts > until: + continue + if kind and e.get("kind") != kind: + continue + if al and al not in (e.get("actor") or "").lower(): + continue + if ql: + hay = (str(e.get("msg", "")) + " " + str(e.get("kind", "")) + " " + + str(e.get("actor") or "")).lower() + if ql not in hay: + continue + out.append(e) + if len(out) >= limit: + break + return out + + +def _audit_query(req): + q = req.query + def _int(name): + try: + return int(q[name]) if q.get(name) else None + except (TypeError, ValueError): + return None + return dict(kind=(q.get("kind") or None), q=(q.get("q") or None), + actor=(q.get("actor") or None), since=_int("since"), until=_int("until")) + + +async def portal_audit(req): + """Filterable audit/connection history for the operator console.""" + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + qp = _audit_query(req) + try: + limit = min(int(req.query.get("limit") or 500), 5000) + except (TypeError, ValueError): + limit = 500 + events = _read_audit(limit=limit, **qp) + # the distinct event kinds present, for the filter dropdown + try: + with open(AUDIT_LOG, "r", encoding="utf-8") as f: + kinds = sorted({json.loads(l).get("kind", "") for l in f.readlines()[-4000:] + if l.strip()} - {""}) + except Exception: + kinds = sorted({e.get("kind", "") for e in events} - {""}) + return web.json_response({"events": events, "count": len(events), "kinds": kinds}) + + +async def portal_audit_csv(req): + """CSV export of the (filtered) audit log — SIEM / compliance import.""" + if not portal_authorized(req): + return web.Response(status=401, text="unauthorized") + import csv as _csv, io as _io, datetime as _dt + qp = _audit_query(req) + try: + limit = min(int(req.query.get("limit") or 50000), 200000) + except (TypeError, ValueError): + limit = 50000 + events = _read_audit(limit=limit, **qp) + buf = _io.StringIO() + w = _csv.writer(buf) + w.writerow(["timestamp_utc", "unix_ts", "kind", "actor", "message"]) + for e in events: + ts = e.get("ts", 0) + iso = (_dt.datetime.utcfromtimestamp(ts).isoformat() + "Z") if ts else "" + w.writerow([iso, ts, e.get("kind", ""), e.get("actor", ""), e.get("msg", "")]) + fname = "netdesk-audit-%d.csv" % int(time.time()) + return web.Response( + body=buf.getvalue().encode("utf-8"), + headers={"Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="%s"' % fname}) + + +def portal_authorized(req) -> bool: + if owner_from_cookie(req): + return True + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS + + +# ---------------------------------------------------------------- HTTP + +MIME = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".xml": "application/xml", + ".webmanifest": "application/manifest+json", +} + +# marketing pages are public & indexable; the app/console are not +NOINDEX_FILES = {"app.html", "portal.html", "portal.js", "app.js", "admin.html", "admin.js"} + + +async def serve_file(name): + path = os.path.normpath(os.path.join(WEB_DIR, name)) + if not path.startswith(WEB_DIR) or not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + ext = os.path.splitext(path)[1] + resp = web.Response( + body=body, + content_type=MIME.get(ext, "application/octet-stream"), + charset="utf-8" if MIME.get(ext, "").startswith("text/") or + MIME.get(ext, "").endswith(("javascript", "xml", "json")) else None, + ) + if os.path.basename(path) in NOINDEX_FILES: + resp.headers["X-Robots-Tag"] = "noindex, nofollow" + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + else: + # marketing pages / assets: allow indexing + sane caching + resp.headers["Cache-Control"] = "public, max-age=600" + return resp + + +async def http_index(req): + return await serve_file("index.html") + + +async def http_app(req): + return await serve_file("app.html") + + +async def http_health(req): + return web.json_response({"ok": True, "version": VERSION, "uptime": int(time.time() - STATS.started)}) + + +async def http_status(req): + if ARGS.status_key: + tok = req.query.get("token", "") + import hmac + if not hmac.compare_digest(tok, ARGS.status_key): + return web.json_response({"error": "bad token"}, status=403) + hosts = [ + { + "id": h.id, + "name": h.name, + "version": h.ver, + "connected_for": int(time.time() - h.connected_at), + "in_session": h.session is not None, + } + for h in HOSTS.values() + ] + return web.json_response( + { + "version": VERSION, + "uptime": int(time.time() - STATS.started), + "hosts_online": len(HOSTS), + "hosts": hosts, + "sessions_total": STATS.sessions_total, + "bytes_relayed": STATS.bytes_relayed, + } + ) + + +# ---------------------------------------------------------------- portal + +async def http_robots(req): + base = public_base(req) + txt = ("User-agent: *\n" + "Allow: /\n" + "Disallow: /app\n" + "Disallow: /portal\n" + "Disallow: /api/\n" + "Disallow: /ws\n" + "Disallow: /dl/\n" + f"Sitemap: {base}/sitemap.xml\n") + return web.Response(text=txt, content_type="text/plain") + + +async def http_sitemap(req): + return await serve_file("sitemap.xml") + + +async def portal_page(req): + if not ARGS.portal_password: + return web.Response(status=404, text="Portal disabled. Start the server with --portal-password.") + return await serve_file("portal.html") + + +async def portal_login(req): + if not ARGS.portal_password: + return web.json_response({"error": "disabled"}, status=404) + ip = req_ip(req) + locked = login_locked("portal", ip) + if locked: + return web.json_response( + {"error": f"too many attempts — try again in {int(locked) + 1}s"}, + status=429) + form = await req.post() + pw = str(form.get("password", "")) + if not _secrets.compare_digest(pw, ARGS.portal_password): + login_fail("portal", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong password"}, status=401) + if ARGS.portal_totp and not _totp_verify(ARGS.portal_totp, str(form.get("totp", "")).strip()): + login_fail("portal", ip) + await asyncio.sleep(0.4) + return web.json_response({"error": "wrong or missing 2FA code"}, status=401) + login_ok("portal", ip) + tok = _secrets.token_urlsafe(32) + PORTAL_SESSIONS.add(tok) + log_event("portal", "portal login") + resp = web.Response(status=303, headers={"Location": "/portal"}) + resp.set_cookie("ndportal", tok, httponly=True, samesite="Lax", max_age=7 * 24 * 3600) + return resp + + +async def portal_logout(req): + tok = req.cookies.get("ndportal", "") + PORTAL_SESSIONS.discard(tok) + resp = web.Response(status=303, headers={"Location": "/portal"}) + resp.del_cookie("ndportal") + return resp + + +async def portal_overview(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + now = time.time() + known = [] + for hid, k in sorted(KNOWN_HOSTS.items()): + entry = dict(k) + h = HOSTS.get(hid) + entry["online"] = h is not None and not h.ws.closed + entry["in_session"] = bool(h and h.session is not None) + entry["hidden"] = is_hidden(hid) + entry["telemetry"] = (h.telemetry if h else {}) or {} + known.append(entry) + files = [] + if os.path.isdir(FILES_DIR): + for n in os.listdir(FILES_DIR): + p = os.path.join(FILES_DIR, n) + if os.path.isfile(p): + files.append({"name": n, "size": os.path.getsize(p), "mtime": int(os.path.getmtime(p))}) + files.sort(key=lambda f: f["name"]) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + for k in known: + h = HOSTS.get(k["id"]) + k["plan"] = h.session.plan if (h and h.session) else "" + # Live visitors: everyone currently connected to one of the hosts. + visitors = [] + for hid2, h2 in HOSTS.items(): + if h2.session is not None: + s = h2.session + visitors.append({ + "ip": getattr(s, "vip", ""), "geo": getattr(s, "vgeo", {}) or {}, + "host": hid2, "host_name": h2.name, "plan": s.plan, + "email": s.vemail or "", "since": int(s.started), "active": bool(s.active), + }) + return web.json_response({ + "version": VERSION, + "visitors": visitors, + "visitors_recent": list(RECENT_VISITORS)[:50], + "owner": bool(ARGS.owner_email and user_from_cookie(req) and + user_from_cookie(req).lower() == ARGS.owner_email.lower()), + "users": users, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started), + "hosts_online": sum(1 for k in known if k["online"]), + "hosts": known, + "sessions_total": STATS.sessions_total, + "bytes_relayed": STATS.bytes_relayed, + "events": list(EVENTS)[:60], + "files": files, + "portal": True, + }) + + +async def public_download(req): + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + resp = web.Response(body=body, content_type="application/octet-stream") + resp.headers["Content-Disposition"] = f'attachment; filename="{name}"' + return resp + + +async def tunnel_ws(req): + """Viewer-side endpoint: wss://server/tunnel/ + Client sends {"t":"open","port":N}; then raw binary both ways is the TCP + stream. The server asks the host to open the TCP connection and pipes.""" + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = req.match_info.get("hid", "") + h = HOSTS.get(hid) + if h is None or h.ws.closed: + return web.json_response({"error": "host offline"}, status=404) + ws = web.WebSocketResponse(max_msg_size=64 * 1024 * 1024) + await ws.prepare(req) + my_tids = [] + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") != "open": + continue + tid = TUNNEL_TID[0] + TUNNEL_TID[0] += 1 + TUNNELS[tid] = ws + my_tids.append(tid) + await h.ws.send_json({"t": "tunnel.open", "tid": tid, + "thost": m.get("host", "127.0.0.1"), + "port": int(m.get("port", 0))}) + elif msg.type == WSMsgType.BINARY: + if my_tids: + await h.ws.send_bytes(b"\xf3" + my_tids[0].to_bytes(4, "little") + msg.data) + finally: + for tid in my_tids: + TUNNELS.pop(tid, None) + try: + if h and not h.ws.closed: + await h.ws.send_json({"t": "tunnel.close", "tid": tid}) + except Exception: + pass + return ws + + +async def portal_users(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + return web.json_response({"users": users}) + + +async def portal_user_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + email = str(body.get("email", "")).lower() + if ARGS.owner_email and email == ARGS.owner_email.lower(): + return web.json_response({"error": "cannot delete the owner"}, status=400) + with _acct_db() as conn: + conn.execute("DELETE FROM users WHERE email=?", (email,)) + conn.execute("DELETE FROM tokens WHERE email=?", (email,)) + conn.execute("DELETE FROM devices WHERE owner=?", (email,)) + for tok, em in list(USER_SESSIONS.items()): + if em == email: + USER_SESSIONS.pop(tok, None) + log_event("admin", f"user deleted: {email}") + return web.json_response({"ok": True}) + + +async def portal_host_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None: + return web.json_response({"error": "host offline (removed from registry)"}, status=404) + try: + await h.ws.close() + except Exception: + pass + log_event("admin", f"host {hid} removed by admin") + return web.json_response({"ok": True, "msg": "host removed; it must re-register"}) + + +async def portal_host_hide(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + hid = str(body.get("id", "")).strip() + hidden = bool(body.get("hidden", True)) + if not hid: + return web.json_response({"error": "no host id"}, status=400) + set_hidden(hid, hidden) + log_event("admin", f"host {hid} {'hidden' if hidden else 'unhidden'}") + return web.json_response({"ok": True, "hidden": hidden}) + + +async def portal_session_disconnect(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None or not h.session: + return web.json_response({"error": "no active session"}, status=404) + await relay_session_end(h.session, "disconnected by admin", None) + h.session = None + log_event("admin", f"session on {hid} disconnected by admin") + return web.json_response({"ok": True}) + + +async def portal_download(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + return web.Response( + body=body, + headers={"Content-Disposition": f'attachment; filename="{name}"'}, + content_type="application/octet-stream", + ) + + +# ===================== SCIM 2.0 provisioning (RFC 7643/7644) ===================== +# An IdP (Okta/Azure AD/OneLogin) auto-creates/updates/deactivates NetDesk +# accounts (Users) and teams (Groups) with a Bearer provisioning token. +SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERR = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + +def _scim_json(data, status=200, location=None): + headers = {"Location": location} if location else None + return web.json_response(data, status=status, headers=headers, + content_type="application/scim+json") + + +def _scim_err(status, detail, scim_type=None): + body = {"schemas": [SCIM_ERR], "detail": detail, "status": str(status)} + if scim_type: + body["scimType"] = scim_type + return _scim_json(body, status=status) + + +def _scim_auth(req): + """Validate the Bearer provisioning token -> {token, admin} or None.""" + auth = req.headers.get("Authorization", "") + if not auth.lower().startswith("bearer "): + return None + tok = auth[7:].strip() + if not tok: + return None + with _acct_db() as conn: + row = conn.execute("SELECT token, created_by FROM scim_tokens WHERE token=?", (tok,)).fetchone() + if not row: + return None + conn.execute("UPDATE scim_tokens SET last_used=? WHERE token=?", (int(time.time()), tok)) + conn.commit() + return {"token": row[0], "admin": row[1] or ""} + + +def _iso(ts): + import datetime + return datetime.datetime.utcfromtimestamp(int(ts or 0)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _scim_user_row(conn, uid=None, email=None): + if uid is not None: + return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE id=?", (uid,)).fetchone() + return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", (email,)).fetchone() + + +def _scim_user_json(row, req): + uid, email, verified, created, disabled, ext = row + base = public_base(req) + d = {"schemas": [SCIM_USER], "id": str(uid), "userName": email, + "name": {"formatted": email.split("@")[0]}, + "emails": [{"value": email, "primary": True, "type": "work"}], + "active": not bool(disabled), + "meta": {"resourceType": "User", "location": f"{base}/scim/v2/Users/{uid}", + "created": _iso(created), "lastModified": _iso(created)}} + if ext: + d["externalId"] = ext + return d + + +def _scim_group_json(conn, team_row, req): + tid, name = team_row[0], team_row[1] + base = public_base(req) + members = [] + for em, in conn.execute("SELECT email FROM team_members WHERE team_id=? ORDER BY email", (tid,)).fetchall(): + ur = conn.execute("SELECT id FROM users WHERE lower(email)=lower(?)", (em,)).fetchone() + members.append({"value": str(ur[0]) if ur else em, "display": em}) + return {"schemas": [SCIM_GROUP], "id": str(tid), "displayName": name, + "members": members, + "meta": {"resourceType": "Group", "location": f"{base}/scim/v2/Groups/{tid}"}} + + +async def scim_spc(req): + """ServiceProviderConfig: advertise PATCH + filter support.""" + base = public_base(req) + return _scim_json({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "documentationUri": base + "/docs", + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": True, "maxResults": 200}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [{"type": "oauthbearertoken", "name": "OAuth Bearer Token", + "description": "Provisioning token as an HTTP Authorization: Bearer header"}], + "meta": {"resourceType": "ServiceProviderConfig", "location": base + "/scim/v2/ServiceProviderConfig"}, + }) + + +def _filter_username(req): + """Parse a SCIM `userName eq "x"` (or displayName eq) filter -> value or None.""" + import re + f = req.query.get("filter", "") + m = re.search(r'(?:userName|displayName)\s+eq\s+"([^"]+)"', f, re.I) + return m.group(1) if m else None + + +async def scim_users_list(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uname = _filter_username(req) + with _acct_db() as conn: + if uname: + rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", + (uname,)).fetchall() + else: + rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," + "COALESCE(scim_ext,'') FROM users ORDER BY id").fetchall() + res = [_scim_user_json(r, req) for r in rows] + return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), + "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) + + +async def scim_user_create(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + body = await _json_body(req) + email = str(body.get("userName", "")).strip().lower() + if "@" not in email: + # some IdPs put the address only in emails[] + for e in (body.get("emails") or []): + if isinstance(e, dict) and e.get("value"): + email = str(e["value"]).strip().lower(); break + if "@" not in email or "." not in email.split("@")[-1]: + return _scim_err(400, "userName must be an email address", "invalidValue") + active = body.get("active", True) + ext = str(body.get("externalId", "")).strip() + now = int(time.time()) + with _acct_db() as conn: + if _scim_user_row(conn, email=email): + return _scim_err(409, "user already exists", "uniqueness") + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) # IdP-managed account; local login unused until reset + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,disabled,scim_ext,role) " + "VALUES(?,?,?,1,?,?,?,'member')", + (email, salt.hex(), _pbkdf(pw, salt), now, 0 if active else 1, ext)) + conn.commit() + row = _scim_user_row(conn, email=email) + log_event("scim", f"provisioned user {email}" + ("" if active else " (inactive)")) + j = _scim_user_json(row, req) + return _scim_json(j, status=201, location=j["meta"]["location"]) + + +async def scim_user_get(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + return _scim_json(_scim_user_json(row, req)) + + +def _apply_user_active(conn, uid, active): + conn.execute("UPDATE users SET disabled=? WHERE id=?", (0 if active else 1, uid)) + if not active: # deactivation revokes live sessions + em = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() + if em: + conn.execute("DELETE FROM tokens WHERE email=?", (em[0],)) + + +async def scim_user_put(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + active = body.get("active", True) + _apply_user_active(conn, uid, active) + ext = body.get("externalId") + if ext is not None: + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(ext), uid)) + conn.commit() + row = _scim_user_row(conn, uid=uid) + log_event("scim", f"updated user #{uid} (active={bool(body.get('active', True))})") + return _scim_json(_scim_user_json(row, req)) + + +async def scim_user_patch(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + with _acct_db() as conn: + row = _scim_user_row(conn, uid=uid) + if not row: + return _scim_err(404, "user not found") + for op in (body.get("Operations") or []): + if not isinstance(op, dict): + continue + path = str(op.get("path", "")).strip().lower() + val = op.get("value") + # active may arrive as {"active": false} (no path) or path="active" + if path == "active": + _apply_user_active(conn, uid, _scim_truthy(val)) + elif path == "externalid": + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val or ""), uid)) + elif path in ("", None) and isinstance(val, dict): + if "active" in val: + _apply_user_active(conn, uid, _scim_truthy(val.get("active"))) + if "externalId" in val: + conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val.get("externalId") or ""), uid)) + conn.commit() + row = _scim_user_row(conn, uid=uid) + return _scim_json(_scim_user_json(row, req)) + + +def _scim_truthy(v): + if isinstance(v, bool): + return v + return str(v).strip().lower() in ("true", "1", "yes") + + +async def scim_user_delete(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + uid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() + if not row: + return _scim_err(404, "user not found") + em = row[0] + conn.execute("DELETE FROM users WHERE id=?", (uid,)) + conn.execute("DELETE FROM tokens WHERE email=?", (em,)) + conn.execute("DELETE FROM team_members WHERE lower(email)=lower(?)", (em,)) + conn.execute("DELETE FROM api_keys WHERE lower(owner)=lower(?)", (em,)) + conn.commit() + log_event("scim", f"deprovisioned user {em} (#{uid})") + return web.Response(status=204) + + +# ---- SCIM Groups (mapped to teams) ---- +async def scim_groups_list(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + dname = _filter_username(req) + with _acct_db() as conn: + if dname: + trows = conn.execute("SELECT id,name FROM teams WHERE name=? ORDER BY id", (dname,)).fetchall() + else: + trows = conn.execute("SELECT id,name FROM teams ORDER BY id").fetchall() + res = [_scim_group_json(conn, t, req) for t in trows] + return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), + "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) + + +def _scim_member_emails(conn, value): + """Resolve a SCIM members value list -> [emails].""" + out = [] + for mv in (value or []): + if not isinstance(mv, dict): + continue + v = str(mv.get("value", "")).strip() + if not v: + continue + ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None + out.append(ur[0] if ur else v) + return out + + +async def scim_group_create(req): + sa = _scim_auth(req) + if not sa: + return _scim_err(401, "invalid or missing provisioning token") + body = await _json_body(req) + name = str(body.get("displayName", "")).strip() + if not name: + return _scim_err(400, "displayName required", "invalidValue") + now = int(time.time()) + with _acct_db() as conn: + cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", + (name, sa["admin"] or "scim", now)) + tid = cur.lastrowid + if sa["admin"]: + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, sa["admin"], "owner", now)) + for em in _scim_member_emails(conn, body.get("members")): + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + j = _scim_group_json(conn, row, req) + log_event("scim", f"provisioned group '{name}' (#{tid})") + return _scim_json(j, status=201, location=j["meta"]["location"]) + + +async def scim_group_get(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_put(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + name = str(body.get("displayName", "")).strip() + if name: + conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) + if "members" in body: # PUT replaces the member set (owners preserved) + conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) + for em in _scim_member_emails(conn, body.get("members")): + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_patch(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + import re + tid = _rule_int(req.match_info.get("id")) + body = await _json_body(req) + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + if not row: + return _scim_err(404, "group not found") + for op in (body.get("Operations") or []): + if not isinstance(op, dict): + continue + action = str(op.get("op", "")).strip().lower() + path = str(op.get("path", "")).strip() + val = op.get("value") + if path.lower() == "displayname" or (not path and isinstance(val, dict) and "displayName" in val): + nm = val if path else val.get("displayName") + if isinstance(nm, list): + nm = nm[0].get("value") if nm and isinstance(nm[0], dict) else None + if nm: + conn.execute("UPDATE teams SET name=? WHERE id=?", (str(nm), tid)) + elif path.lower().startswith("members") or (not path and isinstance(val, dict) and "members" in val): + mfilter = re.search(r'members\[\s*value\s+eq\s+"([^"]+)"\s*\]', path, re.I) + if action == "remove" and mfilter: + v = mfilter.group(1) + ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None + em = ur[0] if ur else v + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) + else: + members = val.get("members") if (not path and isinstance(val, dict)) else val + emails = _scim_member_emails(conn, members) + if action == "add" or action == "replace": + if action == "replace": + conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) + for em in emails: + conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", + (tid, em, "member", now)) + elif action == "remove": + for em in emails: + conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) + conn.commit() + row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() + return _scim_json(_scim_group_json(conn, row, req)) + + +async def scim_group_delete(req): + if not _scim_auth(req): + return _scim_err(401, "invalid or missing provisioning token") + tid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + if not conn.execute("SELECT 1 FROM teams WHERE id=?", (tid,)).fetchone(): + return _scim_err(404, "group not found") + conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) + conn.execute("DELETE FROM teams WHERE id=?", (tid,)) + conn.commit() + log_event("scim", f"deprovisioned group #{tid}") + return web.Response(status=204) + + +# ---- SCIM provisioning-token management (admin session) ---- +async def api_scim_tokens_list(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + rows = conn.execute("SELECT token,label,created,last_used,created_by FROM scim_tokens " + "ORDER BY created DESC").fetchall() + out = [{"label": lb, "created": c, "last_used": lu, "created_by": cb, + "token_preview": tk[:10] + "…" + tk[-4:]} for (tk, lb, c, lu, cb) in rows] + return web.json_response({"tokens": out, "base": public_base(req) + "/scim/v2"}) + + +async def api_scim_token_create(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + body = await _json_body(req) + label = str(body.get("label", "")).strip()[:64] or "SCIM" + token = "scim_" + _secrets.token_urlsafe(32) + with _acct_db() as conn: + conn.execute("INSERT INTO scim_tokens(token,label,created_by,created,last_used) VALUES(?,?,?,?,0)", + (token, label, email, int(time.time()))) + conn.commit() + log_event("scim", f"{email} minted SCIM token '{label}'", email) + return web.json_response({"ok": True, "token": token, "label": label, + "base": public_base(req) + "/scim/v2"}) + + +async def api_scim_token_revoke(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + with _acct_db() as conn: + if _user_role(conn, email) != "admin": + return web.json_response({"error": "admin only"}, status=403) + body = await _json_body(req) + k = str(body.get("token", "")).strip() + with _acct_db() as conn: + if k.startswith("scim_") and "…" not in k: + cur = conn.execute("DELETE FROM scim_tokens WHERE token=?", (k,)) + else: + cur = conn.execute("DELETE FROM scim_tokens WHERE token LIKE ?", (k.split("…")[0] + "%",)) + conn.commit() + return web.json_response({"ok": True, "revoked": cur.rowcount}) + + +# ===================== central session recording (server half) ===================== +# Policy flag record_required (pushed to hosts) tells the host to record the +# session and upload it here. The host authenticates the upload with a short- +# lived token minted at session.begin. Org-controlled storage + retention + +# admin/owner list/download/delete. +REC_DIR = os.path.join(_server_root, "recordings") +_REC_CAP = 2 * 1024 * 1024 * 1024 # 2 GB per recording + + +def _rec_retention_days(): + try: + return int(getattr(ARGS, "recording_retention_days", 0) or 0) + except Exception: + return 0 + + +def _rec_mint(host_id, owner, session_id, ttl=6 * 3600): + token = "rec_" + _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + conn.execute("INSERT INTO rec_tokens(token,host_id,owner,session_id,created,exp) " + "VALUES(?,?,?,?,?,?)", (token, host_id, owner, session_id, now, now + ttl)) + conn.commit() + return token + + +def _rec_purge_expired(): + """Drop stale upload tokens and, when retention is set, old recordings.""" + now = int(time.time()) + days = _rec_retention_days() + try: + with _acct_db() as conn: + conn.execute("DELETE FROM rec_tokens WHERE exp>0 AND exp < ?", (now,)) + if days > 0: + cutoff = now - days * 86400 + for rid, path in conn.execute("SELECT id,path FROM recordings WHERE created < ?", + (cutoff,)).fetchall(): + try: + if path: + os.remove(path) + except OSError: + pass + conn.execute("DELETE FROM recordings WHERE created < ?", (cutoff,)) + conn.commit() + except Exception as e: + log.warning("recording purge: %s", e) + + +async def api_rec_upload(req): + """Host uploads a finished session recording (token-authed, streamed to disk).""" + token = req.query.get("token", "") or req.headers.get("X-Rec-Token", "") + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT host_id,owner,session_id,exp FROM rec_tokens WHERE token=?", + (token,)).fetchone() + if not row or (row[3] and row[3] < now): + return web.json_response({"error": "invalid or expired recording token"}, status=401) + host_id, owner, session_id, _exp = row + ext = "".join(c for c in (req.query.get("ext", "webm") or "webm") if c.isalnum())[:8] or "webm" + os.makedirs(REC_DIR, exist_ok=True) + name = f"rec-{host_id}-{_secrets.token_hex(6)}.{ext}" + path = os.path.join(REC_DIR, name) + tmp = path + ".part" + size = 0 + try: + with open(tmp, "wb") as f: + async for chunk in req.content.iter_chunked(256 * 1024): + size += len(chunk) + if size > _REC_CAP: + f.close(); os.remove(tmp) + return web.json_response({"error": "recording too large"}, status=413) + f.write(chunk) + except Exception as e: + try: os.remove(tmp) + except OSError: pass + return web.json_response({"error": "upload failed: %s" % e}, status=500) + if size == 0: + try: os.remove(tmp) + except OSError: pass + return web.json_response({"error": "empty upload"}, status=400) + os.replace(tmp, path) + duration = 0 + try: + duration = int(req.query.get("duration", 0) or 0) + except ValueError: + duration = 0 + with _acct_db() as conn: + cur = conn.execute("INSERT INTO recordings(owner,host_id,session_id,name,path,size,duration," + "viewer,mime,created) VALUES(?,?,?,?,?,?,?,?,?,?)", + (owner, host_id, session_id, name, path, size, duration, + (req.query.get("viewer", "") or "")[:120], "video/" + ext, now)) + rid = cur.lastrowid + # single-use token + conn.execute("DELETE FROM rec_tokens WHERE token=?", (token,)) + conn.commit() + log_event("recording", f"stored recording #{rid} for host {host_id} " + f"({size} bytes)", owner or "") + _rec_purge_expired() + return web.json_response({"ok": True, "id": rid, "size": size}) + + +async def api_recordings(req): + """List recordings for the signed-in account (admin sees all).""" + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + _rec_purge_expired() + with _acct_db() as conn: + if _user_role(conn, email) == "admin": + rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " + "FROM recordings ORDER BY created DESC").fetchall() + else: + rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " + "FROM recordings WHERE lower(owner)=lower(?) ORDER BY created DESC", + (email,)).fetchall() + out = [{"id": r[0], "owner": r[1], "host_id": r[2], "session_id": r[3], "name": r[4], + "size": r[5], "duration": r[6], "viewer": r[7], "created": r[8]} for r in rows] + return web.json_response({"recordings": out, "retention_days": _rec_retention_days()}) + + +def _rec_authz_row(conn, rid, email): + row = conn.execute("SELECT owner,path,name,mime FROM recordings WHERE id=?", (rid,)).fetchone() + if not row: + return None, "not found" + if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": + return None, "forbidden" + return row, None + + +async def api_rec_download(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + rid = _rule_int(req.match_info.get("id")) + with _acct_db() as conn: + row, err = _rec_authz_row(conn, rid, email) + if err == "not found": + return web.json_response({"error": "recording not found"}, status=404) + if err == "forbidden": + return web.json_response({"error": "not your recording"}, status=403) + owner, path, name, mime = row + if not path or not os.path.exists(path): + return web.json_response({"error": "recording file missing"}, status=404) + return web.FileResponse(path, headers={ + "Content-Disposition": f'attachment; filename="{name}"', + "Content-Type": mime or "application/octet-stream"}) + + +async def api_rec_delete(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + body = await _json_body(req) + rid = _rule_int(body.get("id")) + if rid is None: + return web.json_response({"error": "id required"}, status=400) + with _acct_db() as conn: + row, err = _rec_authz_row(conn, rid, email) + if err == "not found": + return web.json_response({"error": "recording not found"}, status=404) + if err == "forbidden": + return web.json_response({"error": "not your recording"}, status=403) + path = row[1] + conn.execute("DELETE FROM recordings WHERE id=?", (rid,)) + conn.commit() + try: + if path: + os.remove(path) + except OSError: + pass + log_event("recording", f"{email} deleted recording #{rid}", email) + return web.json_response({"ok": True}) + + +# ===================== SAML 2.0 SSO (Service Provider) ===================== +# A secure, spec-compliant SP: SP-initiated AuthnRequest (HTTP-Redirect), +# ServiceProviderConfig metadata, and an ACS that validates the IdP's XML-DSig +# signature (exclusive-C14N via lxml + RSA verify via cryptography), defends +# against signature-wrapping, and checks conditions/audience/issuer/recipient. +_SAML_NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + "saml": "urn:oasis:names:tc:SAML:2.0:assertion", + "ds": "http://www.w3.org/2000/09/xmldsig#", + "ec": "http://www.w3.org/2001/10/xml-exc-c14n#"} + + +class SamlError(Exception): + pass + + +def _saml_enabled(): + return bool(getattr(ARGS, "saml_idp_sso_url", "") and getattr(ARGS, "saml_idp_cert", "")) + + +def _saml_acs_url(req): + return public_base(req) + "/saml/acs" + + +def _saml_sp_entity_id(req): + return getattr(ARGS, "saml_sp_entity_id", "") or (public_base(req) + "/saml/metadata") + + +def _saml_idp_cert_pem(): + v = getattr(ARGS, "saml_idp_cert", "") or "" + try: + if os.path.exists(v): + with open(v, "rb") as f: + return f.read() + except Exception: + pass + if "BEGIN CERTIFICATE" not in v: + v = "-----BEGIN CERTIFICATE-----\n" + v + "\n-----END CERTIFICATE-----\n" + return v.encode() + + +def _xmlesc(s): + return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def _saml_parsedt(s): + import datetime + s = (s or "").strip().replace("Z", "") + if "." in s: + s = s.split(".")[0] + return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + + +def _saml_by_id(root, el_id): + """Find the single element with ID==el_id; reject duplicates (XSW defence).""" + found = [e for e in root.iter() if e.get("ID") == el_id] + if len(found) != 1: + raise SamlError("ambiguous or missing ID reference") + return found[0] + + +def _saml_validate(xml_bytes, acs_url, idp_cert_pem, sp_entity_id, idp_entity_id=None, skew=180): + from lxml import etree + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives import hashes + import base64, hashlib, hmac, copy, datetime + NS = _SAML_NS + + parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False) + try: + root = etree.fromstring(xml_bytes, parser) + except Exception as e: + raise SamlError("malformed SAML XML: %s" % e) + if root.getroottree().docinfo.doctype: + raise SamlError("DOCTYPE is not allowed") + + # Response-level Status must be Success (when this is a Response) + if root.tag.endswith("}Response"): + sc = root.find("./samlp:Status/samlp:StatusCode", NS) + if sc is not None and not (sc.get("Value", "").endswith(":status:Success")): + raise SamlError("IdP returned a non-Success status") + + cert = x509.load_pem_x509_certificate(idp_cert_pem) + pub = cert.public_key() + + sigs = root.findall(".//ds:Signature", NS) + if not sigs: + raise SamlError("response is not signed") + + verified_ids = set() + for sig in sigs: + si = sig.find("./ds:SignedInfo", NS) + if si is None: + continue + cm = si.find("./ds:CanonicalizationMethod", NS) + si_excl = "exc-c14n" in (cm.get("Algorithm", "") if cm is not None else "") + si_c14n = etree.tostring(si, method="c14n", exclusive=si_excl, with_comments=False) + sv_el = sig.find("./ds:SignatureValue", NS) + sig_value = base64.b64decode((sv_el.text or "").strip()) + sm = si.find("./ds:SignatureMethod", NS) + smeth = sm.get("Algorithm", "") if sm is not None else "" + if "sha256" in smeth: + halg = hashes.SHA256() + elif "sha1" in smeth: + halg = hashes.SHA1() + else: + raise SamlError("unsupported signature algorithm") + try: + pub.verify(sig_value, si_c14n, padding.PKCS1v15(), halg) + except Exception: + raise SamlError("signature verification failed (untrusted or tampered)") + + for ref in si.findall("./ds:Reference", NS): + uri = ref.get("URI", "") + if not uri.startswith("#"): + raise SamlError("only same-document references are allowed") + target = _saml_by_id(root, uri[1:]) + node = copy.deepcopy(target) + for s in node.findall(".//ds:Signature", NS): + s.getparent().remove(s) + ref_excl = True + incl = None + for tr in ref.findall("./ds:Transforms/ds:Transform", NS): + alg = tr.get("Algorithm", "") + if "exc-c14n" in alg: + ref_excl = True + elif "xml-c14n" in alg: + ref_excl = False + inc_el = tr.find("./ec:InclusiveNamespaces", NS) + if inc_el is not None and inc_el.get("PrefixList"): + incl = inc_el.get("PrefixList").split() + node_c14n = etree.tostring(node, method="c14n", exclusive=ref_excl, + with_comments=False, inclusive_ns_prefixes=incl) + dm = ref.find("./ds:DigestMethod", NS) + dmeth = dm.get("Algorithm", "") if dm is not None else "" + dv = base64.b64decode((ref.find("./ds:DigestValue", NS).text or "").strip()) + if "sha256" in dmeth: + calc = hashlib.sha256(node_c14n).digest() + elif "sha1" in dmeth: + calc = hashlib.sha1(node_c14n).digest() + else: + raise SamlError("unsupported digest algorithm") + if not hmac.compare_digest(calc, dv): + raise SamlError("digest mismatch (assertion was tampered)") + verified_ids.add(target.get("ID")) + + # Use ONLY a signed assertion (its ID was covered by a verified signature). + assertion = None + for a in root.findall(".//saml:Assertion", NS): + if a.get("ID") in verified_ids: + assertion = a + break + if assertion is None and root.tag.endswith("}Response") and root.get("ID") in verified_ids: + assertion = root.find("./saml:Assertion", NS) + if assertion is None: + raise SamlError("no signed assertion present") + + now = datetime.datetime.utcnow() + cond = assertion.find("./saml:Conditions", NS) + if cond is not None: + nb, na = cond.get("NotBefore"), cond.get("NotOnOrAfter") + if nb and _saml_parsedt(nb) - datetime.timedelta(seconds=skew) > now: + raise SamlError("assertion is not yet valid") + if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: + raise SamlError("assertion has expired") + auds = [(e.text or "").strip() for e in cond.findall("./saml:AudienceRestriction/saml:Audience", NS)] + if auds and sp_entity_id not in auds: + raise SamlError("audience restriction does not match this SP") + + iss = assertion.find("./saml:Issuer", NS) + if idp_entity_id and iss is not None and (iss.text or "").strip() != idp_entity_id: + raise SamlError("assertion issuer does not match the configured IdP") + + scd = assertion.find("./saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", NS) + if scd is not None: + na = scd.get("NotOnOrAfter") + if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: + raise SamlError("subject confirmation has expired") + rcpt = scd.get("Recipient") + if rcpt and acs_url and rcpt.rstrip("/") != acs_url.rstrip("/"): + raise SamlError("subject confirmation recipient mismatch") + + nameid = assertion.find("./saml:Subject/saml:NameID", NS) + email = ((nameid.text or "").strip().lower()) if nameid is not None else "" + if "@" not in email: + email_attrs = ("email", "mail", "urn:oid:0.9.2342.19200300.100.1.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") + for at in assertion.findall(".//saml:Attribute", NS): + if at.get("Name", "").lower() in email_attrs: + v = at.find("./saml:AttributeValue", NS) + if v is not None and "@" in (v.text or ""): + email = v.text.strip().lower() + break + if "@" not in email: + raise SamlError("assertion carries no email NameID/attribute") + return email + + +def _saml_provision(email): + now = int(time.time()) + with _acct_db() as conn: + row = conn.execute("SELECT COALESCE(disabled,0) FROM users WHERE lower(email)=lower(?)", + (email,)).fetchone() + if row is None: + salt = _secrets.token_bytes(16) + pw = _secrets.token_urlsafe(24) + role = getattr(ARGS, "saml_default_role", "member") or "member" + conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " + "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) + conn.commit() + log_event("auth", f"SAML provisioned account {email}") + return True + return not bool(row[0]) # True unless disabled + + +async def saml_metadata(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) + xml = ('' + '' + '' + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + '' + '') % (_xmlesc(sp), _xmlesc(acs)) + return web.Response(text=xml, content_type="application/samlmetadata+xml") + + +async def saml_login(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + import base64, zlib, urllib.parse, uuid, datetime + acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) + rid = "_" + uuid.uuid4().hex + inst = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + xml = ('' + '%s' + '') % ( + rid, inst, _xmlesc(ARGS.saml_idp_sso_url), _xmlesc(acs), _xmlesc(sp)) + co = zlib.compressobj(9, zlib.DEFLATED, -15) + deflated = co.compress(xml.encode()) + co.flush() + relay = req.query.get("RelayState", "/app") + q = urllib.parse.urlencode({"SAMLRequest": base64.b64encode(deflated).decode(), "RelayState": relay}) + sep = "&" if "?" in ARGS.saml_idp_sso_url else "?" + raise web.HTTPFound(ARGS.saml_idp_sso_url + sep + q) + + +async def saml_acs(req): + if not _saml_enabled(): + return web.Response(status=404, text="SAML is not configured on this server") + import base64 + form = await req.post() + b64 = form.get("SAMLResponse", "") + relay = form.get("RelayState", "") + if not b64: + return web.Response(status=400, text="missing SAMLResponse") + try: + xml = base64.b64decode(b64) + except Exception: + return web.Response(status=400, text="bad SAMLResponse encoding") + try: + email = _saml_validate(xml, _saml_acs_url(req), _saml_idp_cert_pem(), + _saml_sp_entity_id(req), + getattr(ARGS, "saml_idp_entity_id", "") or None) + except SamlError as e: + log_event("authfail", "SAML rejected: %s" % e) + return web.Response(status=403, text="SAML validation failed: %s" % e) + except Exception as e: + log_event("authfail", "SAML error: %s" % e) + return web.Response(status=400, text="SAML processing error") + active = _saml_provision(email) + if not active: + return web.Response(status=403, text="this account has been deactivated") + tok = _new_token(email) + log_event("auth", "signed in via SAML: " + email) + dest = relay if (relay and relay.startswith("/")) else "/app" + resp = web.HTTPFound(dest) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) + return resp + + +async def api_auth_sso(req): + """Tell the login UI whether SSO is available (so it can show a button).""" + return web.json_response({"saml": _saml_enabled(), "login_url": "/saml/login", + "enforce": bool(getattr(ARGS, "saml_enforce", False))}) + + +# ---------------------------------------------------------------- WebSocket + +async def relay_session_end(sess, reason, source_ws): + """Tear down a session and notify both sides.""" + try: + if sess.host is not None and sess.host.ws is not source_ws and not sess.host.ws.closed: + await sess.host.ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) + except Exception: + pass + try: + if sess.viewer_ws is not source_ws and not sess.viewer_ws.closed: + await sess.viewer_ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) + except Exception: + pass + try: + fire_webhook(_device_owner(sess.host.id) if sess.host else None, "disconnect", + {"host_id": sess.host.id if sess.host else "", "reason": reason}) + except Exception: + pass + + +async def send_json(ws, obj): + try: + if not ws.closed: + await ws.send_str(json.dumps(obj)) + except Exception: + pass + + +async def ws_handler(req): + ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024, autoping=True) + await ws.prepare(req) + + # behind nginx req.remote is 127.0.0.1 - prefer the forwarded client IP + peer = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + or req.remote) + host: Host | None = None + viewer_sid: str | None = None + log.info("connection from %s", peer) + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if not isinstance(m, dict): + continue + t = m.get("t") + + # tunnel lifecycle JSON from a host: route to the tunnel socket + if t in ("tunnel.ok", "tunnel.err", "tunnel.closed") and TUNNELS: + tws = TUNNELS.get(m.get("tid", -1)) + if tws is not None and not tws.closed: + try: + await tws.send_str(msg.data) + except Exception: + pass + continue + + # ---- host registration -------------------------------- + if t == "reg.host": + hid = str(m.get("id", "")).strip() + if not hid or not hid.isdigit() or not (100000000 <= int(hid) <= 999999999): + await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad id"}) + await ws.close() + break + if ARGS.reg_key and m.get("key") != ARGS.reg_key: + await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad registration key"}) + await ws.close() + break + old = HOSTS.get(hid) + if old is not None and old.ws is not ws and not old.ws.closed: + # A live connection already holds this ID. + if old.session is not None: + # There's an active/pending session - NEVER displace it. + # This is what stops the "viewer drops 2-3s after + # connecting" flapping: a duplicate/stuck engine can + # never kill a live session. + log.info("host %s: rejecting registration from %s " + "(holder has an active session)", hid, peer) + await send_json(ws, {"t": "host_status", "ok": False, + "msg": "this ID is busy with a session"}) + await ws.close() + break + if not m.get("takeover"): + # Idle holder, plain duplicate: reject so two engines + # never ping-pong the ID. The holder keeps it. + log.info("host %s: rejecting duplicate registration from %s " + "(a live connection already holds this idle ID)", hid, peer) + await send_json(ws, {"t": "host_status", "ok": False, + "msg": "this ID is already connected elsewhere"}) + await ws.close() + break + # Idle holder + explicit takeover from a (fresh) engine: + # replace the old connection (lets a new/updated engine + # cleanly take over a stuck one without a session running). + log.info("host %s: takeover from %s replacing idle holder", hid, peer) + try: + await old.ws.close() + except Exception: + pass + elif old is not None and old.ws is not ws: + # holder's socket is already closed: clean it up and take over + if old.session: + await relay_session_end(old.session, "host reconnected", ws) + # seat / licensing admission (0 seats = unlimited; owner from + # the signed-in token, else the device's bound owner). + _tok = str(m.get("token") or "") + _ro = (email_for_token(_tok) if _tok else "") or device_owner(hid) or "" + if _ro and not _seat_ok(_ro, hid): + _sc = _owner_seats(_ro) + log_event("deny", f"host {hid} register: seat limit {_sc} reached " + f"for {_ro}") + await send_json(ws, {"t": "host_status", "ok": False, + "msg": f"seat limit reached ({_sc}) - free a " + f"device or add seats"}) + await ws.close() + break + host = Host(ws, hid, str(m.get("name", ""))[:64], str(m.get("ver", "?"))[:32]) + HOSTS[hid] = host + prev = KNOWN_HOSTS.get(hid) + KNOWN_HOSTS[hid] = { + "id": hid, "name": host.name, "ver": host.ver, + "first_seen": prev["first_seen"] if prev else int(time.time()), + "last_seen": int(time.time()), + } + # account binding: the desktop app signs in and sends its token + owner = None + if "token" in m: + owner = email_for_token(str(m.get("token") or "")) + try: + bind_device(hid, owner, host.name, host.ver, + str(m.get("mac") or "")[:32]) + except Exception as e: + log.warning("device bind failed: %s", e) + else: + touch_device(hid) + log_event("host", f"host {hid} ({host.name}) online") + log.info("host %s registered from %s (name=%s ver=%s owner=%s)", + hid, peer, host.name, host.ver, owner or "-") + _pol = _account_policy(owner) if owner else {} + await send_json(ws, {"t": "host_status", "ok": True, "msg": "registered", + "account": owner or "", "policy": _pol}) + + elif t == "session.begin" and host is not None and "sid" not in m: + # Host is never the initiator of session.begin without sid; + # this branch intentionally ignores it. + continue + + # ---- viewer: start a session -------------------------- + elif t == "session.begin": + vuser = user_from_cookie(req) + vplan, vlimit = plan_for(vuser) + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid) + if h is None or h.ws.closed: + log_event("deny", f"connect to {hid}: host offline") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) + continue + # Hidden host: invisible to everyone but its signed-in owner. + # Denied as "offline" so its existence isn't revealed. + if is_hidden(hid): + ow = device_owner(hid) + if not (vuser and ow and vuser.lower() == ow.lower()): + log_event("deny", f"connect to {hid}: host hidden") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) + continue + if h.session is not None: + # Same user hopping devices can explicitly TAKE OVER the + # existing session (TeamViewer-style) instead of being + # locked out. Without the flag we still reject. + if m.get("takeover"): + log.info("session takeover on host %s by %s", hid, peer) + await relay_session_end(h.session, "taken over from another device", None) + h.session = None + else: + log_event("deny", f"connect to {hid}: host busy") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host busy"}) + continue + # conditional-access rules (per host-owner account): first + # matching deny stops the connection, with its reason shown + # to the viewer. Default-allow when no rule matches. + _aowner, _agroup = _device_owner_group(hid) + _aok, _areason, _arid = _eval_access( + _aowner, vuser, hid, _agroup, peer, int(time.time())) + if not _aok: + log_event("deny", f"connect to {hid} by {vuser or peer}: " + f"{_areason} (rule #{_arid})", vuser or "") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": _areason}) + continue + sid = uuid.uuid4().hex + sess = Session(sid, h, ws) + if vlimit and host_is_owners(hid): + vlimit = None # owner's own PC: unlimited + sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser + sess.attribution = vuser or ("guest@" + peer) + sess.vip = peer or "" + h.session = sess + # Live-visitor intel: record + geo-locate the viewer's IP. + _vrec = {"ip": sess.vip, "host": hid, "host_name": h.name, + "plan": vplan, "email": vuser or "", "t": int(time.time()), "geo": {}} + RECENT_VISITORS.appendleft(_vrec) + + async def _vgeo(s=sess, r=_vrec, ip=sess.vip): + g = await geo_lookup(ip) + s.vgeo = g + r["geo"] = g + asyncio.ensure_future(_vgeo()) + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} ({vplan}) connecting to host {hid}") + log.info("session %s: viewer %s (%s) -> host %s", sid, peer, vplan, hid) + _sbmsg = {"t": "session.begin", "sid": sid, + "peer": peer or "", "user": vuser or ""} + try: + if _aowner and _account_policy(_aowner).get("record_required"): + _rtok = _rec_mint(hid, _aowner, sid) + _sbmsg["rec"] = {"token": _rtok, "url": "/api/recordings/upload"} + log_event("recording", f"session {sid} on {hid} is policy-recorded", + _aowner) + except Exception as _e: + log.warning("rec token mint: %s", _e) + await send_json(h.ws, _sbmsg) + + async def _auth_timeout(s=sess, hh=h): + # a viewer that never finishes auth must not block the host. + # 25s (was 120): a half-open slot frees fast so a dropped + # viewer's own reconnect isn't locked out for two minutes. + await asyncio.sleep(25) + if hh.session is s and not s.active: + hh.session = None + await relay_session_end(s, "authentication timed out", None) + asyncio.ensure_future(_auth_timeout()) + + # ---- host telemetry (CPU/RAM/disk/battery) -> portal ---- + elif t == "host.telemetry" and host is not None: + host.telemetry = { + "cpu": m.get("cpu"), "ram_used": m.get("ram_used"), + "ram_total": m.get("ram_total"), "disk_used": m.get("disk_used"), + "disk_total": m.get("disk_total"), "battery": m.get("battery"), + "charging": m.get("charging"), "uptime": m.get("uptime"), + "ts": int(time.time()), + } + + # ---- everything else: relay between the two peers ----- + else: + sid = m.get("sid") + sess = None + if host is not None: + sess = host.session if (host.session and host.session.sid == sid) else None + if sess is None and not sid and host.session and t == "session.deny": + sess = host.session # older hosts deny without a sid + if sess is None and viewer_sid and sid == viewer_sid: + # find via host registry (viewer side of the link) + for h in HOSTS.values(): + if h.session and h.session.sid == sid and h.session.viewer_ws is ws: + sess = h.session + break + if sess is None: + continue + + # viewer asks for ICE servers (STUN + nearest TURN) for the + # P2P fast path - answered by the server, never relayed + if t == "rtc.config.req" and host is None and sess.viewer_ws is ws: + await send_json(ws, {"t": "rtc.config", "sid": sess.sid, + "iceServers": await ice_servers()}) + continue + + if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}", + actor=sess.attribution) + fire_webhook(_device_owner(sess.host.id), "connect", + {"host_id": sess.host.id, "viewer": sess.vemail or ""}) + plan_msg = {"t": "session.plan", "plan": sess.plan, + "limit": sess.vlimit or 0, "email": sess.vemail or ""} + await send_json(sess.viewer_ws, plan_msg) + if sess.vlimit: + async def _limit_killer(s=sess): + await asyncio.sleep(s.vlimit) + if s.active: + log_event("session", f"time limit on host {s.host.id}") + await relay_session_end( + s, "session time limit reached - reconnect or sign up", + None) + if s.host: + s.host.session = None + asyncio.ensure_future(_limit_killer(sess)) + await send_json(sess.viewer_ws, m) + elif t == "session.deny": + reason = m.get("reason", "denied") + if reason in ("wrong password", "bad 2FA code"): + log_event("auth-fail", f"{reason} on host {sess.host.id}") + else: + log_event("deny", f"connect to {sess.host.id}: {reason}") + sess.host.session = None + await send_json(sess.viewer_ws, m) + elif t == "session.end": + await relay_session_end(sess, m.get("reason", "closed"), ws) + log_event("session", f"session on host {sess.host.id} ended ({m.get('reason', 'closed')})") + if sess.host: + sess.host.session = None + else: + dest = sess.other(ws) + if dest is not None: + await send_json(dest, m) + + elif msg.type == WSMsgType.BINARY: + # TCP-tunnel frames from a host: [0xF3][tid u32][payload] + if msg.data and msg.data[0] == 0xF3 and len(msg.data) >= 5: + tid = int.from_bytes(msg.data[1:5], "little") + tws = TUNNELS.get(tid) + if tws is not None and not tws.closed: + try: + await tws.send_bytes(msg.data[5:]) + except Exception: + pass + continue + # Opaque relay: find session for this socket, forward bytes. + sess = None + if host is not None and host.session and host.session.active: + sess = host.session + elif viewer_sid: + for h in HOSTS.values(): + if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws and h.session.active: + sess = h.session + break + if sess is not None: + dest = sess.other(ws) + if dest is not None: + STATS.bytes_relayed += len(msg.data) + try: + await dest.send_bytes(msg.data) + except Exception: + pass + + elif msg.type == WSMsgType.ERROR: + break + finally: + if host is not None and HOSTS.get(host.id) is host: + HOSTS.pop(host.id, None) + k = KNOWN_HOSTS.get(host.id) + if k: + k["last_seen"] = int(time.time()) + log_event("host", f"host {host.id} ({host.name}) offline") + if host.session: + await relay_session_end(host.session, "host disconnected", ws) + if viewer_sid: + for h in list(HOSTS.values()): + if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws: + await relay_session_end(h.session, "viewer disconnected", ws) + h.session = None + log.info("connection closed from %s", peer) + return ws + + +# ---------------------------------------------------------------- self-signed cert + +def gen_selfsigned(cert_path, key_path, domain): + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + import datetime, ipaddress + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain or "netdesk")]) + now = datetime.datetime.now(datetime.timezone.utc) + san = x509.SubjectAlternativeName( + [x509.DNSName(domain)] if domain + else [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(san, critical=False) + .sign(key, hashes.SHA256()) + ) + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + with open(key_path, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + fp = cert.fingerprint(hashes.SHA256()).hex() + print(f"[netdesk] self-signed cert written: {cert_path}") + print(f"[netdesk] SHA256 fingerprint: {fp}") + print("[netdesk] Browsers will warn until you accept it (or use Let's Encrypt in production).") + + +# ---------------------------------------------------------------- main + +ARGS = None + + +def build_app(): + app = web.Application() + # public infra (protected by reg-key / cookies / tokens) + app.router.add_get("/health", http_health) + app.router.add_get("/status", http_status) + app.router.add_get("/ws", ws_handler) + app.router.add_get("/robots.txt", http_robots) + app.router.add_post("/portal/login", portal_login) + app.router.add_get("/portal/logout", portal_logout) + app.router.add_get("/portal/api/overview", portal_overview) + app.router.add_get("/portal/api/audit", portal_audit) + app.router.add_get("/portal/api/audit.csv", portal_audit_csv) + app.router.add_get("/portal/api/users", portal_users) + app.router.add_post("/portal/api/user/delete", portal_user_delete) + app.router.add_post("/portal/api/host/delete", portal_host_delete) + app.router.add_post("/portal/api/host/hide", portal_host_hide) + app.router.add_post("/portal/api/session/disconnect", portal_session_disconnect) + app.router.add_get("/dl/{name}", portal_download) + app.router.add_get("/tunnel/{hid}", tunnel_ws) + app.router.add_get("/download/{name}", public_download) + # viewer email accounts + app.router.add_post("/api/auth/signup", auth_signup) + app.router.add_post("/api/auth/login", auth_login) + app.router.add_post("/api/auth/forgot", auth_forgot) + app.router.add_post("/api/auth/reset", auth_reset) + app.router.add_get("/api/auth/verify", auth_verify) + app.router.add_get("/api/auth/me", auth_me) + app.router.add_post("/api/auth/logout", auth_logout) + app.router.add_get("/api/auth/oidc/{provider}/start", auth_oidc_start) + app.router.add_get("/api/auth/oidc/{provider}/callback", auth_oidc_callback) + app.router.add_post("/api/keys", api_keys_create) + app.router.add_get("/api/keys", api_keys_list) + app.router.add_post("/api/keys/revoke", api_keys_revoke) + app.router.add_get("/api/v1/devices", v1_devices) + app.router.add_get("/api/v1/webhooks", v1_webhooks_list) + app.router.add_post("/api/v1/webhooks", v1_webhooks_add) + app.router.add_post("/api/v1/webhooks/remove", v1_webhooks_remove) + app.router.add_post("/api/v1/webhooks/test", v1_webhooks_test) + app.router.add_get("/api/devices", api_devices) + app.router.add_post("/api/devices/remove", api_device_remove) + app.router.add_post("/api/devices/meta", api_device_meta) + app.router.add_get("/api/teams", api_teams) + app.router.add_post("/api/teams", api_team_create) + app.router.add_post("/api/teams/rename", api_team_rename) + app.router.add_post("/api/teams/delete", api_team_delete) + app.router.add_get("/api/teams/members", api_team_members) + app.router.add_post("/api/teams/members", api_team_member_add) + app.router.add_post("/api/teams/members/remove", api_team_member_remove) + app.router.add_get("/api/teams/devices", api_team_devices) + app.router.add_post("/api/teams/devices", api_team_device_add) + app.router.add_post("/api/teams/devices/remove", api_team_device_remove) + app.router.add_get("/api/access/rules", api_access_rules) + app.router.add_post("/api/access/rules", api_access_rule_set) + app.router.add_post("/api/access/rules/delete", api_access_rule_delete) + app.router.add_get("/api/access/test", api_access_test) + app.router.add_post("/api/recordings/upload", api_rec_upload) + app.router.add_get("/api/recordings", api_recordings) + app.router.add_get("/api/recordings/{id}/download", api_rec_download) + app.router.add_post("/api/recordings/delete", api_rec_delete) + app.router.add_get("/saml/metadata", saml_metadata) + app.router.add_get("/saml/login", saml_login) + app.router.add_post("/saml/acs", saml_acs) + app.router.add_get("/api/auth/sso", api_auth_sso) + # SCIM 2.0 provisioning (Bearer-token authed) + app.router.add_get("/scim/v2/ServiceProviderConfig", scim_spc) + app.router.add_get("/scim/v2/Users", scim_users_list) + app.router.add_post("/scim/v2/Users", scim_user_create) + app.router.add_get("/scim/v2/Users/{id}", scim_user_get) + app.router.add_put("/scim/v2/Users/{id}", scim_user_put) + app.router.add_route("PATCH", "/scim/v2/Users/{id}", scim_user_patch) + app.router.add_delete("/scim/v2/Users/{id}", scim_user_delete) + app.router.add_get("/scim/v2/Groups", scim_groups_list) + app.router.add_post("/scim/v2/Groups", scim_group_create) + app.router.add_get("/scim/v2/Groups/{id}", scim_group_get) + app.router.add_put("/scim/v2/Groups/{id}", scim_group_put) + app.router.add_route("PATCH", "/scim/v2/Groups/{id}", scim_group_patch) + app.router.add_delete("/scim/v2/Groups/{id}", scim_group_delete) + app.router.add_get("/api/admin/scim/tokens", api_scim_tokens_list) + app.router.add_post("/api/admin/scim/tokens", api_scim_token_create) + app.router.add_post("/api/admin/scim/tokens/revoke", api_scim_token_revoke) + app.router.add_post("/api/admin/role", api_admin_role) + app.router.add_get("/api/admin/users", api_admin_users) + app.router.add_get("/api/admin/overview", api_admin_overview) + app.router.add_get("/api/admin/teams", api_admin_teams) + app.router.add_get("/api/admin/policy", api_admin_policy) + app.router.add_post("/api/admin/policy", api_admin_policy) + app.router.add_get("/api/v1/policy", v1_policy_get) + app.router.add_post("/api/v1/policy", v1_policy_set) + app.router.add_post("/api/admin/license", api_admin_license) + app.router.add_get("/api/relays", api_relays) + app.router.add_get("/api/relay/best", api_relay_best) + app.router.add_get("/api/auth/2fa/status", auth_2fa_status) + app.router.add_post("/api/auth/2fa/setup", auth_2fa_setup) + app.router.add_post("/api/auth/2fa/enable", auth_2fa_enable) + app.router.add_post("/api/auth/2fa/disable", auth_2fa_disable) + app.router.add_post("/api/wake", api_wake) + app.router.add_get("/api/info", api_info) + app.router.add_post("/api/contact", api_contact) + app.router.add_get("/sitemap.xml", http_sitemap) + + # a static-serve helper for the marketing pages + def serve(fname): + return lambda r: serve_file(fname) + + marketing = ["features", "pricing", "security", "download", "contact", + "about", "terms", "privacy", "refund"] + + # UI pages: marketing site at root, web app under /app + pages = [("/", http_index), ("/index.html", http_index), + ("/app", http_app), ("/app.html", http_app), + ("/app.js", serve("app.js")), ("/host.js", serve("host.js")), + ("/app.css", serve("app.css")), + ("/site.css", serve("site.css")), ("/site.js", serve("site.js")), + ("/admin", serve("admin.html")), ("/admin.js", serve("admin.js")), + ("/portal", portal_page), + ("/portal.js", serve("portal.js")), + ("/manifest.webmanifest", serve("manifest.webmanifest")), + ("/ndicon.svg", serve("ndicon.svg"))] + for slug in marketing: + pages.append(("/" + slug, serve(slug + ".html"))) + pages.append(("/" + slug + ".html", serve(slug + ".html"))) + if ARGS.access_path: + for path, handler in pages: + app.router.add_get("/" + ARGS.access_path + ("" if path == "/" else path), handler) + else: + for path, handler in pages: + app.router.add_get(path, handler) + return app + + +def main(): + global ARGS + ap = argparse.ArgumentParser(description="NetDesk signaling/relay server") + ap.add_argument("--port", type=int, default=8443) + ap.add_argument("--bind", default="0.0.0.0") + ap.add_argument("--cert", help="TLS certificate PEM") + ap.add_argument("--key", help="TLS private key PEM") + ap.add_argument("--gen-cert", action="store_true", help="generate a self-signed cert and use it") + ap.add_argument("--domain", help="DNS name for --gen-cert SAN") + ap.add_argument("--reg-key", help="secret hosts must present to register (recommended)") + ap.add_argument("--status-key", help="secret required for /status details") + ap.add_argument("--portal-password", dest="portal_password", + help="password for the web management portal") + ap.add_argument("--access-path", dest="access_path", + help="secret URL segment hosting the UI (random hash); root serves 404") + ap.add_argument("--owner-email", dest="owner_email", default="", + help="email with unrestricted access + admin panel") + # TURN tier of the hybrid transport (P2P -> TURN -> WSS relay). + # Cloudflare Realtime TURN is anycast: peers relay via their NEAREST city. + ap.add_argument("--turn-key-id", dest="turn_key_id", + default=os.environ.get("ND_TURN_KEY_ID", ""), + help="Cloudflare Realtime TURN key id (mints short-lived creds)") + ap.add_argument("--turn-token", dest="turn_token", + default=os.environ.get("ND_TURN_TOKEN", ""), + help="Cloudflare Realtime TURN key API token") + ap.add_argument("--turn-urls", dest="turn_urls", + default=os.environ.get("ND_TURN_URLS", ""), + help="static TURN urls (comma sep), e.g. turn:host:3478?transport=udp") + ap.add_argument("--turn-user", dest="turn_user", default=os.environ.get("ND_TURN_USER", "")) + ap.add_argument("--turn-pass", dest="turn_pass", default=os.environ.get("ND_TURN_PASS", "")) + # Force every session onto the WSS relay (no ICE handed out -> no P2P). A + # privacy/compliance control: peer IPs are never exposed and all traffic + # flows through this server. (RustDesk's ALWAYS_USE_RELAY.) + ap.add_argument("--always-use-relay", dest="always_use_relay", action="store_true", + default=os.environ.get("ND_ALWAYS_USE_RELAY", "") not in ("", "0", "false", "no"), + help="hand out no STUN/TURN so all sessions stay on the relay") + # self-hosted coturn with use-auth-secret: the server mints 12h HMAC creds + ap.add_argument("--turn-host", dest="turn_host", default=os.environ.get("ND_TURN_HOST", ""), + help="hostname of our own coturn (3478 udp/tcp + 5349 tls)") + ap.add_argument("--turn-secret", dest="turn_secret", default=os.environ.get("ND_TURN_SECRET", ""), + help="coturn static-auth-secret (TURN REST API credentials)") + ap.add_argument("--oidc", default=os.environ.get("ND_OIDC", ""), + help='SSO providers as JSON, e.g. ' + '{"google":{"issuer":"https://accounts.google.com","client_id":"..","client_secret":".."}}') + ap.add_argument("--mail-from", dest="mail_from", default="", + help="sender address enabling email accounts, e.g. noreply@netdesk.space") + ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1", + help="SMTP relay for verification mail (default localhost postfix)") + ap.add_argument("--public-url", dest="public_url", default="", + help="public base URL used in email links, e.g. https://netdesk.space") + ap.add_argument("--portal-totp", dest="portal_totp", default=os.environ.get("ND_PORTAL_TOTP", ""), + help="base32 TOTP secret to also require at portal login (optional 2FA)") + ap.add_argument("--relays", default=os.environ.get("ND_RELAYS", ""), + help="JSON array of relay nodes [{id,region,url,lat,lon,tier}] " + "for nearest-relay geo selection (default: a single self-entry)") + ap.add_argument("--ldap-url", dest="ldap_url", default=os.environ.get("ND_LDAP_URL", ""), + help="ldap://host:389 or ldaps://host:636 to enable LDAP/AD login") + ap.add_argument("--ldap-bind-template", dest="ldap_bind_template", + default=os.environ.get("ND_LDAP_BIND", "{email}"), + help="bind DN template; {user}=local part, {email}=full, {domain}=domain " + "(AD UPN: '{email}'; OpenLDAP: 'uid={user},ou=people,dc=ex,dc=com')") + ap.add_argument("--ldap-default-role", dest="ldap_default_role", + default=os.environ.get("ND_LDAP_ROLE", "member")) + ap.add_argument("--ldap-timeout", dest="ldap_timeout", type=int, + default=int(os.environ.get("ND_LDAP_TIMEOUT", "6"))) + ap.add_argument("--ldap-insecure", dest="ldap_insecure", action="store_true", + default=bool(os.environ.get("ND_LDAP_INSECURE")), + help="skip TLS cert validation for ldaps:// (internal CA self-signed only)") + ap.add_argument("--recording-retention-days", dest="recording_retention_days", type=int, + default=int(os.environ.get("ND_REC_RETENTION", "0")), + help="auto-delete central recordings older than N days (0 = keep forever)") + ap.add_argument("--saml-idp-sso-url", dest="saml_idp_sso_url", + default=os.environ.get("ND_SAML_SSO_URL", ""), + help="IdP SSO (SingleSignOnService) URL to enable SAML 2.0 SSO") + ap.add_argument("--saml-idp-cert", dest="saml_idp_cert", + default=os.environ.get("ND_SAML_IDP_CERT", ""), + help="IdP X.509 signing certificate (PEM file path or inline PEM)") + ap.add_argument("--saml-idp-entity-id", dest="saml_idp_entity_id", + default=os.environ.get("ND_SAML_IDP_ENTITY", ""), + help="expected IdP entityID (Issuer) to validate (optional)") + ap.add_argument("--saml-sp-entity-id", dest="saml_sp_entity_id", + default=os.environ.get("ND_SAML_SP_ENTITY", ""), + help="this SP's entityID (default: /saml/metadata)") + ap.add_argument("--saml-default-role", dest="saml_default_role", + default=os.environ.get("ND_SAML_ROLE", "member")) + ap.add_argument("--saml-enforce", dest="saml_enforce", action="store_true", + default=bool(os.environ.get("ND_SAML_ENFORCE")), + help="advertise SSO as mandatory to the login UI") + ap.add_argument("--verbose", action="store_true") + ARGS = ap.parse_args() + + logging.basicConfig( + level=logging.DEBUG if ARGS.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + ssl_ctx = None + if ARGS.gen_cert: + d = os.path.dirname(os.path.abspath(__file__)) + cert_path, key_path = os.path.join(d, "selfsigned.crt"), os.path.join(d, "selfsigned.key") + gen_selfsigned(cert_path, key_path, ARGS.domain) + ARGS.cert, ARGS.key = cert_path, key_path + if ARGS.cert and ARGS.key: + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(ARGS.cert, ARGS.key) + print(f"[netdesk] TLS enabled ({ARGS.cert})") + else: + print("[netdesk] WARNING: running without TLS - fine on localhost/LAN, " + "NOT for the public internet. Use --cert/--key or --gen-cert.") + + if ARGS.reg_key: + print("[netdesk] host registration key required (good).") + if ARGS.portal_password: + os.makedirs(FILES_DIR, exist_ok=True) + print(f"[netdesk] web portal enabled at /portal (downloads: {FILES_DIR})") + + _load_audit() + app = build_app() + scheme = "https/wss" if ssl_ctx else "http/ws" + print(f"[netdesk] server v{VERSION} listening on {scheme}://{ARGS.bind}:{ARGS.port}") + print(f"[netdesk] web viewer: {'https' if ssl_ctx else 'http'}://:{ARGS.port}/") + web.run_app(app, host=ARGS.bind, port=ARGS.port, ssl_context=ssl_ctx, print=None, access_log=None) + + +if __name__ == "__main__": + main() diff --git a/server/web/about.html b/server/web/about.html new file mode 100644 index 0000000..901ead6 --- /dev/null +++ b/server/web/about.html @@ -0,0 +1,42 @@ + + + + + +About — NetDesk + + + + + + + + + + + + + + +
+

About NetDesk

Building remote access that respects your ownership.

+

NetDesk exists because remote access shouldn't mean handing your screen, your files and your trust to someone else's +cloud. Tools like TeamViewer and AnyDesk are powerful, but they route your sessions through their servers and charge +per seat. RustDesk proved a self-hosted model works — NetDesk takes it further with a first-class web client, an +account and device system, and a polished desktop app.

+

Our principles

+
    +
  • You own it. Your server, your keys, your data. No NetDesk cloud in the path.
  • +
  • Private by default. End-to-end encryption is on out of the box, not an upsell.
  • +
  • Open source. Every line is auditable. Trust is earned by transparency.
  • +
  • Free for individuals. The core is free forever; teams pay for convenience, not for the right to connect.
  • +
+

Built for scale

+

NetDesk runs a stateless relay you can deploy on a $5 VPS or a fleet of them. It handles NAT traversal without port +forwarding, survives reconnections, and streams hardware-accelerated H.264 at up to 1080p60.

+

We're just getting started. Talk to us or browse the full source.

+
+ + + + \ No newline at end of file diff --git a/server/web/admin.html b/server/web/admin.html new file mode 100644 index 0000000..d37ee59 --- /dev/null +++ b/server/web/admin.html @@ -0,0 +1,230 @@ + + + + + +NetDesk — Admin Console + + + + + + + + +
Checking access…
+ + +
+ + + diff --git a/server/web/admin.js b/server/web/admin.js new file mode 100644 index 0000000..341f484 --- /dev/null +++ b/server/web/admin.js @@ -0,0 +1,447 @@ +/* NetDesk admin console — manages users/roles, devices, MDM policy, API keys, + relays. Account admin-role gated (the /api/admin/* endpoints return 403 for + non-admins; the server is the authority, this UI just reacts). */ +(function () { + "use strict"; + var $ = function (s) { return document.querySelector(s); }; + function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } + function fmtTs(t) { return t ? new Date(t * 1000).toLocaleDateString() : "—"; } + function fmtAgo(t) { if (!t) return "never"; var s = Date.now() / 1000 - t; + if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago"; + if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; } + var toastT; + function toast(msg, bad) { var t = $("#toast"); t.textContent = msg; t.style.borderColor = bad ? "#ef4444" : ""; + t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove("show"); }, 2400); } + + function getJSON(p) { return fetch(p, { credentials: "same-origin", cache: "no-store" }).then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } + function post(p, body) { return fetch(p, { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }).then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } + + /* ---- access gate ---- */ + getJSON("/api/admin/overview").then(function (r) { + if (r.status === 401) { location.href = "/app"; return; } + if (r.status === 403) { + $("#gate").innerHTML = '

Admin access required

Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.

Back to app'; + return; + } + if (!r.ok) { $("#gate").innerHTML = '

Could not reach the server.

'; return; } + $("#gate").hidden = true; $("#shell").hidden = false; + renderOverview(r.d); + wireNav(); loadUsersForPolicy(); + }); + + /* ---- nav ---- */ + var loaded = {}; + function wireNav() { + $("#nav").querySelectorAll("a").forEach(function (a) { + a.onclick = function () { + $("#nav").querySelectorAll("a").forEach(function (x) { x.classList.remove("on"); }); + a.classList.add("on"); + var p = a.dataset.p; + ["overview", "users", "devices", "policy", "keys", "relays", "teams", "access", "recordings"].forEach(function (s) { + var el = $("#p-" + s); if (el) el.hidden = s !== p; }); + if (p === "users" && !loaded.users) loadUsers(); + if (p === "devices" && !loaded.devices) loadDevices(); + if (p === "keys" && !loaded.keys) loadKeys(); + if (p === "relays" && !loaded.relays) loadRelays(); + if (p === "teams" && !loaded.teams) loadTeams(); + if (p === "access" && !loaded.access) loadAccess(); + if (p === "recordings" && !loaded.recordings) loadRecordings(); + }; + }); + } + + /* ---- overview ---- */ + function renderOverview(d) { + var items = [["users", "Accounts"], ["admins", "Administrators"], ["devices", "Registered devices"], + ["hosts_online", "Hosts online"], ["in_session", "In session now"], ["api_keys", "API keys"], ["relays", "Relay nodes"]]; + $("#ovGrid").innerHTML = items.map(function (i) { + return '
' + (d[i[0]] != null ? d[i[0]] : "—") + '' + i[1] + '
'; }).join(""); + } + + /* ---- users + roles + seats ---- */ + var USERS = []; + function loadUsers() { + loaded.users = true; + getJSON("/api/admin/users").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + USERS = r.d.users || []; + $("#uCount").textContent = USERS.length + " account" + (USERS.length === 1 ? "" : "s"); + $("#uBody").innerHTML = USERS.map(function (u) { + var rsel = ''; + var seat = ''; + return '' + esc(u.email) + (u.email === (r.d.me || "") ? ' (you)' : "") + '' + + '' + rsel + '' + seat + '' + (u.devices || 0) + '' + + '' + (u.twofa ? "on" : "off") + '' + + '' + (u.verified ? "yes" : "no") + ''; + }).join(""); + $("#uBody").querySelectorAll("[data-role]").forEach(function (sel) { + sel.onchange = function () { post("/api/admin/role", { email: sel.dataset.role, role: sel.value }) + .then(function (x) { toast(x.ok ? "Role updated" : (x.d.error || "failed"), !x.ok); }); }; + }); + $("#uBody").querySelectorAll("[data-seat]").forEach(function (inp) { + inp.onchange = function () { post("/api/admin/license", { email: inp.dataset.seat, seats: parseInt(inp.value, 10) || 0 }) + .then(function (x) { toast(x.ok ? "Seat limit saved" : (x.d.error || "failed"), !x.ok); }); }; + }); + }); + } + + /* ---- devices ---- */ + function loadDevices() { + loaded.devices = true; + getJSON("/api/devices?scope=all").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var ds = r.d.devices || []; + $("#dCount").textContent = ds.length + " device" + (ds.length === 1 ? "" : "s"); + $("#dBody").innerHTML = ds.map(function (dv) { + var st = dv.online ? '' + (dv.busy ? "in session" : "online") : 'offline'; + var tags = (dv.tags || []).map(function (t) { return '' + esc(t) + ''; }).join("") || '—'; + return '' + esc(dv.name) + '
' + esc(dv.id) + '
' + + '' + esc(dv.owner || "—") + '' + st + '' + + '' + + '' + + ' '; + }).join("") || 'No devices yet.'; + $("#dBody").querySelectorAll("tr[data-id]").forEach(function (tr) { + var id = tr.dataset.id; + var save = tr.querySelector("[data-save]"); if (save) save.onclick = function () { + post("/api/devices/meta", { id: id, group: tr.querySelector("[data-grp]").value, tags: tr.querySelector("[data-tags]").value }) + .then(function (x) { toast(x.ok ? "Saved" : (x.d.error || "failed"), !x.ok); }); }; + var del = tr.querySelector("[data-del]"); if (del) del.onclick = function () { + if (!confirm("Remove " + id + "? It must re-register to return.")) return; + post("/api/devices/remove", { id: id }).then(function (x) { if (x.ok) { tr.remove(); toast("Removed"); } else toast(x.d.error || "failed", true); }); }; + }); + }); + } + + /* ---- MDM policy editor ---- */ + var STRKEYS = [["server", "Relay server (wss://…)"], ["reg_key", "Registration key"], ["allow_ips", "Viewer IP allowlist (CIDR, comma-sep)"], ["totp", "2FA secret (base32)"]]; + function loadUsersForPolicy() { + getJSON("/api/admin/users").then(function (r) { + var sel = $("#polAcct"); if (!sel) return; + sel.innerHTML = (r.d.users || []).map(function (u) { return ''; }).join(""); + renderPolicyForm({}); + }); + $("#polLoad").onclick = function () { + getJSON("/api/admin/policy?email=" + encodeURIComponent($("#polAcct").value)).then(function (r) { renderPolicyForm(r.d.policy || {}); toast("Loaded"); }); }; + $("#polSave").onclick = function () { + post("/api/admin/policy", { email: $("#polAcct").value, policy: collectPolicy() }) + .then(function (x) { toast(x.ok ? "Policy saved & will push on host registration" : (x.d.error || "failed"), !x.ok); if (x.ok) renderPolicyForm(x.d.policy || {}); }); }; + $("#polClear").onclick = function () { + post("/api/admin/policy", { email: $("#polAcct").value, policy: {} }).then(function (x) { toast(x.ok ? "Policy cleared" : "failed", !x.ok); renderPolicyForm({}); }); }; + } + function renderPolicyForm(pol) { + var perms = pol.perms || {}; + var h = STRKEYS.map(function (k) { + return '
'; }).join(""); + function chk(key, lbl, on) { return ''; } + h += '
' + chk("lock_on_end", "Lock the PC when the session ends", !!pol.lock_on_end) + chk("approve_click", "Require the host user to approve each connect", !!pol.approve_click) + chk("record_required", "Force central session recording (upload to the server)", !!pol.record_required) + '
'; + h += '
' + + chk("perm_input", "Keyboard / mouse input", perms.input !== false) + chk("perm_clipboard", "Clipboard sync", perms.clipboard !== false) + + chk("perm_files", "File transfer", perms.files !== false) + chk("perm_audio", "Audio", perms.audio !== false) + '
'; + $("#polForm").innerHTML = h; + } + function collectPolicy() { + var pol = {}, perms = {}; + $("#polForm").querySelectorAll("[data-pk]").forEach(function (i) { if (i.value.trim()) pol[i.dataset.pk] = i.value.trim(); }); + $("#polForm").querySelectorAll("[data-pb]").forEach(function (c) { + var k = c.dataset.pb; + if (k.indexOf("perm_") === 0) perms[k.slice(5)] = c.checked; else if (c.checked) pol[k] = true; + }); + pol.perms = perms; + return pol; + } + + /* ---- API keys ---- */ + function loadKeys() { + loaded.keys = true; + getJSON("/api/keys").then(function (r) { + var ks = (r.d && r.d.keys) || []; + $("#kBody").innerHTML = ks.map(function (k) { + return '' + esc(k.name) + '' + esc(k.key_preview) + '' + fmtTs(k.created) + '' + fmtAgo(k.last_used) + '' + + ''; }).join("") || + 'No API keys yet.'; + $("#kBody").querySelectorAll("[data-rev]").forEach(function (b) { + b.onclick = function () { if (!confirm("Revoke this key?")) return; + post("/api/keys/revoke", { key: b.dataset.rev }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loaded.keys = false; loadKeys(); }); }; + }); + }); + $("#kNew").onclick = function () { + var name = $("#kName").value.trim() || "key"; + post("/api/keys", { name: name }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#kName").value = ""; $("#kNewBox").hidden = false; + $("#kNewBox").innerHTML = 'New key (copy it now — shown once): ' + esc(x.d.key) + ''; + loaded.keys = false; loadKeys(); + }); + }; + loadScim(); + $("#scNew").onclick = function () { + var label = $("#scName").value.trim() || "SCIM"; + post("/api/admin/scim/tokens", { label: label }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#scName").value = ""; $("#scNewBox").hidden = false; + $("#scNewBox").innerHTML = 'New SCIM token (copy now — shown once): ' + esc(x.d.token) + ''; + loadScim(); + }); + }; + } + function loadScim() { + getJSON("/api/admin/scim/tokens").then(function (r) { + if (!r.ok) return; + var ts = (r.d && r.d.tokens) || []; + if (r.d.base) $("#scBase").innerHTML = 'Point your IdP at ' + esc(r.d.base) + ' with the token as an HTTP Bearer header. Provisions accounts (Users) & teams (Groups).'; + $("#scBody").innerHTML = ts.map(function (t) { + return '' + esc(t.label) + '' + esc(t.token_preview) + '' + fmtTs(t.created) + '' + fmtAgo(t.last_used) + '' + + ''; }).join("") || + 'No provisioning tokens yet.'; + $("#scBody").querySelectorAll("[data-screv]").forEach(function (b) { + b.onclick = function () { if (!confirm("Revoke this SCIM token? The IdP will stop provisioning.")) return; + post("/api/admin/scim/tokens/revoke", { token: b.dataset.screv }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loadScim(); }); }; + }); + }); + } + + /* ---- relays ---- */ + function loadRelays() { + loaded.relays = true; + getJSON("/api/relays").then(function (r) { + var rs = (r.d && r.d.relays) || []; + $("#rBody").innerHTML = rs.map(function (x) { + return '' + esc(x.id) + '' + esc(x.region || "—") + '' + esc(x.url) + '' + esc(x.tier || "—") + '' + x.lat + ", " + x.lon + ''; }).join(""); + }); + } + + /* ---- teams (shared address books) ---- */ + var TEAMS = [], curTeam = null, ALLDEV = []; + function refreshCounts() { loaded.teams = false; loadTeams(); } + function loadTeams() { + loaded.teams = true; + getJSON("/api/admin/teams").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + TEAMS = r.d.teams || []; + $("#tCount").textContent = TEAMS.length + " team" + (TEAMS.length === 1 ? "" : "s"); + $("#tList").innerHTML = TEAMS.map(function (t) { + return '
' + esc(t.name) + '' + + '
' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '
' + + '#' + t.id + '
'; + }).join("") || '
No teams yet — create one above.
'; + $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { + el.onclick = function () { selectTeam(parseInt(el.dataset.tid, 10)); }; + }); + if (curTeam && TEAMS.some(function (t) { return t.id === curTeam; })) selectTeam(curTeam); + else { curTeam = null; $("#tDetail").hidden = true; } + }); + } + function selectTeam(tid) { + curTeam = tid; + var t = TEAMS.filter(function (x) { return x.id === tid; })[0]; + $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.classList.toggle("on", parseInt(el.dataset.tid, 10) === tid); }); + $("#tDetail").hidden = false; + $("#tdName").textContent = t ? t.name : ("Team #" + tid); + loadMembers(tid); loadTeamDevices(tid); + } + function loadMembers(tid) { + getJSON("/api/teams/members?team_id=" + tid).then(function (r) { + var ms = (r.d && r.d.members) || []; + $("#tmList").innerHTML = ms.map(function (m) { + return '
' + esc(m.email) + ' ' + esc(m.role) + '' + + '
'; + }).join("") || '
No members.
'; + $("#tmList").querySelectorAll("[data-rm]").forEach(function (b) { + b.onclick = function () { + post("/api/teams/members/remove", { team_id: tid, email: b.dataset.rm }).then(function (x) { + toast(x.ok ? "Removed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + }); + }); + } + function loadTeamDevices(tid) { + getJSON("/api/teams/devices?team_id=" + tid).then(function (r) { + var ds = (r.d && r.d.devices) || [], shared = {}; + $("#tdDevList").innerHTML = ds.map(function (d) { + shared[d.id] = true; + return '
' + (d.online ? '' : '') + esc(d.name) + + ' ' + esc(d.owner || "") + '
'; + }).join("") || '
No shared devices.
'; + $("#tdDevList").querySelectorAll("[data-unshare]").forEach(function (b) { + b.onclick = function () { + post("/api/teams/devices/remove", { team_id: tid, device_id: b.dataset.unshare }).then(function (x) { + toast(x.ok ? "Unshared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + }); + var fill = function () { + $("#tdDev").innerHTML = ALLDEV.filter(function (d) { return !shared[d.id]; }).map(function (d) { + return ''; + }).join("") || ''; + }; + if (ALLDEV.length) fill(); + else getJSON("/api/devices?scope=all").then(function (rr) { ALLDEV = (rr.d && rr.d.devices) || []; fill(); }); + }); + } + (function wireTeams() { + var nw = $("#tNew"); if (nw) nw.onclick = function () { + var name = $("#tName").value.trim(); if (!name) { toast("Enter a team name", true); return; } + post("/api/teams", { name: name }).then(function (x) { + if (!x.ok) { toast(x.d.error || "failed", true); return; } + $("#tName").value = ""; toast("Team created"); curTeam = x.d.id; refreshCounts(); }); + }; + var ad = $("#tmAdd"); if (ad) ad.onclick = function () { + if (!curTeam) return; var em = $("#tmEmail").value.trim(); if (!em) { toast("Enter an account email", true); return; } + post("/api/teams/members", { team_id: curTeam, email: em, role: $("#tmRole").value }).then(function (x) { + toast(x.ok ? "Member added" : (x.d.error || "failed"), !x.ok); if (x.ok) { $("#tmEmail").value = ""; refreshCounts(); } }); + }; + var sh = $("#tdShare"); if (sh) sh.onclick = function () { + if (!curTeam) return; var id = $("#tdDev").value; if (!id) { toast("No device selected", true); return; } + post("/api/teams/devices", { team_id: curTeam, device_id: id }).then(function (x) { + toast(x.ok ? "Device shared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + var rn = $("#tdRename"); if (rn) rn.onclick = function () { + if (!curTeam) return; + var cur = (TEAMS.filter(function (x) { return x.id === curTeam; })[0] || {}).name || ""; + var name = prompt("Rename team", cur); if (name == null || !name.trim()) return; + post("/api/teams/rename", { team_id: curTeam, name: name.trim() }).then(function (x) { + toast(x.ok ? "Renamed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); + }; + var dl = $("#tdDelete"); if (dl) dl.onclick = function () { + if (!curTeam) return; + if (!confirm("Delete this team? Members lose the shared list (the devices themselves are not deleted).")) return; + post("/api/teams/delete", { team_id: curTeam }).then(function (x) { + toast(x.ok ? "Team deleted" : (x.d.error || "failed"), !x.ok); + if (x.ok) { curTeam = null; $("#tDetail").hidden = true; refreshCounts(); } }); + }; + })(); + + /* ---- session recordings ---- */ + function fmtSize(b) { b = +b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB"; return (b / 1073741824).toFixed(2) + " GB"; } + function fmtDur(s) { s = +s || 0; if (!s) return "—"; var m = Math.floor(s / 60), ss = s % 60; return m + ":" + ("" + ss).padStart(2, "0"); } + function loadRecordings() { + loaded.recordings = true; + getJSON("/api/recordings").then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var rs = r.d.recordings || []; + $("#recRetention").textContent = r.d.retention_days > 0 ? ("Auto-deleted after " + r.d.retention_days + " days.") : "Kept indefinitely (no retention limit set)."; + $("#recCount").textContent = rs.length + " recording" + (rs.length === 1 ? "" : "s"); + $("#recBody").innerHTML = rs.map(function (x) { + return '' + x.id + '' + esc(x.host_id) + '' + esc(x.owner || "—") + '' + + '' + esc(x.viewer || "—") + '' + fmtSize(x.size) + '' + fmtDur(x.duration) + '' + + '' + fmtTs(x.created) + '' + + 'Download '; + }).join("") || 'No recordings yet. Set a record_required MDM policy on an account, then its hosts upload session recordings here.'; + $("#recBody").querySelectorAll("[data-recdel]").forEach(function (b) { + b.onclick = function () { if (!confirm("Delete this recording permanently?")) return; + post("/api/recordings/delete", { id: +b.dataset.recdel }).then(function (x) { toast(x.ok ? "Deleted" : (x.d.error || "failed"), !x.ok); loadRecordings(); }); }; + }); + }); + } + + /* ---- conditional access rules ---- */ + var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + var editRuleId = null; + function minsToHHMM(m) { if (m == null || m === "") return ""; m = +m; return (("" + Math.floor(m / 60)).padStart(2, "0")) + ":" + (("" + (m % 60)).padStart(2, "0")); } + function hhmmToMins(s) { s = (s || "").trim(); if (!s) return null; var p = s.split(":"); if (p.length !== 2) return null; var h = +p[0], m = +p[1]; if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; } + function acOwner() { var s = $("#acAcct"); return (s && s.value) || ""; } + function loadAccess() { + loaded.access = true; + // day checkboxes + $("#acDays").innerHTML = DAYS.map(function (d, i) { return ''; }).join(""); + // account selector (reuse users list) + getJSON("/api/admin/users").then(function (r) { + var us = (r.d && r.d.users) || [], me = (r.d && r.d.me) || ""; + $("#acAcct").innerHTML = us.map(function (u) { return ''; }).join(""); + loadRules(); + }); + var lb = $("#acLoad"); if (lb) lb.onclick = loadRules; + } + function condText(r) { + var bits = []; + if (r.cidr) bits.push("from " + esc(r.cidr)); + if (r.weekdays) bits.push(r.weekdays.split(",").map(function (i) { return DAYS[+i] || i; }).join(" ")); + if (r.time_start != null && r.time_end != null) bits.push(minsToHHMM(r.time_start) + "–" + minsToHHMM(r.time_end) + (r.tz_offset ? " (UTC" + (r.tz_offset >= 0 ? "+" : "") + (r.tz_offset / 60) + ")" : " UTC")); + return bits.join(" · ") || 'any'; + } + function subjText(r) { + if (r.subject_type === "any") return 'any connection'; + return esc(r.subject_type) + ": " + esc(r.subject || "—") + ""; + } + function loadRules() { + var owner = acOwner(); + getJSON("/api/access/rules?owner=" + encodeURIComponent(owner)).then(function (r) { + if (!r.ok) { toast(r.d.error || "failed", true); return; } + var rs = r.d.rules || []; + $("#acBody").innerHTML = rs.map(function (x) { + return '' + x.priority + '' + + '' + x.action + '' + + '' + subjText(x) + '' + condText(x) + '' + + '' + esc(x.reason || "") + '' + + '' + + ' '; + }).join("") || 'No rules — every connection is allowed. Add a rule below.'; + $("#acBody").querySelectorAll("[data-del]").forEach(function (b) { + b.onclick = function () { if (!confirm("Delete this rule?")) return; + post("/api/access/rules/delete", { id: +b.dataset.del }).then(function (x) { toast(x.ok ? "Deleted" : "failed", !x.ok); loadRules(); }); }; + }); + $("#acBody").querySelectorAll("[data-toggle]").forEach(function (cb) { + cb.onchange = function () { var rule = rs.filter(function (z) { return z.id === +cb.dataset.toggle; })[0]; if (!rule) return; + post("/api/access/rules", ruleBody(rule, { enabled: cb.checked })).then(function (x) { toast(x.ok ? (cb.checked ? "Enabled" : "Disabled") : "failed", !x.ok); }); }; + }); + $("#acBody").querySelectorAll("[data-edit]").forEach(function (b) { + b.onclick = function () { var rule = rs.filter(function (z) { return z.id === +b.dataset.edit; })[0]; if (rule) fillRuleForm(rule); }; + }); + }); + } + function ruleBody(base, over) { + var b = { owner: acOwner(), id: base.id, priority: base.priority, action: base.action, subject_type: base.subject_type, + subject: base.subject, cidr: base.cidr, weekdays: base.weekdays, time_start: base.time_start, time_end: base.time_end, + tz_offset: base.tz_offset, reason: base.reason, enabled: base.enabled }; + for (var k in (over || {})) b[k] = over[k]; + return b; + } + function fillRuleForm(r) { + editRuleId = r.id; + $("#acFormTitle").textContent = "Edit rule #" + r.id; + $("#acReset").hidden = false; + $("#acPrio").value = r.priority; $("#acAction").value = r.action; $("#acSubType").value = r.subject_type; + $("#acSubject").value = r.subject || ""; $("#acCidr").value = r.cidr || ""; $("#acReason").value = r.reason || ""; + $("#acTimeStart").value = minsToHHMM(r.time_start); $("#acTimeEnd").value = minsToHHMM(r.time_end); $("#acTz").value = r.tz_offset || 0; + var days = (r.weekdays || "").split(","); + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = days.indexOf(cb.dataset.day) >= 0; }); + try { $("#acFormTitle").scrollIntoView({ behavior: "smooth", block: "center" }); } catch (e) {} + } + function resetRuleForm() { + editRuleId = null; $("#acFormTitle").textContent = "Add a rule"; $("#acReset").hidden = true; + $("#acPrio").value = 100; $("#acAction").value = "deny"; $("#acSubType").value = "any"; + $("#acSubject").value = ""; $("#acCidr").value = ""; $("#acReason").value = ""; $("#acTimeStart").value = ""; $("#acTimeEnd").value = ""; $("#acTz").value = 0; + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = false; }); + } + (function wireAccess() { + var sv = $("#acSave"); if (sv) sv.onclick = function () { + var days = []; + $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { if (cb.checked) days.push(cb.dataset.day); }); + var body = { owner: acOwner(), priority: +$("#acPrio").value || 100, action: $("#acAction").value, + subject_type: $("#acSubType").value, subject: $("#acSubject").value.trim(), cidr: $("#acCidr").value.trim(), + reason: $("#acReason").value.trim(), weekdays: days.join(","), tz_offset: +$("#acTz").value || 0, + time_start: hhmmToMins($("#acTimeStart").value), time_end: hhmmToMins($("#acTimeEnd").value), enabled: true }; + if (editRuleId) body.id = editRuleId; + post("/api/access/rules", body).then(function (x) { + toast(x.ok ? "Rule saved" : (x.d.error || "failed"), !x.ok); + if (x.ok) { resetRuleForm(); loadRules(); } }); + }; + var rs = $("#acReset"); if (rs) rs.onclick = resetRuleForm; + var tr = $("#tRun"); if (tr) tr.onclick = function () { + var q = new URLSearchParams({ owner: acOwner(), viewer: $("#tViewer").value.trim(), host: $("#tHost").value.trim(), group: $("#tGroup").value.trim(), ip: $("#tIp").value.trim() }); + getJSON("/api/access/test?" + q.toString()).then(function (x) { + if (!x.ok) { $("#tResult").innerHTML = '' + esc(x.d.error || "error") + ''; return; } + var d = x.d; + $("#tResult").innerHTML = d.allowed + ? '✓ ALLOWED' + (d.rule_id ? ' by rule #' + d.rule_id + '' : ' (no rule matched — default allow)') + : '✕ DENIED rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + ''; + }); + }; + })(); +})(); diff --git a/server/web/app.css b/server/web/app.css new file mode 100644 index 0000000..5bb9203 --- /dev/null +++ b/server/web/app.css @@ -0,0 +1,1302 @@ +/* ================================================================ + NetDesk design system — enterprise dark, RustDesk/TeamViewer-style + ================================================================ */ +:root { + --bg: #0b0e14; + --bg2: #11151f; + --bg3: #171d2a; + --bg4: #1d2536; + --fg: #e8edf6; + --muted: #8b95a9; + --faint: #5d6777; + --accent: #3d7eff; + --accent-h: #5c93ff; + --accent-dim: rgba(61, 126, 255, .14); + --ok: #2fbf71; + --warn: #f5a524; + --danger: #e5484d; + --danger-h: #f2666b; + --border: #222b3d; + --border-soft: #1a2233; + --shadow: 0 12px 40px rgba(0, 0, 0, .5); + --r-sm: 6px; + --r-md: 10px; + --r-lg: 14px; +} +/* light (corporate) theme - TeamViewer-style */ +:root[data-theme="light"] { + --bg: #eef1f6; + --bg2: #ffffff; + --bg3: #f0f3f8; + --bg4: #e4e9f2; + --fg: #17202f; + --muted: #5b6577; + --faint: #98a1b3; + --border: #d9e0ea; + --border-soft: #e7ebf2; + --accent-dim: rgba(61, 126, 255, .10); + --shadow: 0 12px 40px rgba(23, 32, 47, .12); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Inter", sans-serif; + background: var(--bg); + color: var(--fg); + font-size: 13.5px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} +svg.ic { width: 16px; height: 16px; display: block; } + +/* ================= connect screen : split layout ================= */ +#screenConnect { min-height: 100vh; } + +.brand-pane { + flex: 1.1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 48px 56px; + background: + radial-gradient(900px 500px at -10% -10%, rgba(61, 126, 255, .16), transparent 60%), + radial-gradient(700px 500px at 110% 115%, rgba(47, 191, 113, .10), transparent 60%), + var(--bg2); + border-right: 1px solid var(--border-soft); +} +.brand-logo { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; } +.brand-logo h1 { font-size: 26px; font-weight: 600; letter-spacing: .2px; } +.brand-tag { font-size: 22px; font-weight: 600; line-height: 1.3; max-width: 460px; margin-bottom: 10px; } +.brand-sub { color: var(--muted); max-width: 440px; margin-bottom: 30px; } +.brand-feats { display: flex; flex-direction: column; gap: 12px; } +.feat { display: flex; gap: 12px; align-items: flex-start; color: var(--muted); max-width: 440px; } +.feat .fic { + flex-shrink: 0; + width: 30px; height: 30px; + border-radius: 8px; + background: var(--accent-dim); + color: var(--accent-h); + display: flex; align-items: center; justify-content: center; +} +.feat b { color: var(--fg); font-weight: 600; display: block; font-size: 13.5px; } +.feat span { font-size: 12.5px; } +.brand-foot { margin-top: 40px; color: var(--faint); font-size: 12px; } + +.form-pane { + flex: 1; + min-width: 400px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow-y: auto; +} +.card { + width: 400px; + max-width: 96vw; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 26px; + box-shadow: var(--shadow); +} +.card .logo { display: none; } +.card h2 { font-size: 17px; font-weight: 600; margin-bottom: 2px; } +.card .sub { color: var(--muted); margin-bottom: 18px; font-size: 13px; } + +label { display: block; margin: 12px 0 6px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; } +input[type=text], input[type=password], input:not([type]) { + width: 100%; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 9px 12px; + font-size: 14px; + outline: none; + transition: border-color .12s, box-shadow .12s; +} +input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +input::placeholder { color: var(--faint); } +.pw-wrap { display: flex; gap: 6px; } +.pw-wrap input { flex: 1; } +.row { display: flex; gap: 10px; } +.grow { flex: 1; } +#inId { font-size: 17px; letter-spacing: 2px; font-variant-numeric: tabular-nums; font-weight: 600; } +.chk { + display: flex; align-items: center; gap: 8px; + color: var(--muted); text-transform: none; letter-spacing: 0; + font-size: 13px; font-weight: 400; margin: 10px 0; cursor: pointer; user-select: none; +} +.chk input { accent-color: var(--accent); width: 14px; height: 14px; } + +button.primary { + width: 100%; + margin-top: 12px; + background: linear-gradient(180deg, #4b88ff, #2f6ef2); + color: #fff; + border: 0; + border-radius: var(--r-sm); + padding: 11px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 14px rgba(61, 126, 255, .35); + transition: background .12s, filter .12s, transform .06s; +} +button.primary:hover { filter: brightness(1.07); } +button.primary:active { transform: translateY(1px); } +button.primary:disabled { opacity: .55; cursor: wait; } + +.ghost { + background: var(--bg3); + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 8px 10px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.ghost:hover { color: var(--fg); border-color: var(--accent); } + +.error { + margin-top: 12px; + background: rgba(229, 72, 77, .10); + border: 1px solid rgba(229, 72, 77, .45); + color: #f6a9ac; + padding: 9px 12px; + border-radius: var(--r-sm); + font-size: 12.5px; +} +.hint { margin-top: 16px; color: var(--faint); font-size: 12px; line-height: 1.55; } +.hint code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; } +.foot { color: var(--faint); font-size: 12px; } +.form-foot { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-soft); display: flex; justify-content: space-between; align-items: center; } +.theme-toggle { + background: transparent; border: 1px solid var(--border); color: var(--muted); + border-radius: var(--r-sm); padding: 4px 8px; cursor: pointer; font-size: 12px; + display: inline-flex; align-items: center; gap: 6px; +} +.theme-toggle:hover { color: var(--fg); border-color: var(--accent); } +body, .card, #toolbar, .panel { transition: background-color .18s, border-color .18s, color .18s; } +#toolbar { background: var(--bg2); } + +/* address book */ +#abookWrap { margin-top: 16px; border-top: 1px solid var(--border-soft); padding-top: 12px; } +.ab-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; } +.ab-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 8px; border-radius: var(--r-sm); cursor: pointer; +} +.ab-row:hover { background: var(--bg3); } +.ab-ava { + width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0; + background: var(--accent-dim); color: var(--accent-h); + display: flex; align-items: center; justify-content: center; + font-weight: 600; font-size: 13px; +} +.ab-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ab-id { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; } +.ab-shared { display: inline-block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; padding: 1px 6px; border-radius: 999px; background: rgba(77,139,255,.16); color: #9cc0ff; vertical-align: 1px; } +.ab-row .tb { padding: 3px 7px; } + +/* ================= session screen ================= */ +#screenSession { height: 100vh; display: flex; flex-direction: column; } +#toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + background: linear-gradient(180deg, #151a26, #10141d); + border-bottom: 1px solid var(--border); + padding: 6px 10px; + min-height: 46px; + z-index: 30; +} +.tb-group { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } +.sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; flex-shrink: 0; } +.tb-title { font-weight: 600; margin-right: 4px; font-size: 13.5px; } +.state { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; } +.state::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); } +.state.ok::before { background: var(--ok); box-shadow: 0 0 6px var(--ok); } +.state.warn::before { background: var(--warn); } +.state.err::before { background: var(--danger); } +.tb { + height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + color: var(--fg); + border: 1px solid transparent; + border-radius: var(--r-sm); + padding: 0 9px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background .1s, border-color .1s, color .1s; + white-space: nowrap; +} +.tb:hover { background: var(--bg3); border-color: var(--border); } +.tb:active { background: var(--bg4); } +.tb.active { background: var(--accent-dim); color: var(--accent-h); border-color: rgba(61, 126, 255, .45); } +.tb.danger:hover { background: rgba(229, 72, 77, .12); color: var(--danger-h); border-color: rgba(229, 72, 77, .4); } +.tb.rec-on { color: var(--danger-h); } +.tb.rec-on:hover { background: rgba(229, 72, 77, .12); } +select { + height: 30px; + background: var(--bg3); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 0 8px; + font-size: 12.5px; + font-family: inherit; + outline: none; + cursor: pointer; +} +select:hover { border-color: var(--accent); } +.stats { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +#tbStats { background: var(--bg3); border: 1px solid var(--border-soft); border-radius: var(--r-sm); padding: 4px 10px; } +:root[data-theme="light"] .brand-pane { + background: + radial-gradient(900px 500px at -10% -10%, rgba(61, 126, 255, .12), transparent 60%), + radial-gradient(700px 500px at 110% 115%, rgba(47, 191, 113, .08), transparent 60%), + var(--bg2); +} +:root[data-theme="light"] #stage { background: #10141d; } + +#stage { position: relative; flex: 1; overflow: hidden; background: #05070c; } +#canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + outline: none; + cursor: none; +} +#canvas.viewonly { cursor: default; } +.banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(229, 72, 77, .14); + border: 1px solid rgba(229, 72, 77, .5); + color: #fecaca; + padding: 8px 16px; + border-radius: var(--r-md); + font-size: 13px; + z-index: 20; + max-width: 80%; + backdrop-filter: blur(6px); +} +.banner.info { background: rgba(61, 126, 255, .14); border-color: rgba(61, 126, 255, .5); color: #cfe1ff; } +#dropHint { + position: absolute; + inset: 14px; + border: 2px dashed var(--accent); + border-radius: var(--r-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--accent-h); + background: rgba(61, 126, 255, .08); + z-index: 25; + pointer-events: none; +} + +/* ================= panels ================= */ +.panel { + position: absolute; + top: 54px; + right: 12px; + width: 470px; + max-width: calc(100vw - 24px); + max-height: calc(100% - 70px); + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r-md); + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + z-index: 40; + overflow: hidden; +} +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 13.5px; +} +.file-path { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border-soft); } +.file-path input { flex: 1; font-size: 12.5px; } +.file-list { flex: 1; overflow-y: auto; padding: 4px 0; } +.file-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; +} +.file-row:hover { background: var(--bg3); } +.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-row .meta { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.file-row .acts { display: none; gap: 2px; } +.file-row:hover .acts { display: flex; } +.file-row .acts button { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 13px; padding: 2px 5px; border-radius: 4px; } +.file-row .acts button:hover { color: var(--fg); background: var(--bg4); } +.panel-foot { display: flex; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border-soft); align-items: center; } +.panel-foot input { flex: 1; min-width: 0; font-size: 12.5px; } +#fileStatus { white-space: nowrap; } + +.chat-log { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; min-height: 220px; } +.chat-msg { + max-width: 85%; + background: var(--bg3); + border-radius: 10px 10px 10px 3px; + padding: 7px 11px; + font-size: 13px; + word-wrap: break-word; +} +.chat-msg.me { align-self: flex-end; background: var(--accent-dim); border-radius: 10px 10px 3px 10px; } +.chat-msg .who { font-size: 10.5px; color: var(--muted); margin-bottom: 2px; text-transform: uppercase; letter-spacing: .5px; } +.chat-input { display: flex; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border-soft); } + +.sys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px; } +.sys-grid .tb { background: var(--bg3); border-color: var(--border); height: 36px; justify-content: center; } +.sys-grid .tb:hover { border-color: var(--accent); } + +.proc-head { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px 4px; color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; border-top: 1px solid var(--border-soft); margin-top: 4px; } +#procList { max-height: 200px; } + +/* collapsible panel sections (TeamViewer-style) */ +details.sec { border-top: 1px solid var(--border-soft); } +details.sec:first-of-type { border-top: 0; } +details.sec > summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + cursor: pointer; + user-select: none; + color: var(--muted); + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .7px; + transition: color .12s, background .12s; +} +details.sec > summary::-webkit-details-marker { display: none; } +details.sec > summary:hover { color: var(--fg); background: var(--bg3); } +details.sec[open] > summary { color: var(--accent-h); } +details.sec .chev { margin-left: auto; transition: transform .15s; color: var(--faint); } +details.sec[open] .chev { transform: rotate(180deg); } +details.sec summary .tb { padding: 2px 8px; font-size: 12px; } +#sysPanel { width: 380px; } +.panel-head { cursor: grab; } +.panel-head:active { cursor: grabbing; } + +/* ================= portal ================= */ +body.portal { min-height: 100vh; } +body.console { display: flex; min-height: 100vh; } +.side { + width: 230px; + flex-shrink: 0; + background: var(--bg2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px 12px; + position: sticky; + top: 0; + height: 100vh; +} +.side-brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 18px; } +.side-sub { color: var(--muted); font-size: 11px; } +.side-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; } +.nav-it { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; border-radius: var(--r-sm); + color: var(--muted); text-decoration: none; font-size: 13.5px; + transition: background .1s, color .1s; border: 0; background: transparent; + font-family: inherit; cursor: pointer; text-align: left; width: 100%; +} +.nav-it:hover { background: var(--bg3); color: var(--fg); } +.nav-it.on { background: var(--accent-dim); color: var(--accent-h); } +.side-foot { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid var(--border-soft); padding-top: 10px; } +.mainc { flex: 1; min-width: 0; padding: 22px 24px 48px; max-width: 1100px; } +.keyrow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); } +.keyrow:last-child { border-bottom: 0; } +.key-l { color: var(--muted); font-size: 12px; margin-bottom: 2px; } +.key-v { font-size: 13px; word-break: break-all; } +@media (max-width: 900px) { .side { display: none; } } +.portal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 22px; + background: var(--bg2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.portal-main { max-width: 1060px; margin: 0 auto; padding: 22px 16px 48px; } +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 14px; text-align: center; } +.stat-n { font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; } +.stat-l { color: var(--muted); font-size: 11.5px; margin-top: 2px; text-transform: uppercase; letter-spacing: .6px; } +.p-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 16px; margin-bottom: 20px; } +.p-card h2 { font-size: 12.5px; margin-bottom: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .7px; } +.p-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +@media (max-width: 800px) { + .p-cols { grid-template-columns: 1fr; } + .brand-pane { display: none; } + .form-pane { min-width: 0; } +} +.tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.tbl th { text-align: left; color: var(--muted); font-weight: 500; padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; } +.tbl td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); } +.tbl tr:hover td { background: var(--bg3); } +.mono { font-variant-numeric: tabular-nums; letter-spacing: .5px; } +.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; } +.dot.on { background: var(--ok); box-shadow: 0 0 6px var(--ok); } +.dot.off { background: var(--faint); } +a.connect { text-decoration: none; color: var(--accent-h); border: 1px solid rgba(61, 126, 255, .45); background: var(--accent-dim); } +a.tb { color: var(--fg); text-decoration: none; } +.feed { max-height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } +.feed-row { display: flex; gap: 10px; font-size: 13px; } +.feed-row .stats { flex-shrink: 0; } +.feed-row.kind-auth-fail, .feed-row.kind-deny { color: #f6a9ac; } +.feed-row.kind-session { color: #a9c9ff; } +.dl-row { display: flex; gap: 12px; align-items: center; padding: 8px; border-radius: var(--r-sm); color: var(--fg); text-decoration: none; } +.dl-row:hover { background: var(--bg3); } +.dl-row .name { flex: 1; } +.muted { color: var(--muted); font-size: 13px; line-height: 1.5; } +.muted code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; } + +/* ================= scrollbars ================= */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 5px; border: 2px solid var(--bg2); } +::-webkit-scrollbar-thumb:hover { background: var(--border); } +::-webkit-scrollbar-track { background: transparent; } + +@media (max-width: 760px) { + #screenConnect { flex-direction: column; } + .brand-pane { display: none; } + .form-pane { padding: 16px; } +} + +/* auth modal + account chip + fullscreen pill */ +.modal-wrap { position: fixed; inset: 0; background: rgba(5,7,12,.7); backdrop-filter: blur(4px); + z-index: 100; display: flex; align-items: center; justify-content: center; } +.modal-wrap[hidden] { display: none; } +.modal-card { width: 360px; max-width: 94vw; background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--r-lg); padding: 22px; position: relative; box-shadow: var(--shadow); } +.modal-x { position: absolute; top: 10px; right: 12px; background: none; border: 0; + color: var(--muted); font-size: 15px; cursor: pointer; } +.auth-tabs { display: flex; gap: 4px; margin-bottom: 14px; } +.atab { flex: 1; background: var(--bg3); border: 1px solid var(--border); color: var(--muted); + padding: 8px 4px; border-radius: var(--r-sm); cursor: pointer; font-size: 12.5px; font-family: inherit; } +.atab.on { background: var(--accent-dim); color: var(--accent-h); border-color: rgba(61,126,255,.45); } +.apane { display: flex; flex-direction: column; gap: 10px; } +.apane[hidden] { display: none; } +.apane .primary { margin-top: 2px; } +.auok { margin-top: 12px; background: rgba(47,191,113,.12); border: 1px solid rgba(47,191,113,.45); + color: #8fe6bd; padding: 9px 12px; border-radius: var(--r-sm); font-size: 12.5px; } +#authChip { display: inline-flex; align-items: center; gap: 6px; } +#authChip .tb { padding: 2px 8px; font-size: 11.5px; } +.fs-pill { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); z-index: 45; + background: var(--accent); color: #fff; border: 0; border-radius: 999px; padding: 12px 26px; + font-size: 15px; font-weight: 600; cursor: pointer; box-shadow: 0 8px 30px rgba(0,0,0,.5); + font-family: inherit; } +#toolbar.autohide { transform: translateY(-110%); transition: transform .25s ease; } +#toolbar { transition: transform .25s ease; } + +.plan-strip { display: flex; gap: 8px; align-items: center; justify-content: center; + margin-top: 12px; font-size: 12px; color: var(--muted); flex-wrap: wrap; } +.dotsep { color: var(--faint); } +.linkbtn { background: none; border: 0; color: var(--accent-h); cursor: pointer; + font-size: 12px; padding: 0; text-decoration: underline; font-family: inherit; } +#tbStats.limit-warn { color: #f6a9ac; } + +/* ================= RustDesk-style landing ================= */ +.topnote { background: #eaf2ff; color: #1e3a6e; text-align: center; + padding: 9px 16px; font-size: 13.5px; border-bottom: 1px solid #d6e4ff; } +:root[data-theme="dark"] .topnote, body[data-theme="dark"] .topnote { background: #16233d; color: #b9d2ff; border-color: #1d3054; } +.nav { display: flex; align-items: center; justify-content: space-between; + padding: 14px 5vw; background: var(--bg2); border-bottom: 1px solid var(--border); } +.nav-brand { display: flex; align-items: center; gap: 9px; font-size: 20px; } +.nav-links { display: flex; align-items: center; gap: 22px; } +.nav-links a { color: var(--fg); text-decoration: none; font-size: 14.5px; opacity: .85; } +.nav-links a:hover { opacity: 1; color: var(--accent-h); } +.hero { display: flex; gap: 40px; align-items: center; justify-content: center; + padding: 64px 5vw 48px; flex-wrap: wrap; + background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.10), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.07), transparent 55%); +} +.hero-copy h1 { font-size: 52px; line-height: 1.08; letter-spacing: -1.2px; font-weight: 800; } +.hero-sub { font-size: 17px; color: var(--muted); max-width: 560px; margin: 18px 0 26px; line-height: 1.6; } +.hero-cta { display: flex; gap: 14px; } +.btn-big { display: inline-flex; align-items: center; gap: 8px; padding: 13px 28px; + border-radius: 999px; font-size: 15.5px; font-weight: 600; text-decoration: none; + transition: transform .08s, filter .15s, background .15s; } +.btn-blue { background: var(--accent); color: #fff; box-shadow: 0 6px 22px rgba(61,126,255,.4); } +.btn-blue:hover { filter: brightness(1.08); transform: translateY(-1px); } +.btn-ghost { border: 1.5px solid var(--border); color: var(--fg); background: var(--bg2); } +.btn-ghost:hover { border-color: var(--accent); color: var(--accent-h); } +.dl-ic { font-size: 13px; } +.hero-card { width: 400px; max-width: 94vw; } +.hero-card .card { padding: 22px; } +.feat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 18px; padding: 30px 5vw 40px; max-width: 1250px; margin: 0 auto; } +.dl-band { display: flex; align-items: center; justify-content: space-between; gap: 24px; + flex-wrap: wrap; background: var(--bg2); border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); padding: 34px 5vw; } +.dl-band h2 { font-size: 24px; margin-bottom: 6px; } +.dl-band p { color: var(--muted); max-width: 560px; } +.foot-band { display: flex; align-items: center; justify-content: space-between; + gap: 16px; flex-wrap: wrap; padding: 18px 5vw; color: var(--muted); font-size: 12.5px; + border-top: 1px solid var(--border); } +:root[data-theme="dark"] .hero { background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.16), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.08), transparent 55%); } +:root[data-theme="light"] .hero { background: + radial-gradient(900px 500px at 8% 0%, rgba(61,126,255,.10), transparent 55%), + radial-gradient(700px 500px at 95% 100%, rgba(61,126,255,.05), transparent 55%); } + +/* docked (minimized to right edge) panels */ +.panel.docked { width: 42px !important; min-height: 170px; cursor: pointer; } +.panel.docked > *:not(.panel-head) { display: none !important; } +.panel.docked .panel-head { padding: 12px 0; justify-content: center; cursor: pointer; } +.panel.docked .panel-head span { display: none; } +.panel.docked .panel-head button { display: none; } +.panel.docked::after { + content: attr(data-title); + writing-mode: vertical-rl; + color: var(--accent-h); + font-size: 12.5px; + font-weight: 600; + position: absolute; + top: 44px; + left: 50%; + transform: translateX(-50%); + letter-spacing: 1px; +} +.panel.docked .panel-head .dock-arrow { display: block; color: var(--accent-h); } + +/* session tab: landing fully hidden, pure dark stage */ +body.nw #screenConnect { display: none !important; } +body.nw { background: #05070c; } +#screenConnect.launched { opacity: .35; pointer-events: none; filter: saturate(.4); } +#screenConnect { transition: opacity .3s, filter .3s; } + +.nav-auth-btn { background: var(--accent); color: #fff; border: 0; border-radius: 999px; + padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: inherit; } +.nav-auth-btn:hover { filter: brightness(1.1); } +.nav-auth-email { color: var(--muted); font-size: 13px; } + +/* ---- fixes: hidden must always win; letterboxed canvas with a visible cursor ---- */ +[hidden] { display: none !important; } +#canvas { object-fit: contain; cursor: default; } +/* my computers (account device list) */ +#myDevsWrap { margin-top: 16px; border-top: 1px solid var(--border-soft); padding-top: 12px; } +.dev-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b95a5; flex-shrink: 0; } +.dev-dot.on { background: #2fbf71; box-shadow: 0 0 6px #2fbf71; } +.dev-dot.busy { background: #e0a53a; } + +/* ================= premium polish (v21) ================= */ +/* toolbar: soft depth + crisper separators */ +#toolbar { box-shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 6px 20px rgba(0,0,0,.28); } +.tb { transition: background .12s, border-color .12s, color .12s, transform .06s, box-shadow .12s; } +.tb:hover { box-shadow: 0 2px 8px rgba(0,0,0,.25); } +.tb:active { transform: translateY(1px); } +.tb.active { box-shadow: 0 0 0 1px rgba(61,126,255,.25), 0 2px 10px rgba(61,126,255,.18); } +.tb svg.ic { transition: transform .12s; } +.tb:hover svg.ic { transform: scale(1.06); } + +/* live connection-quality dot on the stats pill */ +#tbStats { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; } +#tbStats::before { + content: ""; width: 8px; height: 8px; border-radius: 50%; + background: var(--faint); flex-shrink: 0; transition: background .3s, box-shadow .3s; +} +#tbStats[data-q="good"]::before { background: var(--ok); box-shadow: 0 0 7px var(--ok); } +#tbStats[data-q="ok"]::before { background: var(--warn); box-shadow: 0 0 7px var(--warn); } +#tbStats[data-q="bad"]::before { background: var(--danger); box-shadow: 0 0 7px var(--danger); } + +/* selects: match the premium button feel */ +select { transition: border-color .12s, box-shadow .12s; } +select:hover, select:focus { box-shadow: 0 0 0 3px var(--accent-dim); } + +/* floating panels: deeper, softer, glassy */ +.panel { + box-shadow: 0 20px 60px rgba(0,0,0,.55), 0 0 0 1px var(--border); + border-radius: var(--r-lg); + backdrop-filter: blur(10px) saturate(120%); +} +:root[data-theme="light"] .panel { backdrop-filter: blur(10px) saturate(105%); } +.panel-head { border-top-left-radius: var(--r-lg); border-top-right-radius: var(--r-lg); } + +/* connect card: subtle premium lift + accent top edge */ +.hero-card .card, #connect { + box-shadow: 0 24px 70px rgba(0,0,0,.35), 0 0 0 1px var(--border); + position: relative; overflow: hidden; +} +.hero-card .card::before { + content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; + background: linear-gradient(90deg, var(--accent), #7aa8ff 45%, var(--ok)); + opacity: .9; +} +.primary { + background: linear-gradient(180deg, var(--accent-h), var(--accent)); + box-shadow: 0 6px 18px rgba(61,126,255,.30); + transition: filter .12s, transform .06s, box-shadow .12s; +} +.primary:hover { filter: brightness(1.06); box-shadow: 0 8px 22px rgba(61,126,255,.38); } +.primary:active { transform: translateY(1px); } + +/* state label: pill-ify while live for a pro look */ +.state.ok { color: var(--ok); } + +/* smooth canvas cursor feel */ +#canvas { image-rendering: auto; } + +/* remote cursor overlay (RustDesk-style: the real remote cursor shape) */ +#remoteCursor { + position: absolute; pointer-events: none; z-index: 5; + image-rendering: auto; will-change: left, top; +} +#stage.remote-cursor #canvas { cursor: none; } + +/* ================================================================ + FIX: the live-session screen is ALWAYS dark (the toolbar bg is dark), + so pin every toolbar/panel control color explicitly. Before this, light + theme set --fg to a dark color -> dark icons on the dark toolbar = + "invisible buttons / no disconnect button". + ================================================================ */ +#screenSession { color-scheme: dark; } +#toolbar { color: #e8edf6; } +#toolbar .tb { color: #e8edf6; } +#toolbar .tb:hover { background: #1d2637; border-color: #2c3a55; color: #ffffff; } +#toolbar .tb.active { background: rgba(61,126,255,.16); color: #6aa1ff; border-color: rgba(61,126,255,.5); } +#toolbar .tb.danger { background: rgba(229,72,77,.14); border: 1px solid rgba(229,72,77,.45); color: #f2868b; } +#toolbar .tb.danger:hover { background: rgba(229,72,77,.26); color: #ffb3b6; } +#toolbar .tb-title { color: #e8edf6; } +#toolbar .state { color: #9aa4b9; } +#toolbar .sep { background: #26314a; } +#toolbar select { background: #171d2a; color: #e8edf6; border-color: #26314a; } +#toolbar #tbStats { background: #171d2a; border-color: #202a40; color: #aab4c8; } +#toolbar svg.ic { stroke: currentColor; } + +/* app page responsiveness: stack the connect hero on narrow screens */ +@media (max-width: 980px) { + .hero { flex-direction: column; gap: 22px; padding: 24px 16px; } + .hero-copy h1 { font-size: clamp(26px, 7vw, 40px); } + .hero-card, .hero-card .card { width: 100%; max-width: 520px; } +} + +/* ===== RustDesk-style floating session toolbar (1.5) ===== */ +#screenSession { position: relative; } +#toolbar { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); + width: auto; max-width: calc(100vw - 20px); + border: 1px solid #26314a; border-top: none; + border-radius: 0 0 14px 14px; + background: rgba(13,17,26,.95); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + padding: 6px 12px; justify-content: center; + box-shadow: 0 10px 34px rgba(0,0,0,.5); +} +#toolbar.autohide { transform: translate(-50%, -110%); } +#toolbar.collapsed { transform: translate(-50%, -100%); } +#toolbar.collapsed.autohide { transform: translate(-50%, -110%); } +#stage { position: absolute; inset: 0; } +#tbTab { + position: absolute; top: 100%; left: 50%; transform: translateX(-50%); + width: 52px; height: 17px; border: 1px solid #26314a; border-top: none; + border-radius: 0 0 11px 11px; background: rgba(13,17,26,.95); + color: #9aa4b9; cursor: pointer; font-size: 10px; line-height: 13px; + padding: 0; text-align: center; +} +#tbTab:hover { color: #fff; background: #1d2637; } +@media (max-width: 900px) { #toolbar { max-width: calc(100vw - 8px); padding: 5px 8px; } } + +/* portal dashboard layout: sidebar + main side by side */ +#viewDash { display: flex; flex: 1; min-width: 0; } + +/* ===== TeamViewer-style session menus (1.5) ===== */ +.menu-btn { font-weight: 600; } +.menu-btn.open { background: #1d2637; border-color: #2c3a55; } +.tmenu { + position: absolute; top: 100%; margin-top: 4px; z-index: 60; + min-width: 230px; max-height: 62vh; overflow-y: auto; + background: rgba(13,17,26,.98); border: 1px solid #26314a; border-radius: 10px; + padding: 6px; box-shadow: 0 16px 44px rgba(0,0,0,.55); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); +} +.tmenu .mi { + display: flex; width: 100%; align-items: center; justify-content: space-between; + gap: 14px; background: transparent; border: 0; border-radius: 7px; + color: #dbe3f0; font-size: 12.5px; padding: 7px 10px; cursor: pointer; + text-align: left; font-family: inherit; +} +.tmenu .mi:hover { background: #1d2637; color: #fff; } +.tmenu .mhead { + font-size: 10px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; + color: #6b7891; padding: 8px 10px 4px; +} +.tmenu .msep { height: 1px; background: #222c42; margin: 5px 4px; } +.tmenu .mtick { visibility: hidden; color: #4ade80; font-weight: 700; } +.tmenu .mi.on .mtick { visibility: visible; } + +/* Original (1:1) scaling: real pixels + scrollbars, like TeamViewer's "Original" */ +#stage.orig { overflow: auto; } +#stage.orig #canvas { position: static; inset: auto; width: auto; height: auto; } + +/* ===== premium account modal (1.5) ===== */ +.auth-card.modal-card { + width: 408px; max-width: 94vw; padding: 26px 28px 18px; border-radius: 18px; + background: linear-gradient(180deg, #141b26, #0e131c); + border: 1px solid #27334a; + box-shadow: 0 40px 110px -20px rgba(0,0,0,.85), 0 0 0 1px rgba(255,255,255,.02) inset, + 0 24px 60px -30px rgba(61,126,255,.35); +} +.auth-head { display: flex; align-items: center; gap: 11px; margin-bottom: 18px; } +.auth-logo { + width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0; + background: linear-gradient(180deg, #25d386, #16a765); + display: inline-flex; align-items: center; justify-content: center; + box-shadow: 0 6px 18px rgba(37,211,134,.35); +} +.auth-brand { font-size: 18px; font-weight: 800; color: #e8edf6; letter-spacing: -.3px; } +.auth-brand i { font-style: normal; font-weight: 500; color: #64748b; } +.auth-card .auth-tabs { + display: flex; gap: 0; margin-bottom: 18px; background: #0b0f16; + border: 1px solid #202b3f; border-radius: 11px; padding: 4px; +} +.auth-card .atab { + flex: 1; background: transparent; border: 0; border-radius: 8px; + color: #8b96a6; font-size: 12.5px; font-weight: 600; padding: 8px 4px; + cursor: pointer; transition: color .15s, background .15s; +} +.auth-card .atab:hover { color: #cbd5e1; } +.auth-card .atab.on { + background: linear-gradient(180deg, #3d7eff, #2f66db); color: #fff; + box-shadow: 0 4px 14px rgba(61,126,255,.4); +} +.auth-title { font-size: 19px; font-weight: 750; color: #f1f5fb; margin: 0 0 4px; letter-spacing: -.3px; } +.auth-sub { font-size: 12.5px; color: #7d8a9d; margin: 0 0 12px; line-height: 1.55; } +.auth-lbl { + font-size: 10.5px; font-weight: 700; letter-spacing: 1.1px; text-transform: uppercase; + color: #6b7891; margin: 2px 0 -3px; +} +.auth-card .apane input { + height: 43px; background: #0d1219; border: 1px solid #263241; border-radius: 10px; + color: #e8edf6; font-size: 13.5px; padding: 0 14px; outline: none; + transition: border-color .15s, box-shadow .15s; width: 100%; +} +.auth-card .apane input:focus { + border-color: #3d7eff; box-shadow: 0 0 0 3px rgba(61,126,255,.18); +} +/* Chrome autofill was painting the field WHITE - pin it to the dark theme */ +.auth-card input:-webkit-autofill, +.auth-card input:-webkit-autofill:hover, +.auth-card input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 60px #0d1219 inset !important; + -webkit-text-fill-color: #e8edf6 !important; + caret-color: #e8edf6; + border: 1px solid #263241; +} +.auth-card .auth-cta { + height: 43px; margin-top: 8px; border: 0; border-radius: 10px; cursor: pointer; + background: linear-gradient(180deg, #4d8dff, #2f6ae0); color: #fff; + font-size: 14px; font-weight: 700; letter-spacing: .2px; + box-shadow: 0 10px 26px -8px rgba(61,126,255,.55); + transition: transform .12s, box-shadow .15s, filter .15s; +} +.auth-card .auth-cta:hover { filter: brightness(1.08); transform: translateY(-1px); + box-shadow: 0 14px 30px -8px rgba(61,126,255,.65); } +.auth-card .auth-cta:active { transform: translateY(0); } +.auth-card .foot { + font-size: 11.5px; color: #6b7891; line-height: 1.55; margin-top: 10px; + background: rgba(61,126,255,.06); border: 1px solid rgba(61,126,255,.14); + border-radius: 9px; padding: 9px 12px; +} +.auth-secure { + margin-top: 16px; padding-top: 12px; border-top: 1px solid #1c2534; + font-size: 10.5px; color: #56637a; text-align: center; letter-spacing: .2px; +} +.auth-card .modal-x { top: 14px; right: 14px; width: 30px; height: 30px; border-radius: 8px; + color: #6b7891; font-size: 13px; transition: background .15s, color .15s; } +.auth-card .modal-x:hover { background: #1b2434; color: #fff; } +.auth-card .error, .auth-card .auok { margin-top: 12px; } + +/* ===== docked slim menu-bar toolbar (TeamViewer style, 1.5.1) ===== + The bar sits ABOVE the video in normal flow - it never covers the screen. + ▲ collapses it to a tiny top-center tab; ▼ brings it back. */ +#screenSession { display: flex; flex-direction: column; position: relative; } +#toolbar { + position: relative; top: auto; left: auto; transform: none; + width: 100%; max-width: none; + border: 0; border-bottom: 1px solid #232e45; border-radius: 0; + background: linear-gradient(180deg, #101623, #0c111b); + backdrop-filter: none; -webkit-backdrop-filter: none; + padding: 4px 10px; min-height: 42px; + flex-wrap: nowrap; justify-content: flex-start; gap: 6px; + box-shadow: 0 4px 18px rgba(0,0,0,.35); +} +#toolbar.autohide, #toolbar.collapsed.autohide { transform: none; } +#toolbar.collapsed { display: none; } +#stage { position: relative; inset: auto; flex: 1; min-height: 0; } + +/* single-row discipline: groups never wrap; the stats text yields first */ +#toolbar .tb-group { flex-wrap: nowrap; flex-shrink: 0; } +#toolbar .tb-group:last-of-type { flex-shrink: 1; min-width: 0; margin-left: auto; } +#tbStats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 11px; } +.tb-title { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 12.5px; } +#toolbar .tb { height: 29px; padding: 0 8px; font-size: 12px; } +#toolbar select { height: 29px; font-size: 11.5px; max-width: 160px; } +#toolbar .menu-btn { padding: 0 10px; } +#toolbar .sep { height: 18px; margin: 0 2px; } + +/* the ▲ collapse control lives IN the bar, far right */ +#toolbar #tbTab { + position: static; top: auto; left: auto; transform: none; + width: 30px; height: 29px; flex-shrink: 0; margin-left: 2px; + border: 1px solid #26314a; border-radius: 7px; + background: transparent; color: #9aa4b9; font-size: 10px; line-height: 1; +} +#toolbar #tbTab:hover { background: #1d2637; color: #fff; } + +/* tiny reopen tab when collapsed - hangs at top-center, TeamViewer style */ +#tbShow { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); z-index: 55; + height: 22px; padding: 0 14px; border: 1px solid #26314a; border-top: 0; + border-radius: 0 0 12px 12px; background: rgba(13,17,26,.94); + color: #9aa4b9; font-size: 11px; font-weight: 600; cursor: pointer; + box-shadow: 0 8px 22px rgba(0,0,0,.45); +} +#tbShow:hover { color: #fff; background: #1a2334; } +#tbShow[hidden] { display: none; } + +/* progressive slimming on narrow windows - never a second row */ +@media (max-width: 1300px) { #tbState { display: none; } } +@media (max-width: 1180px) { #selQuality { display: none; } .tb-title { max-width: 110px; } } +@media (max-width: 1020px) { #btnViewOnly span, #btnKbdLock span { display: none; } + #toolbar .menu-btn { padding: 0 7px; font-size: 11.5px; } } +@media (max-width: 880px) { #tbStats { display: none; } #selMonitor { max-width: 120px; } } + +/* ===== touch / mobile support (1.5.1) ===== */ +/* without touch-action:none the browser pans/zooms instead of sending + pointer events - THE reason touch "didn't work" on tablets/phones */ +#canvas { touch-action: none; } +#stage { touch-action: none; } +.touch-only { display: none !important; } +@media (pointer: coarse) { + .touch-only { display: inline-flex !important; } +} +/* invisible input that summons the phone/tablet on-screen keyboard */ +#oskInput { + position: fixed; bottom: 0; left: 0; width: 2px; height: 2px; + opacity: 0.01; border: 0; padding: 0; background: transparent; color: transparent; + pointer-events: none; z-index: -1; +} + +/* ===== 3D polish for the docked control bar (1.5.2) ===== */ +#toolbar { + min-height: 46px; padding: 6px 12px; gap: 8px; + background: linear-gradient(180deg, #1b2434 0%, #141b29 45%, #0e141f 100%); + border-bottom: 1px solid #05080f; + box-shadow: inset 0 1px 0 rgba(255,255,255,.07), + inset 0 -1px 0 rgba(255,255,255,.02), + 0 8px 26px rgba(0,0,0,.55); +} +#toolbar .tb-group { gap: 6px; } + +/* raised 3D buttons with a real pressed state */ +#toolbar .tb { + height: 31px; padding: 0 10px; border-radius: 8px; + background: linear-gradient(180deg, #253044, #1a2333); + border: 1px solid #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.09), 0 1px 3px rgba(0,0,0,.45); + color: #c9d4e3; + transition: background .12s, box-shadow .12s, transform .06s, color .12s; +} +#toolbar .tb:hover { + background: linear-gradient(180deg, #2d3a54, #222d42); + color: #fff; border-color: #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.13), 0 3px 8px rgba(0,0,0,.5); +} +#toolbar .tb:active { transform: translateY(1px); + background: #18202f; box-shadow: inset 0 2px 6px rgba(0,0,0,.55); } +#toolbar .tb.active { + background: linear-gradient(180deg, #2d5290, #234070); + border-color: #142643; color: #d9e7ff; + box-shadow: inset 0 1px 0 rgba(255,255,255,.16), inset 0 0 12px rgba(61,126,255,.3); +} +#toolbar .tb.danger { + background: linear-gradient(180deg, #93323a, #701f26); + border-color: #400f13; color: #ffdcde; + box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 2px 10px rgba(229,72,77,.35); +} +#toolbar .tb.danger:hover { + background: linear-gradient(180deg, #aa3b45, #86262e); color: #fff; +} + +/* Actions / View / Extras behave like real app menus: flat, raise on hover */ +#toolbar .menu-btn { + background: transparent; border-color: transparent; box-shadow: none; + font-weight: 650; color: #d5dde9; +} +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: linear-gradient(180deg, #2b3852, #202b3f); + border-color: #0b111d; + box-shadow: inset 0 1px 0 rgba(255,255,255,.11), 0 2px 6px rgba(0,0,0,.45); +} + +/* selects match the 3D buttons, custom chevron */ +#toolbar select { + height: 31px; border-radius: 8px; padding: 0 26px 0 11px; + -webkit-appearance: none; appearance: none; + background: linear-gradient(180deg, #253044, #1a2333) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238b96a6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") + no-repeat right 9px center; + border: 1px solid #0b111d; color: #c9d4e3; + box-shadow: inset 0 1px 0 rgba(255,255,255,.09), 0 1px 3px rgba(0,0,0,.45); + cursor: pointer; +} +#toolbar select:hover { color: #fff; + background-image: linear-gradient(180deg, #2d3a54, #222d42), + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23cfd8e6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } + +/* engraved separators */ +#toolbar .sep { + width: 1px; height: 24px; margin: 0 3px; background: + linear-gradient(180deg, transparent, #05080f 25%, #05080f 75%, transparent); + box-shadow: 1px 0 0 rgba(255,255,255,.05); +} + +/* title + live pill + inset stats chip */ +#toolbar .tb-title { font-weight: 700; text-shadow: 0 1px 2px rgba(0,0,0,.6); } +#tbState { + background: #0c1320; border: 1px solid #080d17; border-radius: 999px; + padding: 3px 11px; box-shadow: inset 0 1px 3px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.04); +} +#tbStats { + background: #0b101b; border: 1px solid #070c15; border-radius: 8px; + padding: 6px 11px; + box-shadow: inset 0 1px 3px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.04); +} + +/* dropdown menus + reopen tab share the depth */ +.tmenu { + background: linear-gradient(180deg, #171f2f, #0f1522); + border-color: #0b111d; + box-shadow: 0 20px 54px rgba(0,0,0,.65), inset 0 1px 0 rgba(255,255,255,.06); +} +#tbShow { + background: linear-gradient(180deg, #1b2434, #10161f); + border-color: #0b111d; + box-shadow: 0 10px 26px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08); +} + +/* single-row overflow guards: Disconnect and the collapse tab must NEVER clip; + the stats chip yields first, then labels, then stats hides entirely */ +#toolbar .tb-group:last-of-type > .tb, #toolbar #tbTab { flex-shrink: 0; } +#tbStats { flex: 0 1 auto; max-width: 300px; } +#toolbar .menu-btn { padding: 0 8px; } +#toolbar .tb-title { max-width: 128px; } +#toolbar select { max-width: 150px; } +@media (max-width: 1520px) { + #btnViewOnly span, #btnKbdLock span { display: none; } +} +@media (max-width: 1400px) { #tbStats { display: none; } } +@media (max-width: 1080px) { #btnDisconnect span { display: none; } #tbState { display: none; } } + +/* ===== control bar: TeamViewer-grade flat premium (1.5.3, final pass) ===== + Calm flat surface, ghost controls that light up on hover, one bold red + Disconnect pill. Overrides the heavier 3D pass above. */ +#toolbar { + min-height: 50px; padding: 8px 14px; gap: 10px; + background: linear-gradient(180deg, #121926, #0d1219); + border-bottom: 1px solid rgba(255,255,255,.06); + box-shadow: 0 10px 30px rgba(0,0,0,.45); +} +#toolbar .tb-group { gap: 4px; } + +/* ghost buttons: quiet until you need them */ +#toolbar .tb { + height: 34px; min-width: 34px; padding: 0 11px; border-radius: 9px; + background: transparent; border: 1px solid transparent; box-shadow: none; + color: #b9c6d8; font-size: 12.5px; justify-content: center; +} +#toolbar .tb:hover { + background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.06); + color: #fff; box-shadow: none; +} +#toolbar .tb:active { transform: none; background: rgba(0,0,0,.35); + box-shadow: inset 0 2px 5px rgba(0,0,0,.45); } +#toolbar .tb.active { + background: rgba(61,126,255,.16); border-color: rgba(61,126,255,.4); + color: #7fb0ff; box-shadow: none; +} +#toolbar .menu-btn { font-weight: 650; color: #dbe3ee; padding: 0 12px; + background: transparent; border-color: transparent; box-shadow: none; } +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.06); box-shadow: none; } + +/* selects as quiet inset wells */ +#toolbar select { + height: 34px; border-radius: 9px; padding: 0 28px 0 12px; + background-color: rgba(255,255,255,.05); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238b96a6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; + border: 1px solid rgba(255,255,255,.09); color: #c6d2e2; box-shadow: none; +} +#toolbar select:hover { background-color: rgba(255,255,255,.09); color: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23cfd8e6' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } + +/* hairline separators */ +#toolbar .sep { width: 1px; height: 22px; margin: 0 6px; + background: rgba(255,255,255,.08); box-shadow: none; } + +/* identity cluster: bold name + breathing live dot, no boxes */ +#toolbar .tb-title { font-size: 13px; font-weight: 700; color: #eef3fa; text-shadow: none; } +#tbState { background: transparent; border: 0; box-shadow: none; padding: 0 4px; + font-size: 12px; color: #8ea3bd; } +#tbState.ok::before { animation: livepulse 2.2s ease-in-out infinite; } +@keyframes livepulse { 0%,100% { box-shadow: 0 0 4px var(--ok); } 50% { box-shadow: 0 0 11px var(--ok); } } + +/* stats: quiet telemetry text, no box */ +#tbStats { background: transparent; border: 0; box-shadow: none; padding: 0 6px; + font-size: 11.5px; color: #7f93ad; letter-spacing: .2px; } + +/* THE red pill - always fully visible, unmistakable */ +#toolbar .tb.danger { + height: 34px; padding: 0 18px; border-radius: 999px; flex-shrink: 0; + background: linear-gradient(180deg, #ef5158, #d63a41); + border: 1px solid #b02a30; color: #fff; font-weight: 650; + box-shadow: 0 4px 16px rgba(229,72,77,.4), inset 0 1px 0 rgba(255,255,255,.22); +} +#toolbar .tb.danger:hover { + background: linear-gradient(180deg, #ff5f66, #e2444b); + box-shadow: 0 6px 20px rgba(229,72,77,.55), inset 0 1px 0 rgba(255,255,255,.25); + color: #fff; +} +#toolbar #tbTab { width: 34px; height: 34px; border-radius: 9px; + border: 1px solid transparent; background: transparent; color: #8ea3bd; } +#toolbar #tbTab:hover { background: rgba(255,255,255,.08); color: #fff; } + +/* stats only when there is truly room; never a crushed "1920×…" fragment */ +@media (max-width: 1620px) { #tbStats { display: none; } } +#toolbar .tb-title { max-width: 175px; } + +/* exact TeamViewer right-edge order: … [icons] | ▲ collapse | [red end button] */ +#toolbar #tbTab { order: 8; margin-left: auto; } +#toolbar .tb-group:last-of-type { order: 9; margin-left: 0; } +#toolbar { padding-right: 10px; } + +/* ===== TeamViewer light skin for the session bar (reference-matched) ===== */ +#toolbar { + margin: 8px 10px 0; border-radius: 11px; width: auto; + min-height: 46px; padding: 5px 10px; + background: linear-gradient(180deg, #ffffff, #f2f4f7); + border: 1px solid #d8dde4; border-bottom: 1px solid #c9cfd8; + box-shadow: 0 1px 0 rgba(255,255,255,.9) inset, + 0 10px 28px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25); +} +#toolbar .tb { + color: #3d4756; background: transparent; border: 1px solid transparent; + height: 34px; border-radius: 8px; +} +#toolbar .tb:hover { background: #e8ecf1; border-color: #d6dbe2; color: #1d2530; + box-shadow: 0 1px 0 rgba(255,255,255,.8) inset; } +#toolbar .tb:active { background: #dde2e9; box-shadow: inset 0 2px 4px rgba(0,0,0,.12); } +#toolbar .tb.active { background: #e3edff; border-color: #bdd3f5; color: #1e64d8; box-shadow: none; } +#toolbar .menu-btn { color: #2c3644; font-weight: 600; } +#toolbar .menu-btn:hover, #toolbar .menu-btn.open { + background: #e8ecf1; border-color: #d6dbe2; box-shadow: none; } +#toolbar select { + background-color: #fbfcfd; color: #2c3644; border: 1px solid #ccd3dc; + box-shadow: inset 0 1px 2px rgba(0,0,0,.05); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%235b6878' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); +} +#toolbar select:hover { background-color: #fff; color: #10151d; border-color: #b9c2cd; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%232c3644' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); } +#toolbar .sep { background: #d8dde4; box-shadow: 1px 0 0 #ffffff; height: 24px; } +#toolbar .tb-title { color: #16202e; } +#tbState { color: #5b6878; } +#tbStats { color: #6a7686; } + +/* the red end button: compact, filled, unmistakable - like the reference */ +#toolbar .tb.danger { + padding: 0 12px; border-radius: 8px; + background: linear-gradient(180deg, #ee4048, #d92730); + border: 1px solid #b21e26; color: #fff; + box-shadow: 0 1px 0 rgba(255,255,255,.3) inset, 0 3px 10px rgba(217,39,48,.4); +} +#toolbar .tb.danger:hover { background: linear-gradient(180deg, #f9525a, #e63039); } +#toolbar #tbTab { color: #5b6878; } +#toolbar #tbTab:hover { background: #e8ecf1; color: #1d2530; } + +/* dropdown menus + info panel go light to match */ +.tmenu { + background: #ffffff; border: 1px solid #d8dde4; + box-shadow: 0 16px 44px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.15); +} +.tmenu .mi { color: #2c3644; } +.tmenu .mi:hover { background: #eef2f7; color: #10151d; } +.tmenu .mhead { color: #7c8898; } +.tmenu .msep { background: #e5e9ee; } +.tmenu .mtick { color: #1e64d8; } + +/* reopen tab matches the light bar */ +#tbShow { + background: linear-gradient(180deg, #ffffff, #eef1f5); + border: 1px solid #ccd3dc; border-top: 0; color: #3d4756; + box-shadow: 0 8px 22px rgba(0,0,0,.35); +} +#tbShow:hover { background: #fff; color: #10151d; } + +/* ===== reference-exact: airy sparse bar, menus-first (v41) ===== */ +/* identity + dropdown boxes leave the bar - like the reference image */ +#toolbar .tb-title, #tbState, #selMonitor, #selQuality { display: none !important; } +/* icon-only everywhere */ +#btnViewOnly span, #btnKbdLock span, #btnDisconnect span { display: none !important; } +/* generous breathing room between groups */ +#toolbar { gap: 16px; padding: 6px 12px; } +#toolbar .tb-group { gap: 10px; } +#toolbar .sep { margin: 0 6px; } +#toolbar .menu-btn { padding: 0 13px; font-size: 13px; } +/* compact square-ish red end button, icon only */ +#toolbar .tb.danger { padding: 0 12px; min-width: 40px; } +#toolbar .tb.danger svg { width: 15px; height: 15px; } +/* menus can hold the new Monitor / Quality sections comfortably */ +.tmenu { min-width: 250px; } + +/* monitor cycle label + disabled menu items (v44) */ +#monCycleLbl { font-size: 11px; font-weight: 700; margin-left: 5px; letter-spacing: .3px; } +.tmenu .mi[disabled] { opacity: .45; cursor: default; } +.tmenu .mi[disabled]:hover { background: transparent; color: #2c3644; } + +/* iOS: no long-press callout / text-selection bubbles over the session */ +#stage, #canvas, #toolbar, .tmenu { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +/* ===== mobile session layout (phones) ===== */ +.tmenu { position: fixed; } /* JS sets top/left, clamped to the viewport */ +@media (max-width: 700px) { + #toolbar { + min-height: 44px; margin: 6px 6px 0; padding: 5px 6px; gap: 5px; + border-radius: 10px; + overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; + } + #toolbar::-webkit-scrollbar { display: none; } + #toolbar .tb { height: 36px; min-width: 36px; } + #toolbar .menu-btn { padding: 0 10px; font-size: 12.5px; } + /* secondary controls live inside the menus on phones - keep the bar clean */ + #btnViewOnly, #btnKbdLock, #btnClipboard, #btnPrivacy, #btnRec, + #btnSys, #btnChat, #btnFiles, #toolbar .sep { display: none !important; } + .tmenu { max-height: 68vh; overflow-y: auto; min-width: 230px; max-width: 92vw; } + #tbShow { height: 26px; padding: 0 16px; font-size: 12px; } +} + +/* phones: consolidate to the essentials - everything else stays in menus */ +@media (max-width: 700px) { + .menu-btn[data-menu="mComm"] { display: none !important; } /* chat lives in Extras */ + #btnFullscreen { display: none !important; } /* no Fullscreen API on iPhone */ + #btnOsk span { display: none !important; } + #monCycleLbl { font-size: 10px; margin-left: 3px; } + #toolbar { gap: 3px; } + #toolbar .menu-btn { padding: 0 8px; font-size: 12px; } + #toolbar .tb.danger { min-width: 38px; padding: 0 10px; } +} + +/* inside the desktop app's viewer window: no website chrome */ +body.embed .nav-links, body.embed a.btn-ghost[href="/portal"] { display: none !important; } + +/* ========================================================================= + ELEVATION LAYER v2 — glass, gradient accents, refined depth. Matches the + marketing site's elevation pass. Component-scoped overrides only; the base + design above is untouched so behaviour/layout stay identical. + ====================================================================== */ +:root{ + --accent-grad: linear-gradient(135deg,#4d8bff,#6aa1ff 44%,#34d399); + --glass: rgba(18,24,36,.72); + --hair: rgba(255,255,255,.07); + --hair-strong: rgba(255,255,255,.13); + --shadow-lg: 0 24px 70px -18px rgba(0,0,0,.72); + --ring: 0 0 0 3px rgba(77,139,255,.26); +} +:root[data-theme="light"]{ + --glass: rgba(255,255,255,.76); + --hair: rgba(16,22,34,.08); + --hair-strong: rgba(16,22,34,.14); + --shadow-lg: 0 24px 70px -22px rgba(23,32,47,.22); + --ring: 0 0 0 3px rgba(61,126,255,.18); +} + +/* primary buttons: gradient fill + sheen sweep (sheen sits behind the text) */ +button.primary, .btn-blue{ + position:relative; overflow:hidden; z-index:0; + background:var(--accent-grad) !important; background-size:160% 160% !important; + box-shadow:0 8px 22px -6px rgba(61,126,255,.5), inset 0 1px 0 rgba(255,255,255,.22) !important; +} +button.primary::before, .btn-blue::before{ + content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; + background:linear-gradient(100deg,transparent 22%,rgba(255,255,255,.4) 50%,transparent 78%); + transform:translateX(-120%); transition:transform .6s ease; +} +button.primary:hover::before, .btn-blue:hover::before{ transform:translateX(120%); } +button.primary:hover, .btn-blue:hover{ filter:brightness(1.05); transform:translateY(-1px); } + +/* toolbar: frosted glass + a gradient hairline underline */ +#toolbar{ + position:relative; + background:var(--glass) !important; + backdrop-filter:blur(16px) saturate(1.3); -webkit-backdrop-filter:blur(16px) saturate(1.3); + border-bottom:1px solid var(--hair) !important; + box-shadow:0 1px 0 var(--hair) inset, 0 10px 26px -14px rgba(0,0,0,.55) !important; +} +#toolbar::after{ + content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px; pointer-events:none; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.55),rgba(52,211,153,.4),transparent); + opacity:.75; +} + +/* connect card + modals: gradient hairline border + deeper shadow */ +.card, .modal-card{ position:relative; border-color:var(--hair-strong) !important; box-shadow:var(--shadow-lg) !important; } +.card::before, .modal-card::before{ + content:""; position:absolute; inset:0; border-radius:inherit; padding:1px; pointer-events:none; opacity:.85; + background:linear-gradient(135deg,rgba(77,139,255,.5),transparent 42%,transparent 58%,rgba(52,211,153,.32)); + -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0); + -webkit-mask-composite:xor; mask-composite:exclude; +} +.modal-wrap{ background:rgba(5,7,12,.62) !important; backdrop-filter:blur(9px) saturate(1.2) !important; -webkit-backdrop-filter:blur(9px) saturate(1.2) !important; } + +/* inputs: softer gradient-tinted focus ring */ +input:focus{ box-shadow:var(--ring) !important; } + +/* active states get the gradient tint */ +.atab.on{ background:linear-gradient(135deg,rgba(77,139,255,.22),rgba(52,211,153,.12)) !important; border-color:rgba(77,139,255,.5) !important; color:#bcd4ff !important; } +#toolbar .tb.active{ background:linear-gradient(135deg,rgba(77,139,255,.24),rgba(52,211,153,.12)) !important; border-color:rgba(77,139,255,.5) !important; } + +/* operator-console (portal) cards: refined hairline + depth */ +.p-card{ border-color:var(--hair-strong) !important; box-shadow:0 1px 2px rgba(0,0,0,.22), 0 12px 30px -16px rgba(0,0,0,.55) !important; } +:root[data-theme="light"] .p-card{ box-shadow:0 1px 2px rgba(23,32,47,.06), 0 12px 30px -18px rgba(23,32,47,.18) !important; } diff --git a/server/web/app.html b/server/web/app.html new file mode 100644 index 0000000..6938f04 --- /dev/null +++ b/server/web/app.html @@ -0,0 +1,351 @@ + + + + + +NetDesk — The Fast Self-Hosted Remote Desktop + + + + + + + + +
+
ⓘ netdesk.space is the only official domain for this NetDesk server. No third-party cloud ever sees your screen.
+ + + +
+
+

The Fast Self-Hosted
Remote Desktop

+

Your own remote access and support software — unattended, silent in the + background, end-to-end encrypted, with your own server. The alternative to TeamViewer, + AnyDesk and RustDesk that you fully control.

+ +
+ +
+
+

Connect to a computer

+

Enter the remote ID shown by the NetDesk agent.

+ + + +
+ + +
+ + +
+ Advanced — direct connection + + +
+ + + + + + +
+
— OR —
+

Share THIS screen

+

Let someone view this computer right from their browser — no app to install.
(View-only: they can see & hear it, not control it.)

+
ID —  ·  Password —
+ + +
+
+ +
+
Address book
+ +
+
+ Direct connection (no server): put ws://IP:8901 in the Server + field when the host runs with --listen on your LAN or via port-forward. +
+
+ NetDesk viewer + + + +
+
+
+
+ + + +
+ + + + + + + + + diff --git a/server/web/app.js b/server/web/app.js new file mode 100644 index 0000000..16bef3b --- /dev/null +++ b/server/web/app.js @@ -0,0 +1,2995 @@ +/* NetDesk web viewer - WebCodecs H.264 decode, E2E (AES-GCM), input, files, chat. */ +"use strict"; + +const $ = (s) => document.querySelector(s); +window.__ndErr = []; +window.addEventListener("error", (e) => window.__ndErr.push("err: " + e.message)); +window.addEventListener("unhandledrejection", (e) => window.__ndErr.push("rej: " + e.reason)); + +/* ------------------------------------------------------------- utils */ +const enc = new TextEncoder(); +const dec = new TextDecoder(); +function toHex(buf) { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} +function b64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +async function sha256Hex(str) { + return toHex(await crypto.subtle.digest("SHA-256", enc.encode(str))); +} +function fmtBytes(n) { + if (n > 1e9) return (n / 1e9).toFixed(2) + " GB"; + if (n > 1e6) return (n / 1e6).toFixed(1) + " MB"; + if (n > 1e3) return (n / 1e3).toFixed(1) + " KB"; + return n + " B"; +} +function now() { return performance.now(); } + +// Resolution stays NATIVE on every rung (crisp text, like TeamViewer): +// a bad link costs fps and bitrate, never sharpness. Scale changes also +// force an encoder rebuild + keyframe - that was the visible "break". +const QUALITY = { + min: { fps: 8, bitrate: 700, scale: 1.0 }, // very slow links: still sharp, fewer frames + low: { fps: 15, bitrate: 2500, scale: 1.0 }, + balanced: { fps: 30, bitrate: 6000, scale: 1.0 }, + high: { fps: 30, bitrate: 12000, scale: 1.0 }, + extreme: { fps: 60, bitrate: 20000, scale: 1.0 }, +}; + +/* ------------------------------------------------------------- state */ +const S = { + ws: null, + sid: null, + direct: false, + serverUrl: "", + hostId: "", + viewOnly: false, + dims: { w: 0, h: 0 }, + monitors: [], + e2e: false, + e2eKey: null, // CryptoKey AES-GCM once derived + decoder: null, + audioDecoder: null, + audioCtx: null, + sps: null, + pps: null, + started: false, // got first keyframe + latestFrame: null, + drawing: false, + bytesVideo: 0, + framesDecoded: 0, + fpsShown: 0, + lastFpsTick: now(), + rtt: 0, + buttons: 0, + pendingMouse: null, + clipSync: false, + lastRemoteClip: "", + transfers: Object.create(null), // fid -> download state + nextFid: 1, + chatOpen: false, + kbdLocked: false, + dead: false, + _timers: [], + heldKeys: new Set(), + curShapes: {}, curId: null, curPos: null, +}; +let decodeQueue = Promise.resolve(); // preserve chunk order across async decrypt + +/* ------------------------------------------------------------- connect screen */ +$("#btnPwEye").onclick = () => { + const i = $("#inPw"); + i.type = i.type === "password" ? "text" : "password"; +}; +$("#inServer").value = localStorage.getItem("nd.server") || ""; +$("#inId").value = localStorage.getItem("nd.id") || ""; +if (localStorage.getItem("nd.remember") === "1") { + $("#inRemember").checked = true; + $("#inPw").value = localStorage.getItem("nd.pw") || ""; +} +// deep link from the portal: /?id=123456789[&server=...] +{ + const qs = new URLSearchParams(location.search); + const qid = (qs.get("id") || "").replace(/\D/g, ""); + // opened inside the desktop app's own viewer window (no site chrome there) + if (qs.get("embed")) document.body.classList.add("embed"); + if (qid) { + $("#inId").value = qid; + if (qs.get("server")) $("#inServer").value = qs.get("server"); + setTimeout(() => $("#inPw").focus(), 100); + } + // Reload while a session was live (and Disconnect was never clicked): + // resume it automatically - with the remembered password it reconnects by + // itself; otherwise one keystroke away. + try { + const rs = JSON.parse(localStorage.getItem("nd.resume") || "null"); + if (!qid && rs && rs.id && Date.now() - rs.t < 12 * 3600e3) { + $("#inId").value = rs.id; + if ($("#inPw").value) { + connectErr("You were in a session — resuming…"); + setTimeout(() => connect(), 300); + } else { + connectErr("You were in a session — enter the password to resume."); + setTimeout(() => $("#inPw").focus(), 150); + } + } + } catch {} +} + +$("#btnConnect").onclick = () => connect(); +$("#inPw").addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); }); +$("#inId").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#inPw").focus(); }); + +/* ------------------------------------------------------------- viewer account */ +const QS = new URLSearchParams(location.search); +const URLTOKEN = QS.get("reset"); +const VERIFIED = QS.get("verified") === "1"; +let myEmail = null; + +async function refreshAuth() { + try { + const r = await fetch("/api/auth/me", { credentials: "same-origin" }); + if (r.ok) { const d = await r.json(); setSignedIn(d.email, d.owner); } + else setSignedOut(); + } catch { setSignedOut(); } +} +async function signOut() { + await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); + window.__isOwner = false; + setSignedOut(); +} +function renderNavAuth() { + const nav = $("#navAuth"); + if (nav) { + nav.innerHTML = ""; + if (myEmail) { + const t = document.createElement("span"); + t.className = "nav-auth-email"; + t.textContent = (window.__isOwner ? "⭐ " : "") + myEmail; + const b = document.createElement("button"); + b.className = "nav-auth-btn"; + b.textContent = "Sign out"; + b.onclick = signOut; + nav.append(t, b); + } else { + const b = document.createElement("button"); + b.className = "nav-auth-btn"; + b.textContent = "Sign in / Sign up"; + b.onclick = () => openAuth("in"); + nav.append(b); + } + } + const fb = $("#btnAuthOpen"); + if (fb) { + fb.textContent = myEmail ? "Sign out" : "Sign in / Sign up"; + fb.onclick = myEmail ? signOut : () => openAuth("in"); + } +} +function setSignedIn(email, owner) { + myEmail = email; + window.__isOwner = !!owner; + renderNavAuth(); + document.querySelectorAll("#authChip").forEach((c) => { + c.hidden = false; c.textContent = email; + c.style.cursor = "pointer"; c.title = "Account & two-factor"; + c.onclick = () => openAuth("2fa"); + }); + const a2 = $("#atab2fa"); if (a2) a2.hidden = false; + loadDevices(); +} +function setSignedOut() { + myEmail = null; + const a2 = $("#atab2fa"); if (a2) a2.hidden = true; + renderNavAuth(); + document.querySelectorAll("#authChip").forEach((c) => { c.hidden = true; }); + const w = $("#myDevsWrap"); + if (w) w.hidden = true; +} + +/* my computers: devices bound to this account from the desktop app */ +async function loadDevices() { + const wrap = $("#myDevsWrap"), box = $("#myDevs"); + if (!wrap || !myEmail) return; + let list = []; + try { + const r = await fetch("/api/devices"); + if (!r.ok) return; + list = (await r.json()).devices || []; + } catch { return; } + wrap.hidden = false; + box.innerHTML = ""; + if (!list.length) { + const e = document.createElement("div"); + e.className = "foot"; + e.textContent = "No computers yet — sign in with this account in the NetDesk app on a PC and it shows up here."; + box.append(e); + return; + } + for (const d of list) { + const row = document.createElement("div"); + row.className = "ab-row"; + const dot = document.createElement("span"); + dot.className = "dev-dot" + (d.online ? (d.busy ? " busy" : " on") : ""); + dot.title = d.online ? (d.busy ? "in a session" : "online") : "offline"; + const lbl = document.createElement("span"); + lbl.className = "ab-label"; + lbl.textContent = d.name; + if (d.shared) { + const sh = document.createElement("span"); + sh.className = "ab-shared"; + sh.textContent = "shared" + (d.team ? " · " + d.team : ""); + sh.title = "Shared with you via team" + (d.team ? " “" + d.team + "”" : "") + (d.owner ? " by " + d.owner : ""); + lbl.append(" ", sh); + } + const id = document.createElement("span"); + id.className = "ab-id"; + id.textContent = d.id; + row.append(dot, lbl, id); + if (!d.shared) { + // only devices you own can be removed from here; team-shared ones are managed by their owner + const del = document.createElement("button"); + del.className = "tb"; + del.textContent = "🗑"; + del.title = "Remove from my computers"; + del.onclick = async (ev) => { + ev.stopPropagation(); + if (!confirm("Remove " + d.name + " from your computers?")) return; + await fetch("/api/devices/remove", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: d.id }) }); + loadDevices(); + }; + row.append(del); + } + row.onclick = () => { $("#inId").value = d.id; $("#inPw").focus(); }; + box.append(row); + } +} +setInterval(() => { if (myEmail && $("#screenSession").hidden) loadDevices(); }, 15000); + +function authMsg(msg, ok) { + const m = $("#auMsg"), o = $("#auOk"); + o.hidden = true; + m.hidden = !msg; + m.textContent = msg || ""; + if (ok) { m.hidden = true; o.hidden = false; o.textContent = msg; } +} +function showAuthPane(t) { + for (const id of ["in", "up", "fp", "rs", "2fa"]) { const p = $("#pane-" + id); if (p) p.hidden = id !== t; } + document.querySelectorAll(".atab").forEach(b => b.classList.toggle("on", b.dataset.t === t)); + $("#auMsg").hidden = true; $("#auOk").hidden = true; + if (t === "2fa") tf2Refresh(); +} +function openAuth(defaultTab) { + $("#authModal").hidden = false; + showAuthPane(defaultTab || "in"); +} +$("#btnAuthClose").onclick = () => { $("#authModal").hidden = true; }; +async function authPost(path, body, okMsg, closeAfter) { + let r, d; + try { + r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) }); + d = await r.json().catch(() => ({})); + } catch { authMsg("Server unreachable"); return false; } + if (!r.ok) { authMsg(d.error || "failed"); return false; } + if (d.email) setSignedIn(d.email, d.owner); + authMsg(okMsg || d.msg || "done", true); + if (closeAfter) setTimeout(() => { $("#authModal").hidden = true; }, 1200); + return true; +} +document.querySelectorAll(".atab").forEach(b => b.onclick = () => showAuthPane(b.dataset.t)); +const onEnter = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("keydown", (e) => { if (e.key === "Enter") fn(); }); }; +const doLogin = async () => { + const body = { email: $("#auEmail").value, password: $("#auPass").value }; + const totp = ($("#auTotp") && $("#auTotp").value || "").trim(); + if (totp) body.totp = totp; + let r, d; + try { + r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) }); + d = await r.json().catch(() => ({})); + } catch { authMsg("Server unreachable"); return; } + if (!r.ok) { + if (d.twofa) { + $("#auTotpLbl").hidden = false; $("#auTotp").hidden = false; $("#auTotp").focus(); + authMsg(totp ? "Invalid 2FA code — try again." : "Enter your 6-digit authenticator code."); + } else { authMsg(d.error || "failed"); } + return; + } + if (d.email) setSignedIn(d.email, d.owner); + authMsg("Signed in.", true); + $("#auTotp").value = ""; $("#auTotpLbl").hidden = true; $("#auTotp").hidden = true; + setTimeout(() => { $("#authModal").hidden = true; }, 1200); +}; +const doSignup = () => authPost("/api/auth/signup", + { email: $("#auEmail2").value, password: $("#auPass2").value }, + "Account created — you are signed in.", true); +const doForgot = () => authPost("/api/auth/forgot", + { email: $("#auEmail3").value }, "If that account exists, a reset email was sent.", false); +const doReset = async () => { + if (await authPost("/api/auth/reset", { token: URLTOKEN, password: $("#auPass4").value }, + "Password changed — sign in with the new password.", false)) { + history.replaceState(null, "", "/"); + setTimeout(() => showAuthPane("in"), 1400); + } +}; +$("#auGo").onclick = doLogin; onEnter("#auPass", doLogin); +$("#auGo2").onclick = doSignup; onEnter("#auPass2", doSignup); +$("#auGo3").onclick = doForgot; +$("#auGo4").onclick = doReset; onEnter("#auPass4", doReset); + +/* ----------------------------------------------- account 2FA (TOTP) */ +let tf2Secret = ""; +async function tf2Refresh() { + tf2Secret = ""; + const st = $("#tf2Status"), en = $("#tf2Enable"), enr = $("#tf2Enroll"), dis = $("#tf2DisableWrap"); + if (enr) enr.hidden = true; if (dis) dis.hidden = true; if (en) en.hidden = true; + if (!myEmail) { if (st) st.textContent = "Sign in first."; return; } + try { + const r = await fetch("/api/auth/2fa/status", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (d.enabled) { st.textContent = "✅ Two-factor is ON for this account."; dis.hidden = false; } + else { st.textContent = "Two-factor is off — enable it to require a code at sign-in."; en.hidden = false; } + } catch { st.textContent = "Could not load 2FA status."; } +} +async function tf2BeginEnroll() { + try { + const r = await fetch("/api/auth/2fa/setup", { method: "POST", credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (!r.ok || !d.secret) { authMsg(d.error || "setup failed"); return; } + tf2Secret = d.secret; + $("#tf2Secret").value = d.secret; + $("#tf2Uri").textContent = d.otpauth || ""; + $("#tf2Enable").hidden = true; $("#tf2Enroll").hidden = false; + $("#tf2Code").value = ""; $("#tf2Code").focus(); + } catch { authMsg("Server unreachable"); } +} +async function tf2Confirm() { + const code = ($("#tf2Code").value || "").trim(); + if (!/^\d{6}$/.test(code)) { authMsg("Enter the 6-digit code."); return; } + const r = await fetch("/api/auth/2fa/enable", { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret: tf2Secret, code }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { authMsg(d.error || "invalid code"); return; } + authMsg("Two-factor enabled.", true); tf2Refresh(); +} +async function tf2Disable() { + const code = ($("#tf2DisCode").value || "").trim(); + const r = await fetch("/api/auth/2fa/disable", { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { authMsg(d.error || "invalid code"); return; } + authMsg("Two-factor disabled.", true); tf2Refresh(); +} +{ const b = $("#tf2Enable"); if (b) b.onclick = tf2BeginEnroll; } +{ const b = $("#tf2Confirm"); if (b) b.onclick = tf2Confirm; } +{ const b = $("#tf2Disable"); if (b) b.onclick = tf2Disable; } +onEnter("#auTotp", doLogin); +onEnter("#tf2Code", tf2Confirm); +onEnter("#tf2DisCode", tf2Disable); +refreshAuth(); +if (VERIFIED) { openAuth("in"); authMsg("Email verified - thank you.", true); history.replaceState(null, "", "/"); } +if (URLTOKEN) { openAuth("rs"); } + +/* ------------------------------------------------------------- theme */ +function applyTheme(t) { + document.documentElement.dataset.theme = t; + const btns = [document.querySelector("#btnTheme"), document.querySelector("#btnThemeP")]; + btns.forEach(b => { if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light"; }); +} +applyTheme(localStorage.getItem("nd.theme") || "dark"); +$("#btnTheme").onclick = () => { + const t = document.documentElement.dataset.theme === "light" ? "dark" : "light"; + localStorage.setItem("nd.theme", t); + applyTheme(t); +}; + +/* ------------------------------------------------------------- address book */ +function abookGet() { + try { return JSON.parse(localStorage.getItem("nd.abook") || "[]"); } catch { return []; } +} +function abookSet(list) { localStorage.setItem("nd.abook", JSON.stringify(list)); renderAbook(); } +function renderAbook() { + const list = abookGet(); + $("#abook").hidden = list.length === 0; + const box = $("#abook"); + box.innerHTML = ""; + for (const e of list) { + const row = document.createElement("div"); + row.className = "ab-row"; + const lbl = document.createElement("span"); + lbl.className = "ab-label"; + lbl.textContent = e.label; + const id = document.createElement("span"); + id.className = "ab-id"; + id.textContent = e.id; + const del = document.createElement("button"); + del.className = "tb"; + del.textContent = "🗑"; + del.onclick = (ev) => { ev.stopPropagation(); abookSet(abookGet().filter((x) => x.id !== e.id)); }; + row.append(lbl, id, del); + row.onclick = () => { + $("#inServer").value = e.server || $("#inServer").value; + $("#inId").value = e.id; + $("#inPw").focus(); + }; + box.append(row); + } +} +$("#btnAbAdd").onclick = () => { + const id = $("#inId").value.replace(/\D/g, ""); + if (!id) { connectErr("Enter an ID first, then save it."); return; } + const label = prompt("Name for this connection:", "My PC") || id; + const list = abookGet().filter((x) => x.id !== id); + list.push({ id, label, server: $("#inServer").value.trim() }); + abookSet(list); +}; +renderAbook(); + +function connectErr(msg) { + const el = $("#connectErr"); + el.textContent = msg; + el.hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Connect"; +} +function connectErrHide() { $("#connectErr").hidden = true; } + +async function connect() { + if (!window.VideoDecoder) { + connectErr("This browser has no WebCodecs video decoder. Use Chrome, Edge or Safari 16.4+."); + return; + } + if (!$("#inId").value.replace(/\D/g, "") && !/^wss?:\/\//i.test($("#inServer").value.trim())) { + connectErr("Enter the remote computer's ID."); + return; + } + if (S.ws) { const old = S.ws; S.ws = null; try { old.close(); } catch {} } + resetSessionState(); + $("#connectErr").hidden = true; + $("#btnConnect").disabled = true; + $("#btnConnect").textContent = "Connecting…"; + + let server = $("#inServer").value.trim(); + const id = $("#inId").value.replace(/\D/g, ""); + const pw = $("#inPw").value; + S.viewOnly = $("#inViewOnly").checked; + + localStorage.setItem("nd.server", server); + localStorage.setItem("nd.id", id); + if ($("#inRemember").checked) { + localStorage.setItem("nd.remember", "1"); + localStorage.setItem("nd.pw", pw); + } else { + localStorage.removeItem("nd.remember"); + localStorage.removeItem("nd.pw"); + } + + let wsUrl; + if (/^wss?:\/\//i.test(server)) { + // Direct connection to a host running --listen + S.direct = true; + wsUrl = server.replace(/\/+$/, "") + "/ws"; + } else { + S.direct = false; + let loc = location; + if (server) { + try { loc = new URL(/^https?:\/\//i.test(server) ? server : "https://" + server); } + catch { connectErr("Invalid server address: " + server); return; } + } + const scheme = loc.protocol === "https:" ? "wss" : "ws"; + wsUrl = `${scheme}://${loc.host}/ws`; + } + S.serverUrl = server; + + let ws; + try { + ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = () => rej(new Error("cannot reach server")); + setTimeout(() => rej(new Error("connection timeout")), 10000); + }); + } catch (e) { + connectErr("Could not connect: " + e.message); + return; + } + S.ws = ws; + S.hostId = id; + S.dead = false; + ws.onmessage = (ev) => { if (ws === S.ws) onMessage(ev); }; + ws.onclose = onWsClose; + ws.onerror = () => {}; + + // Keep the password for the challenge (cleared after auth) + S._pw = pw; + send({ t: "session.begin", id: S.direct ? "0" : id, direct: S.direct, + takeover: !!S._takeover }); + S._takeover = false; +} + +function resetSessionState() { + teardownMedia(); + for (const t of S._timers || []) clearInterval(t); + S._timers = []; + Object.assign(S, { + sid: null, e2e: false, e2eKey: null, e2ePending: null, info: null, + dims: { w: 0, h: 0 }, monitors: [], sps: null, pps: null, started: false, + plan: null, limit: 0, startedAt: 0, _denyReason: null, _challenge: null, + _privacyNoted: false, _logonShown: false, buttons: 0, pendingMouse: null, + transfers: Object.create(null), autoLevel: null, rtt: 0, fpsShown: 0, + _rttBase: 0, _localCurTs: 0, _lastMoveSend: 0, + heldKeys: new Set(), curShapes: {}, curId: null, curPos: null, + }); + wheelAccY = 0; wheelAccX = 0; + decodeQueue = Promise.resolve(); + const rc = document.getElementById('remoteCursor'); if (rc) rc.hidden = true; + const stg = document.getElementById('stage'); if (stg) stg.classList.remove('remote-cursor'); + // menus are per-host: forget the previous host's resolution list & wallpaper state + window.__modesLoaded = false; + const mm = document.getElementById('miModes'); + if (mm) mm.innerHTML = ''; + const wp = document.getElementById('miWallpaper'); if (wp) wp.classList.remove('on'); +} + +function onWsClose(ev) { + if (ev && ev.target && ev.target !== S.ws) return; + if (S.dead) return; + S.dead = true; + teardownMedia(); + const st = $("#tbState"); + if (!$("#screenSession").hidden) { + setState("disconnected", "err"); + showBanner("Connection closed. Returning to connect screen…", true); + setTimeout(showConnectScreen, 600); + } else { + connectErr("Connection closed (" + (S._denyReason || "unexpectedly") + ")"); + } + scheduleReconnect(); +} + +// The user never clicked Disconnect, so a drop (network blip, relay restart, +// host reconnect, page glitch) must heal itself: retry with backoff until the +// session is back. Credential problems never loop. +function scheduleReconnect() { + if (S.userEnd || S._recPending) return; + if (!$("#inPw").value || !$("#inId").value.replace(/\D/g, "")) return; + const reason = (S._denyReason || "").toLowerCase(); + // credential / policy problems never loop + if (/password|2fa|denied|allowlist|not allowed|bad id/.test(reason)) return; + const busy = /busy|another|elsewhere/.test(reason); + S._recN = (S._recN || 0) + 1; + // Busy = the session is open on another device/tab. Don't storm: poll + // gently and, after a couple of tries, offer to take it over. + if (busy) { + if (S._recN >= 3) { offerTakeover(); return; } + scheduleIn(4000, "This computer is in use on another device — retrying…"); + return; + } + if (S._recN > 10) { // give up storming; let the user decide + connectErr("Couldn't reconnect after several tries. Press Connect to retry."); + return; + } + // floor at 2s so even a fast accept→drop cycle can't hammer the relay + scheduleIn(Math.min(2000 + 1000 * S._recN, 8000), + "Connection lost — reconnecting (attempt " + S._recN + ")…"); +} +function scheduleIn(delay, msg) { + S._recPending = true; + setTimeout(() => { + S._recPending = false; + if (S.userEnd || !$("#screenSession").hidden) return; + connectErr(msg); + connect(); + }, delay); +} +function offerTakeover() { + const box = $("#connectErr"); + connectErr("This computer is being controlled on another device."); + if (box && !$("#btnTakeover")) { + const b = document.createElement("button"); + b.id = "btnTakeover"; b.className = "primary"; b.style.marginTop = "10px"; + b.textContent = "Take over the session here"; + b.onclick = () => { b.remove(); S._takeover = true; S._recN = 0; connect(); }; + box.appendChild(b); + } +} + +function showConnectScreen() { + for (const t of S._timers || []) clearInterval(t); + S._timers = []; + if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + $("#screenSession").hidden = true; + $("#screenConnect").hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Connect"; + S._pw = null; + S._challenge = null; + hideBanner(); +} + +/* ------------------------------------------------------------- send helpers */ +function send(obj) { + obj.sid = obj.sid || S.sid; + if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(obj)); +} +async function sendBinary(bytes) { + if (!S.e2eKey) { + if (S.ws && S.ws.readyState === 1) S.ws.send(bytes); + return; + } + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, S.e2eKey, bytes)); + const out = new Uint8Array(12 + ct.length); + out.set(nonce, 0); + out.set(ct, 12); + if (S.ws && S.ws.readyState === 1) S.ws.send(out); +} + +/* ------------------------------------------------------------- message dispatch */ +function onMessage(ev) { + if (typeof ev.data === "string") { + let m; + try { m = JSON.parse(ev.data); } catch { return; } + handleJson(m); + } else { + const bytes = new Uint8Array(ev.data); + decodeQueue = decodeQueue.then(() => handleBinary(bytes)).catch((e) => console.error("binary error", e)); + } +} + +async function handleBinary(bytes) { + if (S.e2eKey) { + if (bytes.length < 29) return; + try { + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: bytes.subarray(0, 12) }, S.e2eKey, bytes.subarray(12)); + bytes = new Uint8Array(pt); + } catch { + return; // drop undecryptable chunk + } + } + const chan = bytes[0]; + if (chan === 0x01) onVideo(bytes); + else if (chan === 0xa1) onAudio(bytes); + else if (chan === 0xf1) onFileData(bytes, false); + else if (chan === 0xf2) onFileData(bytes, true); +} + +async function handleJson(m) { + const t = m.t; + // Host RTT probe: echo it back with our measured frame jitter so the host's + // adaptive QoS gets a real end-to-end delay (not just RTT). Also keeps the + // host's loss estimate honest (an unanswered ping now counts as loss). + if (t === "net.ping") { send({ t: "net.pong", sid: m.sid, id: m.id, jit: frameJitterMs() }); return; } + if (t === "session.challenge") return doAuth(m); + if (t === "session.deny") { + S._denyReason = m.reason || "denied"; + if (m.reason === "signin") { openAuth("in"); authMsg("Sign in with your email to connect."); } + else { + connectErr("Connection refused: " + (m.reason || "denied") + + (m.reason === "host offline" ? " — is the host agent running and registered?" : "")); + } + try { S.ws.close(); } catch {} + return; + } + if (t === "session.accept") { + // session is live: remember it so a page reload resumes automatically + S.userEnd = false; + S._recN = 0; + try { + localStorage.setItem("nd.resume", JSON.stringify( + { id: (S.hostId || $("#inId").value).replace(/\D/g, ""), t: Date.now() })); + } catch {} + return onAccept(m); + } + if (t === "session.info") return onInfo(m.info); + if (t === "session.end") { + teardownMedia(); + showBanner("Session ended: " + (m.reason || "closed"), true); + setState("ended", "err"); + S._denyReason = m.reason || S._denyReason; + setTimeout(showConnectScreen, 600); + scheduleReconnect(); + return; + } + if (t === "pong") { S.rtt = Math.round(now() - m.c); updateStats(); return; } + if (t === "rtc.answer") { + if (S._rtc && S._rtc.pc) { + S._rtc.pc.setRemoteDescription({ type: "answer", sdp: m.sdp }) + .catch(() => { S._rtc = null; }); + } + return; + } + if (t === "rtc.unavailable") { S._rtc = null; return; } // relay keeps working + if (t === "rtc.config") { if (window.__onRtcConfig) window.__onRtcConfig(m); return; } + if (t === "session.plan") { + S.plan = m.plan; S.limit = m.limit || 0; S.startedAt = now(); + S.planEmail = m.email || ""; + if (m.email) setSignedIn(m.email, m.plan === "owner"); + return; + } + if (t === "cursor.shape") return onCursorShape(m); + if (t === "cursor") return onCursorPos(m); + if (t === "clip.set") return onRemoteClip(m); + if (t === "clip.image") return onRemoteClipImage(m); + if (t === "chat") return onChat(m.text, false); + if (t === "file.roots.res" || t === "file.list.res") return onFileList(m); + if (t === "file.done") return onFileDone(m); + if (t === "file.ok") return onFileOk(m); + if (t === "file.err") { setFileStatus("Error: " + m.msg, true); return; } + if (t === "sys.res") { + if (m.kind === "processes") return renderProcesses(m.items); + if (m.kind === "privacy") { + $("#btnPrivacy").classList.toggle("active", !!m.on); + return setFileStatus(m.msg || "ok"); + } + // menu-driven results (modes / setmode / wallpaper / cad) handled below + if (window.onSysRes && window.onSysRes(m)) return; + if (m.msg) showBanner((m.ok ? "" : "Failed: ") + m.msg, m.ok, 2600); + return setFileStatus(m.ok ? (m.msg || "done") : "failed: " + m.msg, !m.ok); + } +} + +/* ------------------------------------------------------------- auth + E2E */ +async function doAuth(ch) { + S.sid = ch.sid; + if (ch.totp && !$("#inTotp").value.trim()) { + // wait for the user's authenticator code before proving the password + S._challenge = ch; + $("#lblTotp").hidden = false; + $("#inTotp").hidden = false; + $("#btnConnect").disabled = false; + $("#btnConnect").textContent = "Enter 2FA code…"; + connectErr("This host requires a 2FA code — type it and press Enter."); + $("#inTotp").focus(); + return; + } + await doAuthSend(ch); +} + +$("#inTotp").addEventListener("keydown", (e) => { + if (e.key === "Enter" && S._challenge) { + const ch = S._challenge; + S._challenge = null; + connectErrHide(); + $("#btnConnect").textContent = "Connecting…"; + doAuthSend(ch); + } +}); + +async function doAuthSend(ch) { + let pub = null; + if (ch.e2e) { + try { + const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + pub = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)))); + const hostPub = await crypto.subtle.importKey( + "raw", b64ToBytes(ch.pub), { name: "ECDH", namedCurve: "P-256" }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: hostPub }, kp.privateKey, 256)); + const ctx = new Uint8Array(shared.length + 3); + ctx.set(shared, 0); + ctx.set(enc.encode("e2e"), shared.length); + const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx)); + S.e2ePending = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); + } catch (e) { + window.__ndErr.push("doAuth: " + e); + console.warn("E2E unavailable, falling back to transport-only encryption", e); + pub = null; + } + } + // verifier = PBKDF2(password, salt, iters) hex ; proof = SHA256(nonce + verifier) hex + const salt = b64ToBytes(ch.salt); + const bits = await crypto.subtle.importKey("raw", enc.encode(S._pw || ""), "PBKDF2", false, ["deriveBits"]); + const dk = await crypto.subtle.deriveBits( + { name: "PBKDF2", hash: "SHA-256", salt, iterations: ch.iters }, bits, 256); + const verifier = toHex(dk); + const proof = await sha256Hex(ch.nonce + verifier); + // Channel-bound proof (MITM hardening): when E2E is active, bind the host's + // exact ephemeral pubkey (ch.pub) into the proof so a relay that swapped it is + // detected host-side. Sent alongside the plain proof; non-E2E omits it. + const proof_bind = (ch.e2e && ch.pub && pub) + ? await sha256Hex(ch.nonce + verifier + ch.pub) + : undefined; + S._pw = null; // discard + send({ t: "session.auth", proof, proof_bind, pub, totp: $("#inTotp").value.trim() }); + // clear the code field for next time; hide unless still needed + $("#inTotp").value = ""; + if (!ch.totp) { $("#lblTotp").hidden = true; $("#inTotp").hidden = true; } +} + +async function onAccept(m) { + S._pw = null; + if (S.e2ePending && m.info && m.info.e2e) { + S.e2eKey = S.e2ePending; + S.e2ePending = null; + S.e2e = true; + } + $("#screenConnect").hidden = true; + $("#screenSession").hidden = false; + setState("live", "ok"); + onInfo(m.info); + $("#canvas").focus(); + + // clipboard sync ON by default (RustDesk-style): text copied on the remote + // appears in your local clipboard automatically; copying locally pushes to + // the remote once you've granted the browser clipboard permission + S.clipSync = true; + $("#btnClipboard").classList.add("active"); + + // restore host-enforced view-only across reconnects (the engine resets the + // flag per session, so a reconnect must re-assert it) + if (S.viewOnly) send({ t: "view.only", on: true }); + + // ping loop + S._timers.push(setInterval(() => { if (!S.dead) send({ t: "ping", c: now() }); }, 2000)); + // mouse coalesce flush loop (~120 Hz for smooth, low-latency dragging) + S._timers.push(setInterval(flushMouse, 8)); + drawLoop(); +} + +function onInfo(info) { + S.info = Object.assign({}, S.info || {}, info); // later partial infos merge, not replace + info = S.info; + const reconfigure = S.dims.w && (S.dims.w !== info.w || S.dims.h !== info.h); + S.dims = { w: info.w, h: info.h }; + S.monitors = info.monitors || []; + const sel = $("#selMonitor"); + sel.innerHTML = ""; + for (const mon of S.monitors) { + const o = document.createElement("option"); + o.value = mon.i; + o.textContent = mon.i < 0 + ? `All displays (${mon.w}×${mon.h})` + : `Display ${mon.i} (${mon.w}×${mon.h})`; + sel.appendChild(o); + } + // restore last-viewed monitor + quality on reconnect (RustDesk 1.4.9 parity) + if (S.restoring !== false) { + const lastMon = localStorage.getItem("nd.lastmon"); + const lastQ = localStorage.getItem("nd.lastq"); + if (lastMon && [...sel.options].some(o => o.value === lastMon)) sel.value = lastMon; + if (lastQ && $("#selQuality").querySelector('option[value="' + lastQ + '"]')) + $("#selQuality").value = lastQ; + } + sel.value = sel.value || info.mon; + if (parseInt(sel.value, 10) !== info.mon) { + setTimeout(() => send({ t: "monitor.switch", mon: parseInt(sel.value, 10) }), 300); + } + $("#tbTitle").textContent = `NetDesk — ${info.host_name || S.hostId}` + (info.e2e ? " 🔒" : ""); + // Host-enforced input permission: when the host disables input entirely, + // reflect it — force view-only and lock the toggle (the engine drops input + // regardless; this keeps the UI truthful). + if (info.perms && info.perms.input === false) { + S.inputDeniedByHost = true; + if (typeof setViewOnly === "function" && !S.viewOnly) setViewOnly(true); + $("#btnViewOnly").title = "Input is disabled by the host's permissions"; + $("#btnViewOnly").classList.add("disabled"); + } + if (typeof info.privacy_active === "boolean") { + $("#btnPrivacy").classList.toggle("active", info.privacy_active); + if (info.privacy_active && !S._privacyNoted) { + S._privacyNoted = true; + showBanner("Privacy mode active — the host's monitor is off.", true, 3000); + } + } + if (!info.privacy_active) S._privacyNoted = false; + if (info.logon_screen) showBanner("Host is at the Windows logon screen — you can type credentials to log in.", true); + else if (S.info && S._logonShown && !info.logon_screen) hideBanner(); + if (info.logon_screen) S._logonShown = true; + if (info.audio && !S.audioDecoder) setupAudio(); + // Remote sound ON by default (like TeamViewer/RDP): the moment the host says + // it has audio, request SYSTEM sound. Playback stays muted by the browser + // until a user gesture resumes the AudioContext — but you're controlling the + // host, so your next click/keypress on the canvas does that (gesture resumer + // below). Net effect: audio just plays once you interact. + if (info.audio && !S._audioAutoStarted) { + S._audioAutoStarted = true; + S.audioSrc = 1; + send({ t: "audio.set", src: 1 }); + try { if (S.audioCtx && S.audioCtx.state !== "running") S.audioCtx.resume(); } catch (e) {} + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🔊 System"; + const b = $("#btnAudio"); if (b) b.classList.add("active"); + } + // Reconnected with unfinished downloads? Re-request only the missing ranges. + try { resumeTransfers(); } catch (e) {} + if (reconfigure) { + // new dimensions: rebuild decoder and wait for the next keyframe + S.started = false; + S.sps = null; + S.pps = null; + if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; } + send({ t: "keyframe" }); + } +} + +/* ------------------------------------------------------------- video decode */ +function splitNals(bytes) { + // find 00 00 01 start codes (00 00 00 01 tolerated); return NAL bodies + const nals = []; + let i = 0, starts = []; + while (i < bytes.length - 2) { + if (bytes[i] === 0 && bytes[i + 1] === 0 && bytes[i + 2] === 1) { + starts.push(i); + i += 3; + } else i++; + } + for (let k = 0; k < starts.length; k++) { + let s = starts[k]; + let bodyStart = s + 3; + let bodyEnd = k + 1 < starts.length ? starts[k + 1] : bytes.length; + // strip extra leading zero from 4-byte start code of the *next* nal + while (bodyEnd > bodyStart && bytes[bodyEnd - 1] === 0) bodyEnd--; // keep zero-padding out + if (bodyEnd > bodyStart) nals.push(bytes.subarray(bodyStart, bodyEnd)); + } + return nals; +} + +/* ------ frame-arrival jitter -> reported to the host in net.pong for QoS ---- */ +function recordFrameJitter() { + const now = performance.now(); + if (!S.jitIv) S.jitIv = []; + if (S.jitLast) { + const dt = Math.min(now - S.jitLast, 2000); + S.jitIv.push(dt); + if (S.jitIv.length > 30) S.jitIv.shift(); + } + S.jitLast = now; +} +function frameJitterMs() { + const iv = S.jitIv || []; + if (iv.length < 2) return 0; + const mean = iv.reduce((a, b) => a + b, 0) / iv.length; + const mad = iv.reduce((a, b) => a + Math.abs(b - mean), 0) / iv.length; + return Math.round(mad); +} + +function onVideo(bytes) { + recordFrameJitter(); + const kf = bytes[1] === 1; + const nals = splitNals(bytes.subarray(2)); + const au = []; + for (const n of nals) { + const t = n.length ? n[0] & 31 : 0; + if (t === 7) S.sps = n; + else if (t === 8) S.pps = n; + if (t === 1 || t === 5) au.push(n); + } + if (!au.length) return; + if (!S.sps || !S.pps) return; + + if (!S.decoder) { + const codec = "avc1." + toHex(S.sps.subarray(1, 4)); + try { + S.decoder = new VideoDecoder({ + output: (frame) => { + if (S.latestFrame) S.latestFrame.close(); + S.latestFrame = frame; + S.framesDecoded++; + }, + error: (e) => { + // self-heal (vital on the lossy P2P path): rebuild + ask for an IDR + console.error("decoder", e); + try { S.decoder.close(); } catch {} + S.decoder = null; S.started = false; + send({ t: "keyframe" }); + }, + }); + S.decoder.configure({ + codec, + codedWidth: S.dims.w, + codedHeight: S.dims.h, + optimizeForLatency: true, + hardwareAcceleration: "prefer-hardware", + avc: { format: "avc" }, + description: avccDescription(S.sps, S.pps), + }); + } catch (e) { + showBanner("Cannot start decoder: " + e.message, false); + return; + } + } + if (!S.started && !kf) { // waiting for an IDR: keep asking (rate-limited) + if (now() - (S._kfReqAt || 0) > 400) { S._kfReqAt = now(); send({ t: "keyframe" }); } + return; + } + // Backpressure: if the decoder queue is deep, drop this delta - BUT a + // dropped delta breaks the reference chain, and decoding the next deltas + // against a frame we never saw is exactly the "pixels break" artifact. + // So: resync - stop decoding deltas and request a fresh keyframe. + if ((S.decoder.decodeQueueSize || 0) > 10 && !kf) { + S.started = false; + S._kfReqAt = now(); + send({ t: "keyframe" }); + return; + } + + const data = new Uint8Array(au.reduce((a, n) => a + 4 + n.length, 0)); + let o = 0; + for (const n of au) { + data[o++] = (n.length >>> 24) & 255; data[o++] = (n.length >>> 16) & 255; + data[o++] = (n.length >>> 8) & 255; data[o++] = n.length & 255; + data.set(n, o); o += n.length; + } + try { + S.decoder.decode(new EncodedVideoChunk({ + type: kf ? "key" : "delta", + timestamp: Math.round(now()), + data, + })); + if (kf) S.started = true; + S.bytesVideo += bytes.length; + } catch (e) { + if (kf) { // config mismatch maybe; rebuild on next keyframe + try { S.decoder.close(); } catch {} + S.decoder = null; S.started = false; send({ t: "keyframe" }); + } + } +} + +function avccDescription(sps, pps) { + const d = new Uint8Array(11 + sps.length + pps.length); + d[0] = 1; + d[1] = sps[1]; d[2] = sps[2]; d[3] = sps[3]; + d[4] = 0xfc | 3; + d[5] = 0xe0 | 1; + d[6] = (sps.length >> 8) & 255; d[7] = sps.length & 255; + d.set(sps, 8); + const p = 8 + sps.length; + d[p] = 1; + d[p + 1] = (pps.length >> 8) & 255; d[p + 2] = pps.length & 255; + d.set(pps, p + 3); + return d; +} + +function drawLoop() { + if (S.dead) return; + const c = $("#canvas"); + if (S.latestFrame && c.width !== S.dims.w) { c.width = S.dims.w; c.height = S.dims.h; } + if (S.latestFrame) { + const ctx = c.getContext("2d"); + ctx.drawImage(S.latestFrame, 0, 0, c.width, c.height); + } + // fps accounting + if (now() - S.lastFpsTick > 1000) { + S.fpsShown = S.framesDecoded; + S.framesDecoded = 0; + S.lastFpsTick = now(); + updateStats(); + } + requestAnimationFrame(drawLoop); +} + +/* ------------------------------------------------------------- audio */ +function setupAudio() { + if (!window.AudioDecoder) return; + try { + // Fresh jitter-buffer state for this (re)connect. + audioNextTime = 0; + audioLead = 0.12; + S._audioUnderruns = 0; + // Reuse an existing (possibly already-resumed) context on a decoder rebuild — + // creating a fresh one would start suspended again and lose the gesture. + if (!S.audioCtx) S.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + S.audioDecoder = new AudioDecoder({ + output: playAudio, + // A single bad/truncated Opus packet closes the decoder; drop it so the + // next packet rebuilds it (see onAudio) instead of killing audio silently + // for the whole session. + error: () => { try { S.audioDecoder = null; } catch (e) {} }, + }); + S.audioDecoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 }); + } catch { S.audioDecoder = null; } +} + +let audioNextTime = 0; +let audioLead = 0.12; // adaptive jitter-buffer depth (s); grows on underrun +const AUDIO_LEAD_MAX = 0.45; +function playAudio(ad) { + const ctx = S.audioCtx; + if (!ctx) { ad.close?.(); return; } + if (ctx.state === "suspended") ctx.resume(); + const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate; + const buf = ctx.createBuffer(n, len, rate); + const planar = (ad.format || "").includes("planar"); + for (let ch = 0; ch < n; ch++) { + const dst = buf.getChannelData(ch); + if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" }); + else { + // planeIndex is REQUIRED by AudioData.copyTo even for interleaved formats; + // omitting it throws "Required member planeIndex is undefined" on EVERY + // frame, which silently killed all web-viewer audio. Chromium decodes opus + // to interleaved "f32", so this branch is the common path. + const tmp = new Float32Array(len * n); + ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); + for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; + } + } + ad.close?.(); + // Jitter buffer: schedule each 20 ms frame on a running clock that leads the + // context by `audioLead`. Relay/network timing is uneven, so too small a lead + // underruns (gaps/crackle). We keep frames seamlessly back-to-back and, on an + // underrun, GROW the lead so the stream self-heals toward smooth; if latency + // creeps too far ahead we resync down. + const now = ctx.currentTime; + if (audioNextTime === 0) { + audioNextTime = now + audioLead; // first frame: prime + } else if (audioNextTime < now) { + audioLead = Math.min(audioLead + 0.04, AUDIO_LEAD_MAX); // underrun: deepen buffer + audioNextTime = now + audioLead; + S._audioUnderruns = (S._audioUnderruns || 0) + 1; + } else if (audioNextTime > now + audioLead + 0.35) { + audioNextTime = now + audioLead; // drifted ahead: resync + } + const src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + src.start(audioNextTime); + audioNextTime += len / rate; +} + +function onAudio(bytes) { + // Rebuild the decoder if a prior decode error dropped it (error cb nulls it). + if (!S.audioDecoder) { + if (!window.AudioDecoder) return; + setupAudio(); + if (!S.audioDecoder) return; + } + try { + S.audioDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: Math.round(now()), + data: bytes.subarray(1), + })); + } catch {} +} + +/* ------------------------------------------------------------- voice call */ +// Two-way voice: capture THIS browser's microphone, Opus-encode it (WebCodecs), +// and send each packet as [CHAN_VOICE=0xa2][opus] (E2E-sealed like everything +// else). The host decodes it and plays it on its speakers. We also flip the +// host's audio source to its mic so you hear the person there — a real call. +async function startVoice() { + if (S.voiceOn) return; + if (!window.AudioEncoder || !window.MediaStreamTrackProcessor) { + showBanner("Voice call needs a Chromium-based browser (WebCodecs).", true, 2800); + return; + } + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, + }); + } catch (e) { showBanner("Microphone permission denied — can't start the call.", true, 2800); return; } + S.voiceStream = stream; + const track = stream.getAudioTracks()[0]; + S.voiceTrack = track; + S.voiceOn = true; + const enc = new AudioEncoder({ + output: (chunk) => { + try { + const tmp = new Uint8Array(chunk.byteLength); + chunk.copyTo(tmp); + const buf = new Uint8Array(tmp.length + 1); + buf[0] = 0xa2; // CHAN_VOICE + buf.set(tmp, 1); + sendBinary(buf); + } catch (e) {} + }, + error: () => {}, + }); + S.voiceEncoder = enc; + // Hear the other side: if host audio is off, switch it to the host mic. + if (!S.audioSrc) { + if (!S.audioDecoder) setupAudio(); + try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {} + S.audioSrc = 2; + send({ t: "audio.set", src: 2 }); + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🎤 Mic"; + const b = $("#btnAudio"); if (b) b.classList.add("active"); + } + const reader = new MediaStreamTrackProcessor({ track }).readable.getReader(); + S.voiceReader = reader; + let configured = false; + (async () => { + while (S.voiceOn) { + let res; + try { res = await reader.read(); } catch (e) { break; } + if (res.done) break; + const ad = res.value; + try { + if (!configured) { + // Opus handles rate internally, so encoding at the mic's native rate is + // fine — the host's 48 kHz decoder reproduces it correctly. + enc.configure({ codec: "opus", sampleRate: ad.sampleRate, numberOfChannels: ad.numberOfChannels, bitrate: 48000 }); + configured = true; + } + if (enc.state === "configured") enc.encode(ad); + } catch (e) {} + ad.close(); + } + })(); + updateVoiceUi(); + showBanner("Voice call started — speak into your mic.", true, 2000); +} +function stopVoice() { + if (!S.voiceOn && !S.voiceStream) { updateVoiceUi(); return; } + S.voiceOn = false; + try { if (S.voiceReader) S.voiceReader.cancel(); } catch (e) {} + try { if (S.voiceStream) S.voiceStream.getTracks().forEach((t) => t.stop()); } catch (e) {} + try { if (S.voiceEncoder && S.voiceEncoder.state !== "closed") S.voiceEncoder.close(); } catch (e) {} + S.voiceReader = S.voiceTrack = S.voiceStream = S.voiceEncoder = null; + updateVoiceUi(); +} +function updateVoiceUi() { + const mi = $("#miVoice"); + if (!mi) return; + mi.classList.toggle("on", !!S.voiceOn); + const txt = mi.childNodes[0]; + const label = S.voiceOn ? "End voice call" : "Start voice call (your mic)"; + if (txt && txt.nodeType === 3) txt.nodeValue = label; else mi.textContent = label; +} + +/* ------------------------------------------------------------- input */ +function canvasPos(e) { + // canvas is letterboxed (object-fit: contain): map into the drawn image rect + const c = $("#canvas"); + const r = c.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; + const dw = iw * k, dh = ih * k; + const ox = r.left + (r.width - dw) / 2, oy = r.top + (r.height - dh) / 2; + return { + x: Math.max(0, Math.min(iw - 1, Math.round((e.clientX - ox) / k))), + y: Math.max(0, Math.min(ih - 1, Math.round((e.clientY - oy) / k))), + }; +} +// button-bit map: left=1, right=2, middle=4, back(X1)=8, forward(X2)=16 +function mouseBtnBit(b) { + return b === 0 ? 1 : b === 2 ? 2 : b === 1 ? 4 : b === 3 ? 8 : b === 4 ? 16 : 0; +} + +const canvas = $("#canvas"); +canvas.addEventListener("pointermove", (e) => { + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchMove(e); + const p = canvasPos(e); + S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }; + localCursorEcho(p); +}); + +// Local cursor echo (RustDesk-style): draw the remote cursor SHAPE at YOUR +// pointer position instantly instead of waiting for the host to echo it back. +// On a long link the echo lags by RTT, which made the cursor feel slow-motion. +function localCursorEcho(p) { + S.curPos = { x: p.x, y: p.y }; + S._localPos = S.curPos; + S._localCurTs = performance.now(); + if (nativeCur()) { + // the OS draws the pointer (with the remote shape, see applyCursorShape): + // it never waits on the page, so it stays crisp even while a slow link + // keeps the main thread busy decoding. Keep the overlay out of the way. + const rc = $("#remoteCursor"); + if (rc && !rc.hidden) { rc.hidden = true; $("#stage").classList.remove("remote-cursor"); } + return; + } + if (S.curId == null || !(S.curShapes || {})[S.curId]) return; + const rc = $("#remoteCursor"); + if (rc) { + positionCursor(); + rc.hidden = false; + $("#stage").classList.add("remote-cursor"); + } +} +// mouse/trackpad clients get the shape as the real OS cursor; touch clients +// and view-only keep the JS overlay (there is no pointer to dress up) +function nativeCur() { return !IS_TOUCH_CLIENT && !S.viewOnly; } +canvas.addEventListener("pointerdown", (e) => { + canvas.focus(); + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchDown(e); + // capture the pointer so a drag that leaves the canvas keeps tracking and the + // button-up is never lost (fixes "drag freezes / stuck button" past the edge) + try { canvas.setPointerCapture(e.pointerId); } catch {} + S.buttons |= mouseBtnBit(e.button); + const p = canvasPos(e); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); +}); +function pointerRelease(e) { + if (S.viewOnly || S.dead) return; + if (e.pointerType === "touch") return touchUp(e); + try { canvas.releasePointerCapture(e.pointerId); } catch {} + S.buttons &= ~mouseBtnBit(e.button); + const p = canvasPos(e); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); +} +canvas.addEventListener("pointerup", pointerRelease); +canvas.addEventListener("pointercancel", (e) => { + if (e && e.pointerType === "touch") return touchUp(e, true); + if (S.buttons) { S.buttons = 0; send({ t: "i.mouse", ...(S.pendingMouse || { x: 0, y: 0 }), b: 0, w: 0, hw: 0 }); } +}); + +/* ---------------- touch: TOUCHPAD mode for phones/tablets (RustDesk-style) + drag = move the cursor · tap = left click · double-tap = double click · + double-tap & hold = drag · hold still ~0.6s = right click · 2 fingers = scroll */ +const T = { pts: new Map(), mode: null, lpTimer: null, lastMidY: 0, scrollAcc: 0, + vx: 0, vy: 0, vInit: false, sid: null, lastX: 0, lastY: 0, + lastTapT: 0, moved: false, downT: 0 }; +function tClearLP() { if (T.lpTimer) { clearTimeout(T.lpTimer); T.lpTimer = null; } } +function tScale() { + const r = canvas.getBoundingClientRect(); + return Math.min(r.width / (S.dims.w || 1), r.height / (S.dims.h || 1)) || 1; +} +function tCursorInit() { + if (T.sid !== S.sid) { T.vInit = false; T.sid = S.sid; try { zoomReset(); } catch (e) {} } + if (T.vInit) return; + if (S.curPos) { T.vx = S.curPos.x; T.vy = S.curPos.y; } + else { T.vx = (S.dims.w || 2) / 2; T.vy = (S.dims.h || 2) / 2; } + T.vInit = true; +} +function tXY() { return { x: Math.round(T.vx), y: Math.round(T.vy) }; } +function tSendMove() { + const p = tXY(); + S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }; + localCursorEcho(p); + try { followCursor(); } catch (e) {} // zoomed view pans with the cursor +} +function tapClick(btnBit) { + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: btnBit, w: 0, hw: 0 }); + setTimeout(() => send({ t: "i.mouse", x: p.x, y: p.y, b: 0, w: 0, hw: 0 }), 60); +} +function touchDown(e) { + try { canvas.setPointerCapture(e.pointerId); } catch {} + T.pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (T.pts.size === 1) { + tCursorInit(); + T.lastX = e.clientX; T.lastY = e.clientY; + T.moved = false; T.downT = performance.now(); + if (performance.now() - T.lastTapT < 320) { + // double-tap & hold: press the left button at the cursor and drag + T.mode = "drag"; + S.buttons |= 1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } else { + T.mode = "move"; + tClearLP(); + T.lpTimer = setTimeout(() => { + if (T.mode === "move" && !T.moved && T.pts.size === 1) { + T.mode = "rclick"; + tapClick(2); + if (navigator.vibrate) try { navigator.vibrate(25); } catch {} + } + }, 600); + } + } else if (T.pts.size === 2) { + tClearLP(); + if (T.mode === "drag") { + S.buttons &= ~1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } + T.mode = "two"; // undecided: becomes "scroll" or "zoom" by motion + const ps = [...T.pts.values()]; + T.lastMidY = (ps[0].y + ps[1].y) / 2; + T.lastDist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y); + T.scrollAcc = 0; + } +} +function touchMove(e) { + const rec = T.pts.get(e.pointerId); + if (!rec) return; + const dx = e.clientX - rec.x, dy = e.clientY - rec.y; + rec.x = e.clientX; rec.y = e.clientY; + if ((T.mode === "two" || T.mode === "scroll" || T.mode === "zoom") && T.pts.size >= 2) { + const ps = [...T.pts.values()]; + const mid = (ps[0].y + ps[1].y) / 2; + const dist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y); + const dMid = mid - T.lastMidY, dDist = dist - T.lastDist; + if (T.mode === "two") { // decide the gesture once, then commit + if (Math.abs(dDist) > Math.abs(dMid) * 1.3 && Math.abs(dDist) > 6) T.mode = "zoom"; + else if (Math.abs(dMid) > 6) T.mode = "scroll"; + } + if (T.mode === "zoom") { + const mx = (ps[0].x + ps[1].x) / 2, my = (ps[0].y + ps[1].y) / 2; + zoomBy(dist / (T.lastDist || dist), mx, my); + T.lastDist = dist; T.lastMidY = mid; + return; + } + if (T.mode === "scroll") { + T.scrollAcc += dMid; + T.lastMidY = mid; T.lastDist = dist; + while (Math.abs(T.scrollAcc) >= 26) { + const w = T.scrollAcc > 0 ? 1 : -1; + T.scrollAcc -= w * 26; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: w, hw: 0 }); + } + return; + } + T.lastMidY = mid; T.lastDist = dist; + return; + } + if (T.mode !== "move" && T.mode !== "drag") return; + if (Math.abs(dx) + Math.abs(dy) >= 1) { T.moved = true; tClearLP(); } + const k = tScale(); + T.vx = Math.max(0, Math.min((S.dims.w || 1) - 1, T.vx + (dx / k) * 1.15)); + T.vy = Math.max(0, Math.min((S.dims.h || 1) - 1, T.vy + (dy / k) * 1.15)); + tSendMove(); +} +function touchUp(e, cancelled) { + try { canvas.releasePointerCapture(e.pointerId); } catch {} + T.pts.delete(e.pointerId); + tClearLP(); + if (T.mode === "move" && !cancelled && !T.moved && + performance.now() - T.downT < 300 && T.pts.size === 0) { + tapClick(1); + T.lastTapT = performance.now(); // arms double-tap / tap-drag + } else if (T.mode === "drag" && T.pts.size === 0) { + S.buttons &= ~1; + const p = tXY(); + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 }); + } + if (T.pts.size === 0) T.mode = null; +} +// Mac trackpads emit many tiny pixel deltas per two-finger swipe; sending +// them raw truncated to zero on the host and most Windows apps ignore +// sub-notch wheel values anyway. Accumulate and emit WHOLE notches (±120 +// on the host side), carrying the remainder - trackpad now scrolls like a +// real wheel, and fast flicks scroll fast. +let wheelAccY = 0, wheelAccX = 0; +canvas.addEventListener("wheel", (e) => { + if (S.viewOnly || S.dead) return; + e.preventDefault(); + const p = canvasPos(e); + const unit = e.deltaMode === 1 ? 3 : e.deltaMode === 2 ? 1 : 100; + wheelAccY += -e.deltaY / unit; + wheelAccX += e.deltaX / unit; + // half-notch granularity (host sends ±60): same stream a Windows Precision + // Touchpad produces, so apps scroll smoothly and gentle swipes respond fast + const w = Math.trunc(wheelAccY * 2) / 2; + const hw = Math.trunc(wheelAccX * 2) / 2; + if (w || hw) { + wheelAccY -= w; + wheelAccX -= hw; + send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, + w: Math.max(-10, Math.min(10, w)), hw: Math.max(-10, Math.min(10, hw)) }); + } +}, { passive: false }); +canvas.addEventListener("contextmenu", (e) => e.preventDefault()); +// back/forward mouse buttons fire 'auxclick'; also stop the browser navigating +canvas.addEventListener("auxclick", (e) => { if (e.button === 3 || e.button === 4) e.preventDefault(); }); + +function flushMouse() { + if (!S.pendingMouse || S.dead || S.viewOnly) return; + // adapt the move rate to the link: flooding a 300ms+ mobile link with + // 120 msg/s just builds a queue and replays your hand in slow motion + const gap = S.rtt > 400 ? 50 : S.rtt > 200 ? 33 : S.rtt > 100 ? 16 : 8; + const now = performance.now(); + if (now - (S._lastMoveSend || 0) < gap - 1) return; + S._lastMoveSend = now; + send({ t: "i.mouse", ...S.pendingMouse, b: S.buttons, _mv: true }); // _mv: pure motion → unreliable channel + S.pendingMouse = null; +} + +// Keyboard is captured at the WINDOW level (capture phase), not the canvas, so +// it keeps working in fullscreen and after you click any toolbar button - the +// canvas losing focus no longer kills the keyboard (a major "keys don't work" +// cause). We only skip when the session isn't showing or you're typing into a +// real form field (chat, file path, connect screen). +function keyGoesToRemote() { + if (S.viewOnly || S.dead) return false; + if ($("#screenSession").hidden) return false; + const el = document.activeElement, tag = (el && el.tagName) || ""; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (el && el.isContentEditable)) + return false; + return true; +} +window.addEventListener("keydown", (e) => { + if (e.code === "F11") return; // let the browser toggle fullscreen + if (!keyGoesToRemote()) return; + // Mac client: let Cmd+V fall through untouched so the browser fires the + // 'paste' event below - we then push the Mac clipboard and paste remotely. + // (Works even in Safari, which blocks background clipboard polling.) + if (IS_MAC_CLIENT && e.metaKey && e.code === "KeyV" && !e.altKey && !e.shiftKey) return; + e.preventDefault(); // keep Ctrl+C/V/A/W/Tab etc. from hitting the browser + const kc = mapKey(e.code); + S.heldKeys.add(kc); + send({ t: "i.key", c: kc, d: true }); +}, true); +window.addEventListener("keyup", (e) => { + if (S.dead) return; + const kc = mapKey(e.code); + const had = S.heldKeys.delete(kc); + if (!had && !keyGoesToRemote()) return; // still release keys we had pressed + e.preventDefault(); + send({ t: "i.key", c: kc, d: false }); +}, true); +// Mac keyboards: Cmd acts as Ctrl on the remote Windows PC (RustDesk-style), +// so Cmd+C / Cmd+V / Cmd+A / Cmd+Z do what a Mac user expects instead of +// hitting the Windows key. +const IS_MAC_CLIENT = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); +function mapKey(code) { + if (IS_MAC_CLIENT) { + if (code === "MetaLeft") return "ControlLeft"; + if (code === "MetaRight") return "ControlRight"; + } + return code; +} +// Gesture-based paste path (Safari-proof): grab the pasted text directly from +// the event, push it to the remote clipboard, then tap Ctrl+V over there. +window.addEventListener("paste", (e) => { + if (!keyGoesToRemote()) return; + const txt = e.clipboardData && e.clipboardData.getData("text"); + if (!txt) return; + e.preventDefault(); + S.lastRemoteClip = txt; + send({ t: "clip.set", text: txt, origin: "viewer" }); + setTimeout(() => { + send({ t: "i.key", c: "ControlLeft", d: true }); + send({ t: "i.key", c: "KeyV", d: true }); + send({ t: "i.key", c: "KeyV", d: false }); + send({ t: "i.key", c: "ControlLeft", d: false }); + }, 140); +}, true); +// release every held key when focus leaves / tab hides, so modifiers never get +// "stuck" down on the remote (classic RustDesk/TeamViewer issue after Alt+Tab) +window.addEventListener("blur", releaseAllKeys); +document.addEventListener("visibilitychange", () => { if (document.hidden) releaseAllKeys(); }); +document.addEventListener("fullscreenchange", () => { + const c = $("#canvas"); if (c && !$("#screenSession").hidden) c.focus(); +}); +function releaseAllKeys() { + if (!S.heldKeys || !S.heldKeys.size) return; + for (const code of S.heldKeys) send({ t: "i.key", c: code, d: false }); + S.heldKeys.clear(); +} + +/* ------------------------------------------------------------- toolbar */ +$("#btnDisconnect").onclick = () => { + // an EXPLICIT disconnect: don't auto-resume afterwards + S.userEnd = true; + try { localStorage.removeItem("nd.resume"); } catch {} + try { S.ws.close(); } catch {} +}; +$("#selMonitor").addEventListener("change", () => { + localStorage.setItem("nd.lastmon", $("#selMonitor").value); +}); +$("#selQuality").addEventListener("change", () => { + localStorage.setItem("nd.lastq", $("#selQuality").value); +}); +const _origQChange = $("#selQuality").onchange; +$("#selQuality").onchange = (e) => { + localStorage.setItem("nd.lastq", $("#selQuality").value); + if (_origQChange) _origQChange(e); +}; +$("#selMonitor").onchange = () => send({ t: "monitor.switch", mon: parseInt($("#selMonitor").value, 10) }); +function setViewOnly(on) { + S.viewOnly = on; + $("#btnViewOnly span").textContent = on ? "Input off" : "Input on"; + $("#btnViewOnly").classList.toggle("active", on); + canvas.classList.toggle("viewonly", on); + if (S.curId != null) applyCursorShape(S.curId); // native cursor on/off with input + // HOST-ENFORCED: the engine drops input while on (defense in depth under + // this local toggle — a modified client couldn't inject either). + if (!S.dead) send({ t: "view.only", on }); +} +$("#btnViewOnly").onclick = () => { + if (S.inputDeniedByHost) return; // host policy wins + setViewOnly(!S.viewOnly); +}; +$("#btnFullscreen").onclick = async () => { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await $("#stage").requestFullscreen(); + } catch {} +}; +$("#btnKbdLock").onclick = async () => { + if (S.kbdLocked) { + try { await navigator.keyboard.unlock(); } catch {} + S.kbdLocked = false; + } else { + try { + if (!document.fullscreenElement) await $("#stage").requestFullscreen(); + await navigator.keyboard.lock(); + S.kbdLocked = true; + showBanner("Keyboard locked: system keys (Alt+Tab, Win, Ctrl+W) now go to the remote PC.", true, 2600); + } catch { + showBanner("Keyboard lock needs Chrome/Edge and fullscreen.", false, 2600); + } + } + $("#btnKbdLock").classList.toggle("active", S.kbdLocked); +}; + +/* clipboard sync */ +$("#btnClipboard").onclick = async () => { + S.clipSync = !S.clipSync; + $("#btnClipboard").classList.toggle("active", S.clipSync); + if (S.clipSync) { + try { await navigator.clipboard.readText(); } // triggers the permission prompt + catch { showBanner("Clipboard permission denied by browser.", false, 2200); } + } +}; +async function onRemoteClip(m) { + S.lastRemoteClip = m.text || ""; + if (S.clipSync && m.text) { + try { await navigator.clipboard.writeText(m.text); } catch {} + } +} + +// The host copied an IMAGE: write it to the browser clipboard as a PNG +// ClipboardItem so the user can paste it locally. Requires the clipboard +// permission + a secure context (netdesk.space is https), and a recent +// browser; failures are silent (the feature degrades, nothing breaks). +async function onRemoteClipImage(m) { + if (!S.clipSync || !m.png) return; + try { + const bytes = b64ToBytes(m.png); + const blob = new Blob([bytes], { type: "image/png" }); + if (navigator.clipboard && window.ClipboardItem) { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + } + } catch {} +} + +/* ------------------------------------------------------------- remote cursor overlay */ +// fallback arrow for the touch overlay when the host could only name the cursor +const ARROW_SVG = "data:image/svg+xml;utf8," + encodeURIComponent( + '' + + ''); +function onCursorShape(m) { + S.curShapes = S.curShapes || {}; + // png = exact bitmap (custom cursors); css = a standard cursor's name, which the + // OS pointer renders natively and which arrives even when the bitmap can't + S.curShapes[m.id] = { url: m.png ? "data:image/png;base64," + m.png : ARROW_SVG, + w: m.w || 24, h: m.h || 24, hx: m.hx || 0, hy: m.hy || 0, css: m.css || null }; + if (S.curId === m.id) applyCursorShape(m.id); +} +function onCursorPos(m) { + const el = $("#remoteCursor"); + if (!el) return; + if (m.hidden) { el.hidden = true; $("#stage").classList.remove("remote-cursor"); return; } + if (S.curId !== m.id) { + S.curId = m.id; + if ((S.curShapes || {})[m.id]) applyCursorShape(m.id); + } + // while the local pointer is actively driving, ignore the host's laggy + // position echoes (they arrive ~RTT late) - shape/id updates were kept above + if (S._localCurTs && performance.now() - S._localCurTs < 400) return; + if (nativeCur()) { + // someone at the remote PC moved its mouse: show where it went as an + // overlay next to our own (parked) pointer - but never as a duplicate of + // our pointer sitting right there + const lp = S._localPos; + const far = !lp || Math.abs(lp.x - m.x) + Math.abs(lp.y - m.y) > 12; + const shp = (S.curShapes || {})[m.id]; + const onScreen = m.x >= 0 && m.y >= 0 && m.x < (S.dims.w || 1) && m.y < (S.dims.h || 1); + if (!far || !shp || !onScreen) { el.hidden = true; return; } + S.curPos = { x: m.x, y: m.y }; + if (el.dataset.id != String(m.id)) applyCursorShape(m.id); + positionCursor(); + el.hidden = false; // OS pointer stays visible (no remote-cursor class) + return; + } + S.curPos = { x: m.x, y: m.y }; + // hide the overlay when the cursor is off the monitor currently being viewed + if (m.x < 0 || m.y < 0 || m.x >= (S.dims.w || 1) || m.y >= (S.dims.h || 1)) { + el.hidden = true; $("#stage").classList.remove("remote-cursor"); return; + } + const shp = (S.curShapes || {})[m.id]; + if (!shp) return; // shape not arrived yet; wait for cursor.shape + if (el.dataset.id != String(m.id)) applyCursorShape(m.id); + positionCursor(); + el.hidden = false; + $("#stage").classList.add("remote-cursor"); // hide the local cursor over the canvas +} +function applyCursorShape(id) { + const shp = (S.curShapes || {})[id]; const el = $("#remoteCursor"); + if (!shp || !el) return; + el.src = shp.url; el.dataset.id = String(id); + // fine pointer: the remote shape becomes the real OS cursor (Chrome caps + // custom cursors at 128px; bigger ones fall back to the normal arrow) + if (!IS_TOUCH_CLIENT) { + const bitmap = shp.url.startsWith("data:image/png") && shp.w <= 128 && shp.h <= 128; + canvas.style.cursor = S.viewOnly ? "" : shp.css ? shp.css + : bitmap ? "url(" + shp.url + ") " + shp.hx + " " + shp.hy + ", auto" : ""; + } +} +function positionCursor() { + const el = $("#remoteCursor"); if (!el) return; + const shp = (S.curShapes || {})[S.curId]; if (!shp || !S.curPos) return; + const c = $("#canvas"), st = $("#stage"); + const r = c.getBoundingClientRect(), sr = st.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; // image px -> display px + const ox = r.left + (r.width - iw * k) / 2, oy = r.top + (r.height - ih * k) / 2; + const px = ox + S.curPos.x * k, py = oy + S.curPos.y * k; + // in Original (1:1) mode the stage scrolls; getBoundingClientRect already + // subtracts scroll, so add it back once to place the overlay in content space + el.style.left = (px - sr.left + st.scrollLeft - shp.hx * k) + "px"; + el.style.top = (py - sr.top + st.scrollTop - shp.hy * k) + "px"; + el.style.width = (shp.w * k) + "px"; + el.style.height = (shp.h * k) + "px"; +} +// keep the overlay aligned when the window/layout changes +window.addEventListener("resize", () => { if (!$("#screenSession").hidden) positionCursor(); }); +const IS_TOUCH_CLIENT = window.matchMedia && matchMedia("(pointer: coarse)").matches; +setInterval(async () => { + if (!S.clipSync || S.dead || !document.hasFocus()) return; + // iOS shows its native "Paste" permission bubble on EVERY programmatic + // clipboard read - polling made it pop nonstop. Touch devices use the + // gesture paste path (long-press → Paste in a text field, or the ⌨ panel). + if (IS_TOUCH_CLIENT) return; + try { + const txt = await navigator.clipboard.readText(); + if (txt && txt !== S.lastRemoteClip) { + S.lastRemoteClip = txt; + send({ t: "clip.set", text: txt, origin: "viewer" }); + } + } catch {} +}, 900); + +/* panels */ +function bindPanel(btn, panel, onClose) { + $(btn).onclick = () => { + const p = $(panel); + const show = p.hidden; + for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) $(q).hidden = true; + p.hidden = !show; + if (show && onClose) onClose(); + }; +} +bindPanel("#btnFiles", "#filePanel", () => send({ t: "file.roots" })); +bindPanel("#btnChat", "#chatPanel", () => { S.chatOpen = true; $("#chatText").focus(); }); +bindPanel("#btnSys", "#sysPanel", () => { + send({ t: "sys.cmd", a: "processes" }); + $("#chkLockOnEnd").checked = !!(S.info && S.info.lock_on_end); +}); +$("#btnFilesClose").onclick = () => $("#filePanel").hidden = true; +$("#btnChatClose").onclick = () => { $("#chatPanel").hidden = true; S.chatOpen = false; }; +$("#btnSysClose").onclick = () => $("#sysPanel").hidden = true; + +/* chat */ +function onChat(text, mine) { + const log = $("#chatLog"); + const div = document.createElement("div"); + div.className = "chat-msg" + (mine ? " me" : ""); + const who = document.createElement("div"); + who.className = "who"; + who.textContent = mine ? "You" : "Host"; + const body = document.createElement("div"); + body.textContent = text; + div.append(who, body); + log.append(div); + log.scrollTop = log.scrollHeight; +} +function sendChat() { + const txt = $("#chatText").value.trim(); + if (!txt) return; + $("#chatText").value = ""; + send({ t: "chat", text: txt, ts: Date.now() }); + onChat(txt, true); +} +$("#chatSend").onclick = sendChat; +$("#chatText").addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") sendChat(); +}); + +/* power */ +for (const b of document.querySelectorAll("#sysPanel [data-cmd]")) { + b.onclick = () => { + const cmd = b.dataset.cmd; + if (cmd === "reboot" || cmd === "shutdown") { + if (!confirm(`Really ${cmd} the remote computer?`)) return; + } + send({ t: "sys.cmd", a: cmd }); + }; +} +$("#btnSysRun").onclick = () => { + const c = $("#sysCmd").value.trim(); + if (c) send({ t: "sys.cmd", a: "run", cmd: c }); +}; + +/* lock-on-disconnect */ +$("#chkLockOnEnd").onchange = () => + send({ t: "set.cfg", lock_on_end: $("#chkLockOnEnd").checked }); + +/* privacy mode (monitor off) */ +$("#btnPrivacy").onclick = () => send({ t: "sys.cmd", a: "privacy" }); + +/* process manager */ +$("#btnProcRefresh").onclick = () => send({ t: "sys.cmd", a: "processes" }); +function renderProcesses(items) { + const box = $("#procList"); + box.innerHTML = ""; + for (const p of items || []) { + const row = document.createElement("div"); + row.className = "file-row"; + const icon = document.createElement("span"); + icon.textContent = "⚙"; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = p.name; + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = `${p.pid} · ${(p.mem / 1024).toFixed(0)} MB`; + const kill = document.createElement("span"); + kill.className = "acts"; + kill.style.display = "flex"; + const btn = document.createElement("button"); + btn.textContent = "⛔"; + btn.title = "End process"; + btn.onclick = (e) => { + e.stopPropagation(); + if (confirm(`End ${p.name} (PID ${p.pid})?`)) { + send({ t: "sys.cmd", a: "kill", pid: p.pid }); + setTimeout(() => send({ t: "sys.cmd", a: "processes" }), 700); + } + }; + kill.append(btn); + row.append(icon, name, meta, kill); + box.append(row); + } +} + +/* TCP tunnel (portal-gated): raw TCP to a host port over the session server */ +let tnlWs = null, tnlBytes = 0; +$("#btnTnlOpen").onclick = () => { + const st = $("#tnlStatus"); + if (tnlWs) { tnlWs.close(); tnlWs = null; st.textContent = "closed"; return; } + const port = parseInt($("#tnlPort").value, 10); + if (!port) { st.textContent = "enter a port"; return; } + const proto = location.protocol === "https:" ? "wss" : "ws"; + st.textContent = "connecting…"; + const ws = new WebSocket(`${proto}://${location.host}/tunnel/${S.hostId}`); + tnlWs = ws; + ws.binaryType = "arraybuffer"; + ws.onopen = () => { ws.send(JSON.stringify({ t: "open", port })); }; + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + const m = JSON.parse(ev.data); + if (m.t === "tunnel.ok") st.textContent = "open ✓ " + fmtBytes(tnlBytes); + if (m.t === "tunnel.err") st.textContent = "error: " + m.msg; + if (m.t === "tunnel.closed") st.textContent = "closed"; + return; + } + tnlBytes += ev.data.byteLength; + st.textContent = "open ✓ " + fmtBytes(tnlBytes) + " from host"; + }; + ws.onclose = () => { if (tnlWs === ws) { tnlWs = null; st.textContent = "disconnected"; } }; + ws.onerror = () => { st.textContent = "unauthorized? sign in to the portal"; }; +}; + +/* panel behavior: ESC closes, headers drag (TeamViewer-style floaters) */ +window.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + let closed = false; + for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) { + const p = $(q); + if (p && !p.hidden) { p.hidden = true; closed = true; } + } + if (closed) e.stopPropagation(); +}); +document.querySelectorAll(".panel").forEach((p) => { + const head = p.querySelector(".panel-head"); + if (!head) return; + let dragArmed = false, dx = 0, dy = 0, moved = false; + head.addEventListener("mousedown", (e) => { + if (e.target.closest("button")) return; + dragArmed = true; moved = false; + const r = p.getBoundingClientRect(); + p.style.left = r.left + "px"; + p.style.top = r.top + "px"; + p.style.right = "auto"; + dx = e.clientX - r.left; dy = e.clientY - r.top; + const move = (ev) => { + if (!dragArmed) return; + moved = true; + p.style.left = Math.min(window.innerWidth - 60, Math.max(60 - r.width, ev.clientX - dx)) + "px"; + p.style.top = Math.min(window.innerHeight - 40, Math.max(0, ev.clientY - dy)) + "px"; + }; + const up = () => { + dragArmed = false; + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + e.preventDefault(); + }); + head.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + if (moved) return; // it was a drag, not a click + if (p.classList.contains("docked")) p.classList.remove("docked"); + }); + // minimize buttons + const minBtn = head.querySelector("[data-min]"); + if (minBtn) minBtn.addEventListener("click", (e) => { + e.stopPropagation(); + p.classList.add("docked"); + }); + // clicking a docked panel restores it + p.addEventListener("click", () => { + if (p.classList.contains("docked")) p.classList.remove("docked"); + }); +}); + +/* session recording (client-side, WebM) */ +const REC_ICON_IDLE = ''; +const REC_ICON_ACTIVE = ''; +let recorder = null, recChunks = []; +// host audio source cycle: 0 off -> 1 system ("what you hear") -> 2 microphone +S.audioSrc = 0; +if ($("#btnAudio")) $("#btnAudio").onclick = async () => { + // Make sure the decoder exists and the AudioContext is RUNNING — browsers keep + // it suspended until a user gesture resumes it, so a resume() from inside + // playAudio (no gesture) is often ignored and you get silence. This click IS + // the gesture, so resume here. + if (!S.audioDecoder) setupAudio(); + try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {} + S.audioSrc = (S.audioSrc + 1) % 3; + send({ t: "audio.set", src: S.audioSrc }); + const names = ["Audio", "🔊 System", "🎤 Mic"]; + $("#audioLbl").textContent = names[S.audioSrc]; + $("#btnAudio").classList.toggle("active", S.audioSrc !== 0); + showBanner("Host audio: " + (S.audioSrc === 0 ? "off (click for system sound)" : S.audioSrc === 1 ? "system sound ON" : "microphone ON"), true, 1800); +}; + +// Browsers keep a freshly created AudioContext suspended until the user +// interacts with the page. Because you actively control the host (mouse/keys on +// the canvas), ANY of those gestures is enough — resume the audio engine on the +// first one so remote sound (auto-requested on connect) starts playing without +// needing a separate, specific button click. +["pointerdown", "mousedown", "keydown", "touchstart"].forEach((evt) => + window.addEventListener( + evt, + () => { try { if (S.audioCtx && S.audioCtx.state === "suspended") S.audioCtx.resume(); } catch (e) {} }, + { capture: true, passive: true } + ) +); + +$("#btnRec").onclick = () => { + if (recorder) { + recorder.stop(); + return; + } + try { + const stream = $("#canvas").captureStream(30); + recChunks = []; + recorder = new MediaRecorder(stream, { mimeType: "video/webm" }); + recorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); }; + recorder.onstop = () => { + const blob = new Blob(recChunks, { type: "video/webm" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `netdesk-${S.hostId}-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 30000); + recorder = null; + $("#btnRec").classList.remove("active", "rec-on"); + $("#btnRec").innerHTML = REC_ICON_IDLE; + showBanner("Recording saved.", true, 2500); + }; + recorder.start(); + $("#btnRec").classList.add("active", "rec-on"); + $("#btnRec").innerHTML = REC_ICON_ACTIVE; + showBanner("Recording…", true); + } catch (e) { + showBanner("Recording not supported in this browser: " + e.message, false, 3000); + recorder = null; + } +}; + +/* adaptive quality */ +let autoTimer = null; +const AUTO_ORDER = ["min", "low", "balanced", "high", "extreme"]; +function autoStep() { + if ($("#selQuality").value !== "auto" || S.dead) return; + const TOP = AUTO_ORDER.length - 1; + const queue = S.decoder ? S.decoder.decodeQueueSize : 0; + const idx = AUTO_ORDER.indexOf(S.autoLevel || "balanced"); + // Congestion = RTT INFLATION over the session's baseline, not absolute RTT. + // A stable 500ms link (far away / mobile) can still run high quality; what + // hurts is rtt growing above its own floor (queues building = link choked). + // RTT measured over the direct path when it's up, else over the relay + const rtt = (S._rtc && S._rtc.up && S._rtc.rttMs) ? S._rtc.rttMs : S.rtt; + if (rtt) S._rttBase = Math.min((S._rttBase || rtt) * 1.01 + 1, rtt); + const base = S._rttBase || 0; + const lag = Math.max(0, (rtt || 0) - base); + // congestion = inflation relative to the link's own floor (a 300ms + // intercontinental link jitters far more in absolute ms than a 20ms one) + const bad = lag > Math.max(120, base * 0.6) || queue > 8; + const good = lag < Math.max(40, base * 0.2) && queue < 2; + // hysteresis: act only on a SUSTAINED condition, then cool down 8s - + // every change rebuilds the encoder (keyframe), so flapping = stutter + S._autoBad = bad ? (S._autoBad || 0) + 1 : 0; + S._autoGood = good ? (S._autoGood || 0) + 1 : 0; + if (performance.now() - (S._autoChangedAt || 0) < 8000) return; + let want = idx; + if (S._autoBad >= 3) want = Math.max(0, idx - (queue > 20 ? 2 : 1)); + else if (S._autoGood >= 6 && + S.fpsShown >= QUALITY[AUTO_ORDER[Math.min(idx + 1, TOP)]].fps - 5) + want = Math.min(TOP, idx + 1); + if (want !== idx) { S._autoChangedAt = performance.now(); S._autoBad = S._autoGood = 0; } + if (want !== idx) { + S.autoLevel = AUTO_ORDER[want]; + const q = QUALITY[S.autoLevel]; + send({ t: "set.quality", ...q }); + $("#tbStats").textContent = `auto → ${S.autoLevel}`; + } +} +$("#selQuality").addEventListener("change", () => { + S.autoLevel = null; + const v = $("#selQuality").value; + if (v === "auto") { + S.autoLevel = "balanced"; + send({ t: "set.quality", ...QUALITY.balanced }); + } else if (QUALITY[v]) { + // manual preset: actually apply it (was silently doing nothing before) + send({ t: "set.quality", ...QUALITY[v] }); + } +}); + +/* ------------------------------------------------------------- files */ +function setFileStatus(msg, isErr) { + const el = $("#fileStatus"); + el.textContent = msg; + el.style.color = isErr ? "#fda4a4" : ""; +} +function onFileList(m) { + const list = $("#fileList"); + list.innerHTML = ""; + $("#filePath").value = m.path || ""; + for (const it of m.items || []) { + const row = document.createElement("div"); + row.className = "file-row"; + const icon = document.createElement("span"); + icon.textContent = it.t === "d" ? "📁" : "📄"; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = it.n; + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = it.t === "d" ? "" : fmtBytes(it.s || 0); + const acts = document.createElement("span"); + acts.className = "acts"; + if (it.t === "f") { + const dl = document.createElement("button"); + dl.textContent = "⬇"; + dl.title = "download"; + dl.onclick = (e) => { e.stopPropagation(); downloadFile(joinPath(m.path, it.n), it.n, it.s); }; + acts.append(dl); + if (isPrintable(it.n)) { + const pr = document.createElement("button"); + pr.textContent = "🖨"; + pr.title = "print on this device"; + pr.onclick = (e) => { e.stopPropagation(); printFile(joinPath(m.path, it.n), it.n, it.s); }; + acts.append(pr); + } + } + const ren = document.createElement("button"); + ren.textContent = "✏"; + ren.title = "rename"; + ren.onclick = (e) => { + e.stopPropagation(); + const nn = prompt("New name", it.n); + if (nn) send({ t: "file.ren", src: joinPath(m.path, it.n), dst: joinPath(m.path, nn) }); + }; + const del = document.createElement("button"); + del.textContent = "🗑"; + del.title = "delete"; + del.onclick = (e) => { + e.stopPropagation(); + if (confirm(`Delete ${it.n}?`)) send({ t: "file.del", path: joinPath(m.path, it.n), dir: it.t === "d" }); + }; + acts.append(ren, del); + row.append(icon, name, meta, acts); + if (it.t === "d") row.ondblclick = () => send({ t: "file.list", path: joinPath(m.path, it.n) }); + else row.ondblclick = () => downloadFile(joinPath(m.path, it.n), it.n, it.s); + list.append(row); + } +} +function joinPath(dir, name) { + if (!dir) return name; + if (/[\\/]$/.test(dir)) return dir + name; + return dir + "/" + name; +} +$("#btnFileGo").onclick = () => send({ t: "file.list", path: $("#filePath").value.trim() }); +$("#filePath").addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") send({ t: "file.list", path: $("#filePath").value.trim() }); +}); +$("#btnFileUp").onclick = () => { + let p = $("#filePath").value.replace(/[\\/][^\\/]*[\\/]?$/, ""); + send({ t: "file.list", path: p }); +}; +$("#btnFileHome").onclick = () => send({ t: "file.roots" }); +$("#btnFileMkdir").onclick = () => { + const n = prompt("Folder name"); + if (n) send({ t: "file.mkdir", path: joinPath($("#filePath").value, n) }); +}; + +// ---- IDM-style downloads: when the host advertises `fr` (file ranges), split +// the file into parallel ranged segments with windowed file.ack flow control, +// live progress, and resume of only the missing byte ranges after a reconnect. +// Hosts without `fr` get the legacy single full-file stream. +function downloadFile(path, name, size, opts) { + size = size || 0; + const print = !!(opts && opts.print); + const verb = print ? "Preparing to print " : "Downloading "; + const ranged = !!(S.info && S.info.fr) && size > 0; + if (!ranged) { + const fid = S.nextFid++; + S.transfers[fid] = { path, name, size, chunks: new Map(), ranged: false, print }; + setFileStatus(verb + name + "…"); + send({ t: "file.get", path, fid }); + return; + } + const nSegs = size >= 8 * 1024 * 1024 ? 4 : size >= 2 * 1024 * 1024 ? 2 : 1; + const segLen = Math.ceil(size / nSegs); + const tr = { path, name, size, chunks: new Map(), ranged: true, print, segs: [], rcvdTotal: 0, lastUi: 0, sidEpoch: S.sid }; + for (let i = 0; i < nSegs; i++) { + const off = i * segLen; + const len = Math.min(segLen, size - off); + if (len <= 0) break; + const fid = S.nextFid++; + tr.segs.push({ fid, off, len, rcvd: 0, reqRcvd: 0, lastAck: 0, done: false }); + S.transfers[fid] = tr; + send({ t: "file.get", path, fid, offset: off, len }); + } + setFileStatus(`${verb}${name} in ${tr.segs.length} segment(s)…`); +} +// Printable types the browser can render + print without a server-side driver. +function isPrintable(name) { + const ext = (String(name).split(".").pop() || "").toLowerCase(); + return [ + "pdf", + "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", + "txt", "log", "md", "csv", "json", "xml", "yml", "yaml", "ini", "conf", + "html", "htm", "js", "css", "py", "rs", "c", "h", "cpp", "java", "sh", "bat", "ps1" + ].includes(ext); +} +function printFile(path, name, size) { downloadFile(path, name, size, { print: true }); } +function escHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +// Open an already-downloaded blob in a hidden same-origin iframe and invoke the +// browser's native print dialog — this prints the HOST's file on the VIEWER's +// local printer, with zero driver install on either side. PDFs/images render +// directly; text-like files are wrapped in a monospace
.
+function printBlob(blob, name) {
+  const ext = (String(name).split(".").pop() || "").toLowerCase();
+  const url = URL.createObjectURL(blob);
+  const done = (ok) => {
+    setFileStatus(ok ? "Sent " + name + " to your printer" : "Could not print " + name, !ok);
+    setTimeout(() => URL.revokeObjectURL(url), 60000);
+  };
+  const frame = document.createElement("iframe");
+  frame.setAttribute("aria-hidden", "true");
+  frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
+  const fire = () => {
+    try { frame.contentWindow.focus(); frame.contentWindow.print(); done(true); }
+    catch (e) { try { window.open(url, "_blank"); done(true); } catch (e2) { done(false); } }
+  };
+  const imgExts = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
+  if (ext === "pdf") {
+    frame.src = url;
+    frame.onload = () => setTimeout(fire, 300);
+    document.body.appendChild(frame);
+  } else if (imgExts.includes(ext)) {
+    document.body.appendChild(frame);
+    const d = frame.contentWindow.document;
+    d.open();
+    d.write('' + escHtml(name) + '' +
+      '' +
+      '');
+    d.close();
+    done(true);
+  } else {
+    blob.text().then((txt) => {
+      document.body.appendChild(frame);
+      const d = frame.contentWindow.document;
+      d.open();
+      d.write('' + escHtml(name) + '' +
+        '
' + escHtml(txt) + '
'); + d.close(); + setTimeout(fire, 150); + }).catch(() => done(false)); + } +} +function onFileData(bytes, isUploadAck) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const fid = dv.getUint32(1, true); + const off = Number(dv.getBigUint64(5, true)); + const tr = S.transfers[fid]; + if (!tr) return; + const data = bytes.subarray(13); + tr.chunks.set(off, data); + if (!tr.ranged) return; + const seg = tr.segs.find((s) => s.fid === fid); + if (seg) { + seg.rcvd += data.length; + seg.reqRcvd += data.length; + // Advance the host's send window roughly every 1 MB per segment. + if (seg.reqRcvd - seg.lastAck >= 1024 * 1024) { + seg.lastAck = seg.reqRcvd; + send({ t: "file.ack", fid, rcvd: seg.reqRcvd }); + } + // Completion is driven by BYTE COUNT here, NOT by file.done: the control-lane + // file.done can arrive before the last data chunks over the media lane, so + // gating completion on it raced and segments never closed even though every + // byte arrived. Once a segment has all its bytes, finalize it. + if (!seg.done && seg.rcvd >= seg.len) segComplete(tr, seg, fid); + } + tr.rcvdTotal += data.length; + const nowMs = performance.now(); + if (nowMs - tr.lastUi > 300) { + tr.lastUi = nowMs; + const pct = Math.min(100, Math.round((tr.rcvdTotal / Math.max(1, tr.size)) * 100)); + setFileStatus(`Downloading ${tr.name}… ${pct}% (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`); + } +} +function segComplete(tr, seg, fid) { + if (seg.done || seg.rcvd < seg.len) return; + seg.done = true; + try { send({ t: "file.ack", fid, rcvd: seg.reqRcvd }); } catch (e) {} // final window release + if (tr.segs.every((s) => s.done)) { + finishDownload(tr); + for (const s of tr.segs) delete S.transfers[s.fid]; + } +} +function finishDownload(tr) { + const parts = [...tr.chunks.keys()].sort((a, b) => a - b).map((k) => tr.chunks.get(k)); + const blob = new Blob(parts); + if (tr.print) { printBlob(blob, tr.name || "file"); return; } + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = tr.name || "file"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 30000); + setFileStatus("Saved " + a.download + " (" + fmtBytes(blob.size) + ")"); +} +function onFileDone(m) { + const tr = S.transfers[m.fid]; + if (!tr) return; + if (!tr.ranged) { + tr.name = m.name || tr.name; + finishDownload(tr); + delete S.transfers[m.fid]; + return; + } + // file.done is a BACKUP completion trigger; byte-count completion in onFileData + // is primary (file.done can arrive before the last data chunks). + const seg = tr.segs.find((s) => s.fid === m.fid); + if (seg) segComplete(tr, seg, m.fid); +} +// After a reconnect (new sid), re-request ONLY the missing byte ranges of any +// unfinished ranged download — IDM-style resume. +function resumeTransfers() { + if (!(S.info && S.info.fr)) return; + const seen = new Set(); + for (const k of Object.keys(S.transfers)) { + const tr = S.transfers[k]; + if (!tr || !tr.ranged || seen.has(tr)) continue; + seen.add(tr); + if (tr.sidEpoch === S.sid) continue; + tr.sidEpoch = S.sid; + for (const seg of tr.segs) { + if (seg.done || seg.rcvd >= seg.len) { seg.done = true; continue; } + delete S.transfers[seg.fid]; + seg.fid = S.nextFid++; + seg.reqRcvd = 0; + seg.lastAck = 0; + S.transfers[seg.fid] = tr; + send({ t: "file.get", path: tr.path, fid: seg.fid, offset: seg.off + seg.rcvd, len: seg.len - seg.rcvd }); + } + setFileStatus(`Resuming ${tr.name}… (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`); + } +} +function onFileOk(m) { + setFileStatus(m.msg || "ok"); + if (m.refresh) send({ t: "file.list", path: $("#filePath").value }); +} + +async function uploadFile(file, destPath, viaBanner) { + const fid = S.nextFid++; + // no explicit target and no folder open in the Files panel -> the host + // drops it on the remote DESKTOP (TeamViewer behaviour) + const path = (destPath || joinPath($("#filePath").value || "", file.name)).replace(/\//g, "\\"); + setFileStatus(`Uploading ${file.name} (${fmtBytes(file.size)})…`); + if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… 0%`, true, 0); + send({ t: "file.put", path, size: file.size, fid }); + const CHUNK = 256 * 1024; + for (let off = 0; off < file.size || off === 0; off += CHUNK) { + const blob = file.slice(off, off + CHUNK); + const head = new Uint8Array(13); + new DataView(head.buffer).setUint32(1, fid, true); + new DataView(head.buffer).setBigUint64(5, BigInt(off), true); + const part = new Uint8Array(await blob.arrayBuffer()); + const msg = new Uint8Array(13 + part.length); + msg.set(head, 0); + msg.set(part, 13); + msg[0] = 0xf2; + await sendBinary(msg); + const pct = Math.min(100, Math.round(((off + part.length) / Math.max(1, file.size)) * 100)); + setFileStatus(`Uploading ${file.name}… ${pct}%`); + if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… ${pct}%`, true, 0); + // Real backpressure instead of a fixed sleep: run flat-out while the socket + // drains, pause only when >4 MB is buffered. Much faster on fast links, + // bounded memory on slow ones (IDM-style windowing for uploads). + while (S.ws && S.ws.readyState === 1 && S.ws.bufferedAmount > 4 * 1024 * 1024) { + await new Promise((r) => setTimeout(r, 10)); + } + if (file.size === 0) break; + } + if (viaBanner) showBanner(`✓ ${file.name} delivered to the remote Desktop`, true, 3200); +} +$("#btnFileUpload").onclick = () => $("#fileInput").click(); +$("#fileInput").onchange = async () => { + for (const f of $("#fileInput").files) await uploadFile(f); + $("#fileInput").value = ""; + send({ t: "file.list", path: $("#filePath").value }); +}; +$("#btnDirUpload").onclick = () => $("#dirInput").click(); +$("#dirInput").onchange = async () => { + const files = [...$("#dirInput").files]; + const base = $("#filePath").value || ""; + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const rel = (f.webkitRelativePath || f.name).split("/").slice(1).join("\\") || f.name; + const dest = base ? `${base}\\${rel}` : rel.replace(/\\[^\\]+$/, ""); + setFileStatus(`Folder upload ${i + 1}/${files.length}: ${rel}`); + await uploadFile(f, dest); + } + $("#dirInput").value = ""; + send({ t: "file.list", path: $("#filePath").value }); +}; +const stage = $("#stage"); +stage.addEventListener("dragover", (e) => { e.preventDefault(); $("#dropHint").hidden = false; }); +stage.addEventListener("dragleave", () => { $("#dropHint").hidden = true; }); +stage.addEventListener("drop", async (e) => { + e.preventDefault(); + $("#dropHint").hidden = true; + for (const f of e.dataTransfer.files) await uploadFile(f, null, true); +}); + +/* ------------------------------------------------------------- misc UI */ +function setState(txt, cls) { + const el = $("#tbState"); + el.textContent = txt; + el.className = "state " + (cls || ""); +} +let bannerTimer = null; +function showBanner(msg, isInfo, ms) { + const b = $("#banner"); + b.textContent = msg; + b.className = "banner" + (isInfo ? " info" : ""); + b.hidden = false; + if (bannerTimer) clearTimeout(bannerTimer); + if (ms) bannerTimer = setTimeout(hideBanner, ms); +} +function hideBanner() { $("#banner").hidden = true; } + +function updateStats() { + autoStep(); + let limitTxt = "", rem = Infinity; + if (S.limit) { + rem = Math.max(0, S.limit - (now() - (S.startedAt || now())) / 1000); + const mm = String(Math.floor(rem / 60)).padStart(2, "0"); + const ss = String(Math.floor(rem % 60)).padStart(2, "0"); + limitTxt = ` · ${mm}:${ss} left`; + } + const mbps = ((S.bytesVideo * 8) / 1e6 / Math.max(0.001, (now() - S.lastFpsTick) / 1000 + 1)).toFixed(1); + S.bytesVideo = 0; + const el = $("#tbStats"); + el.textContent = + `${S.dims.w}×${S.dims.h} · ${S.fpsShown}fps · ~${mbps} Mbps · ${S.rtt}ms${S.e2e ? " · 🔒E2E" : ""}${limitTxt}`; + el.classList.toggle("limit-warn", !!S.limit && rem < 300); + // live connection-quality tint (green / amber / red by round-trip latency) + el.dataset.q = S.rtt < 80 ? "good" : S.rtt < 180 ? "ok" : "bad"; +} + +function teardownMedia() { + if (S.latestFrame) { try { S.latestFrame.close(); } catch {} S.latestFrame = null; } + if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; } + if (S.audioDecoder) { try { S.audioDecoder.close(); } catch {} S.audioDecoder = null; } + if (S.audioCtx) { try { S.audioCtx.close(); } catch {} S.audioCtx = null; } + try { stopVoice(); } catch {} + S.started = false; +} + +// TeamViewer-style collapse: ▲ hides the docked bar (video gets the full +// height), a tiny ▼ tab at top-center brings it back +(function () { + var tab = document.getElementById("tbTab"), tb = document.getElementById("toolbar"), + show = document.getElementById("tbShow"); + if (tab && tb) tab.addEventListener("click", function (e) { + e.stopPropagation(); + tb.classList.add("collapsed"); + if (show) show.hidden = false; + }); + if (show && tb) show.addEventListener("click", function (e) { + e.stopPropagation(); + tb.classList.remove("collapsed"); + show.hidden = true; + }); +})(); + +/* ============== TeamViewer-style session menus (Actions / View / Extras) == */ +(function () { + const menus = ["mActions", "mView", "mComm", "mExtras"]; + function closeMenus() { + menus.forEach((id) => { const m = $("#" + id); if (m) m.hidden = true; }); + document.querySelectorAll(".menu-btn.open").forEach((b) => b.classList.remove("open")); + } + function syncMenuState() { + const le = $("#miLockEnd"); + if (le) le.classList.toggle("on", !!(S.info && S.info.lock_on_end)); + document.querySelectorAll("#mView .mradio").forEach((r) => + r.classList.toggle("on", (S.scaleMode || "fit") === r.dataset.scale)); + } + document.querySelectorAll(".menu-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const m = $("#" + btn.dataset.menu); + const wasOpen = m && !m.hidden; + closeMenus(); + if (!m || wasOpen) return; + syncMenuState(); + // fixed positioning: never clipped by the bar, clamped to the viewport + const br = btn.getBoundingClientRect(); + const tbr = document.getElementById("toolbar").getBoundingClientRect(); + m.style.top = (tbr.bottom + 4) + "px"; + m.style.left = Math.max(4, br.left - 6) + "px"; + m.hidden = false; + requestAnimationFrame(() => { + m.style.left = Math.max(4, Math.min(parseFloat(m.style.left) || 4, + window.innerWidth - m.offsetWidth - 6)) + "px"; + }); + btn.classList.add("open"); + if (btn.dataset.menu === "mView" && !window.__modesLoaded) loadModes(); + }); + }); + document.addEventListener("click", closeMenus); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }); + // collapsing the toolbar must also dismiss any open menu (tbTab stops propagation) + const _tab = $("#tbTab"); if (_tab) _tab.addEventListener("click", closeMenus); + menus.forEach((id) => { const m = $("#" + id); if (m) m.addEventListener("click", (e) => e.stopPropagation()); }); + + /* ---- Actions ---- */ + const miWol = $("#miWol"); + if (miWol) miWol.addEventListener("click", () => { + const mac = prompt( + "Wake-on-LAN\n\nEnter the MAC address of the PC to wake (e.g. AA:BB:CC:DD:EE:FF).\n" + + "This computer will broadcast the wake signal on its local network, so it must be on the same LAN as the sleeping PC."); + if (mac && mac.trim()) { + send({ t: "sys.cmd", a: "wol", mac: mac.trim() }); + showBanner("Sending wake signal to " + mac.trim() + "…", true, 2200); + } + }); + document.querySelectorAll(".mi[data-sys]").forEach((mi) => { + mi.addEventListener("click", () => { + const q = mi.dataset.confirm; + if (q && !window.confirm(q)) return; + send({ t: "sys.cmd", a: mi.dataset.sys }); + closeMenus(); + }); + }); + const miLockEnd = $("#miLockEnd"); + if (miLockEnd) miLockEnd.addEventListener("click", () => { + const next = !(S.info && S.info.lock_on_end); + S.info = Object.assign({}, S.info || {}, { lock_on_end: next }); + send({ t: "set.cfg", lock_on_end: next }); + const c = $("#chkLockOnEnd"); if (c) c.checked = next; + syncMenuState(); + }); + const miInfo = $("#miInfo"); + if (miInfo) miInfo.addEventListener("click", () => { + closeMenus(); + const dur = S._t0 ? Math.round((Date.now() - S._t0) / 1000) : 0; + const rows = [ + ["Partner ID", ($("#inId") || {}).value || "—"], + ["Remote resolution", (S.dims ? S.dims.w + " × " + S.dims.h : "—")], + ["Encryption", "ECDH P-256 + AES-256-GCM (end-to-end)"], + ["Live stats", ($("#tbStats") || {}).textContent || "—"], + ["Session time", Math.floor(dur / 60) + "m " + (dur % 60) + "s"], + ["Lock on session end", (S.info && S.info.lock_on_end) ? "on" : "off"], + ["Clipboard allowed", (S.info && S.info.perms && S.info.perms.clipboard === false) ? "no" : "yes"], + ["File transfer allowed", (S.info && S.info.perms && S.info.perms.files === false) ? "no" : "yes"], + ]; + let box = $("#infoBox"); + if (!box) { + box = document.createElement("div"); + box.id = "infoBox"; box.className = "tmenu"; + box.style.cssText = "position:fixed;top:70px;left:50%;transform:translateX(-50%);min-width:340px;z-index:80"; + document.body.appendChild(box); + box.addEventListener("click", (e) => e.stopPropagation()); + } + box.innerHTML = '
Session information
' + + rows.map((r) => '
' + + r[0] + "" + String(r[1]).replace(/[<>&]/g, "") + "
").join("") + + '
'; + box.hidden = false; + $("#infoClose").onclick = () => { box.hidden = true; }; + }); + if (!S._t0) S._t0 = Date.now(); + + /* ---- View: scaling ---- */ + function applyScale(mode) { + S.scaleMode = mode; + try { localStorage.setItem("nd.scale", mode); } catch (e) {} + $("#stage").classList.toggle("orig", mode === "orig"); + syncMenuState(); + } + document.querySelectorAll("#mView .mradio").forEach((r) => + r.addEventListener("click", () => { applyScale(r.dataset.scale); closeMenus(); })); + try { applyScale(localStorage.getItem("nd.scale") || "fit"); } catch (e) { applyScale("fit"); } + + /* ---- View: remote screen resolution ---- */ + function loadModes() { send({ t: "sys.cmd", a: "modes" }); } + // delegate: the #miModesLoad button is recreated each session (resetSessionState) + const modesWrap = $("#miModes"); + if (modesWrap) modesWrap.addEventListener("click", (e) => { + if (e.target && e.target.id === "miModesLoad") loadModes(); + }); + function renderModes(m) { + window.__modesLoaded = true; + const wrap = $("#miModes"); + if (!wrap) return; + wrap.textContent = ""; + const cur = m.current || []; + (m.items || []).slice(0, 18).forEach(function (wh) { + // coerce to integers - never trust host-sent values into the DOM (no innerHTML) + const w = parseInt(wh[0], 10), h = parseInt(wh[1], 10); + if (!(w > 0 && h > 0)) return; + const b = document.createElement("button"); + b.className = "mi mradio" + (w === cur[0] && h === cur[1] ? " on" : ""); + b.textContent = w + " × " + h; + const tick = document.createElement("span"); + tick.className = "mtick"; tick.textContent = "✓"; + b.appendChild(tick); + b.onclick = function () { + if (!window.confirm("Change the remote screen to " + w + " × " + h + "?")) return; + send({ t: "sys.cmd", a: "setmode", w: w, h: h }); + closeMenus(); + }; + wrap.appendChild(b); + }); + if (!wrap.children.length) { + const d = document.createElement("div"); + d.className = "mi"; d.style.cssText = "cursor:default;color:#8b96a6"; + d.textContent = "No modes reported"; + wrap.appendChild(d); + } + } + + /* ---- View: wallpaper / refresh ---- */ + const miWp = $("#miWallpaper"); + if (miWp) miWp.addEventListener("click", () => send({ t: "sys.cmd", a: "wallpaper" })); + const miRf = $("#miRefresh"); + if (miRf) miRf.addEventListener("click", () => { send({ t: "keyframe" }); showBanner("Screen refreshed", true, 1400); closeMenus(); }); + + /* ---- Extras ---- */ + const click = (sel) => { const b = $(sel); if (b) b.click(); }; + const wire = (id, fn) => { const el = $(id); if (el) el.addEventListener("click", () => { fn(); closeMenus(); }); }; + wire("#miFiles", () => click("#btnFiles")); + wire("#miChat", () => click("#btnChat")); + wire("#miRec", () => click("#btnRec")); + wire("#miShot", () => { + const c = $("#canvas"); + if (!c || !c.width) return showBanner("No frame yet", false, 1600); + c.toBlob(function (blob) { + if (!blob) return; + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "netdesk-" + (($("#inId") || {}).value || "shot") + "-" + + new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) + ".png"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + showBanner("Screenshot saved", true, 1600); + }, "image/png"); + }); + + /* ---- results from the host ---- */ + window.onSysRes = function (m) { + if (m.kind === "modes") { + if (m.ok) renderModes(m); else showBanner("Resolutions: " + (m.msg || "unavailable"), false, 2600); + return true; + } + if (m.kind === "wallpaper") { + if (miWp) miWp.classList.toggle("on", !!m.on); + showBanner(m.msg || "ok", m.ok, 1800); + return true; + } + if (m.kind === "setmode") { + showBanner(m.ok ? "Resolution changed" : "Failed: " + m.msg, m.ok, 2400); + if (m.ok) { window.__modesLoaded = false; send({ t: "keyframe" }); } + return true; + } + return false; // cad + everything else: generic banner + }; +})(); + +/* ============== mobile on-screen keyboard (⌨ Type button) ============== */ +(function () { + const osk = $("#oskInput"), btn = $("#btnOsk"); + if (!osk || !btn) return; + btn.addEventListener("click", () => { + osk.value = ""; + osk.style.pointerEvents = "auto"; + osk.focus({ preventScroll: true }); + }); + const SH = "!@#$%^&*()"; + const PUNCT = { "-": "Minus", "=": "Equal", "[": "BracketLeft", "]": "BracketRight", + "\\": "Backslash", ";": "Semicolon", "'": "Quote", ",": "Comma", ".": "Period", + "/": "Slash", "`": "Backquote" }; + const PUNCT_SH = { "_": "Minus", "+": "Equal", "{": "BracketLeft", "}": "BracketRight", + "|": "Backslash", ":": "Semicolon", "\"": "Quote", "<": "Comma", ">": "Period", + "?": "Slash", "~": "Backquote" }; + function keyOf(ch) { + if (/^[a-z]$/.test(ch)) return { c: "Key" + ch.toUpperCase(), sh: false }; + if (/^[A-Z]$/.test(ch)) return { c: "Key" + ch, sh: true }; + if (/^[0-9]$/.test(ch)) return { c: "Digit" + ch, sh: false }; + const i = SH.indexOf(ch); + if (i >= 0) return { c: "Digit" + ((i + 1) % 10), sh: true }; + if (ch === " ") return { c: "Space", sh: false }; + if (PUNCT[ch]) return { c: PUNCT[ch], sh: false }; + if (PUNCT_SH[ch]) return { c: PUNCT_SH[ch], sh: true }; + return null; + } + function tapKey(code, sh) { + if (sh) send({ t: "i.key", c: "ShiftLeft", d: true }); + send({ t: "i.key", c: code, d: true }); + send({ t: "i.key", c: code, d: false }); + if (sh) send({ t: "i.key", c: "ShiftLeft", d: false }); + } + osk.addEventListener("beforeinput", (ev) => { + ev.preventDefault(); + if (ev.inputType === "insertText" && ev.data) { + for (const ch of ev.data) { const k = keyOf(ch); if (k) tapKey(k.c, k.sh); } + } else if (ev.inputType === "deleteContentBackward") tapKey("Backspace", false); + else if (ev.inputType === "insertLineBreak") tapKey("Enter", false); + }); + osk.addEventListener("keydown", (ev) => { + const pass = ["Backspace", "Enter", "Tab", "Escape", "ArrowLeft", "ArrowRight", + "ArrowUp", "ArrowDown", "Delete", "Home", "End"]; + if (pass.indexOf(ev.key) >= 0) { + ev.preventDefault(); + tapKey(ev.code && ev.code !== "Unidentified" ? ev.code : ev.key, false); + } + }); + osk.addEventListener("blur", () => { osk.style.pointerEvents = "none"; }); +})(); + +/* ===== reference-exact bar: monitors & quality live in the View menu ===== */ +(function () { + const QLABELS = { auto: "Auto (adaptive)", low: "Low", balanced: "Balanced", + high: "High", extreme: "Extreme" }; + function radio(label, on, fn) { + const b = document.createElement("button"); + b.className = "mi mradio" + (on ? " on" : ""); + b.textContent = label; + const t = document.createElement("span"); t.className = "mtick"; t.textContent = "✓"; + b.appendChild(t); b.onclick = fn; + return b; + } + function refreshViewMenu() { + const mons = $("#miMons"), qual = $("#miQual"); + if (mons) { + mons.textContent = ""; + const sel = $("#selMonitor"); + [...sel.options].forEach((o) => { + mons.appendChild(radio(o.textContent || ("Display " + o.value), + sel.value === o.value, () => { + sel.value = o.value; + // dispatch alone fires BOTH the property handler and listeners + sel.dispatchEvent(new Event("change")); + refreshViewMenu(); + })); + }); + if (!sel.options.length) { + const d = document.createElement("div"); + d.className = "mi"; d.style.cssText = "cursor:default;color:#7c8898"; + d.textContent = "Connect to list displays"; + mons.appendChild(d); + } + } + if (qual) { + qual.textContent = ""; + const q = $("#selQuality"); + Object.keys(QLABELS).forEach((v) => { + qual.appendChild(radio(QLABELS[v], q.value === v, () => { + q.value = v; + q.dispatchEvent(new Event("change")); + refreshViewMenu(); + })); + }); + } + } + const vbtn = document.querySelector('.menu-btn[data-menu="mView"]'); + if (vbtn) vbtn.addEventListener("click", refreshViewMenu); + refreshViewMenu(); + // build badge: instantly tells us whether a tab runs the current UI + window.ND_BUILD = "v52"; + const foot = document.querySelector("#screenConnect .foot"); + if (foot) foot.textContent = "NetDesk viewer · build " + window.ND_BUILD; + const tb = $("#toolbar"); if (tb) tb.title = "NetDesk " + window.ND_BUILD; +})(); + +/* block remote user's input (RustDesk parity) */ +(function () { + const mi = $("#miBlockInput"); + if (mi) mi.addEventListener("click", () => send({ t: "sys.cmd", a: "block_input" })); + const prev = window.onSysRes; + window.onSysRes = function (m) { + if (m.kind === "block_input") { + if (mi) mi.classList.toggle("on", !!m.on); + showBanner((m.ok ? "" : "⚠ ") + (m.msg || ""), m.ok, 3000); + return true; + } + return prev ? prev(m) : false; + }; +})(); + +/* ===== monitor quick-switch button + Communicate menu (v44) ===== */ +(function () { + // --- monitor cycle: appears only when the host has 2+ displays --- + const btn = $("#btnMonCycle"), lbl = $("#monCycleLbl"), sel = $("#selMonitor"); + function syncMon() { + if (!btn || !sel) return; + const n = sel.options.length; + btn.hidden = n < 2; + if (n >= 2) { + const idx = Math.max(0, [...sel.options].findIndex(o => o.value === sel.value)); + lbl.textContent = (idx + 1) + "/" + n; + btn.title = "Switch monitor — now on " + + (sel.options[idx] ? sel.options[idx].textContent : ""); + } + } + if (btn) btn.addEventListener("click", () => { + const n = sel.options.length; + if (n < 2) return; + const idx = [...sel.options].findIndex(o => o.value === sel.value); + sel.value = sel.options[(idx + 1) % n].value; + sel.dispatchEvent(new Event("change")); // fires property handler + listeners once + syncMon(); + showBanner("🖥 " + sel.options[(idx + 1) % n].textContent, true, 1500); + }); + if (sel) sel.addEventListener("change", syncMon); + setInterval(syncMon, 1200); // options arrive with session.info + syncMon(); + + // --- Communicate menu --- + const miMsg = $("#miMsg"); + if (miMsg) miMsg.addEventListener("click", () => { + const b = $("#btnChat"); if (b) b.click(); + const inp = $("#chatText"); if (inp) setTimeout(() => inp.focus(), 120); + }); + const miSound = $("#miSound"); + function syncSound() { + // "on" means the host is actually sending (source != 0) AND we can play it. + if (miSound) miSound.classList.toggle("on", + !!(S.audioSrc && S.audioSrc !== 0 && S.audioCtx && S.audioCtx.state === "running")); + } + if (miSound) miSound.addEventListener("click", async () => { + // This toggles REMOTE (host) sound on/off. It must command the host + // (audio.set) — not merely suspend the local context — or the host never + // captures and "on" is silent. Mirrors the 🔊 toolbar button. + if (!S.audioDecoder) setupAudio(); + const turningOn = !(S.audioSrc && S.audioSrc !== 0); + try { + if (turningOn) { + S.audioSrc = 1; + if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); + send({ t: "audio.set", src: 1 }); + showBanner("Remote sound ON (system)", true, 1600); + } else { + S.audioSrc = 0; + send({ t: "audio.set", src: 0 }); + if (S.audioCtx && S.audioCtx.state === "running") await S.audioCtx.suspend(); + showBanner("Remote sound muted", true, 1600); + } + } catch (e) {} + const lbl = $("#audioLbl"); if (lbl) lbl.textContent = S.audioSrc === 0 ? "Audio" : "🔊 System"; + const b = $("#btnAudio"); if (b) b.classList.toggle("active", S.audioSrc !== 0); + syncSound(); + }); + const miVoice = $("#miVoice"); + if (miVoice) miVoice.addEventListener("click", () => { if (S.voiceOn) stopVoice(); else startVoice(); }); + updateVoiceUi(); + const cbtn = document.querySelector('.menu-btn[data-menu="mComm"]'); + if (cbtn) cbtn.addEventListener("click", syncSound); +})(); + +/* ============ WebRTC P2P fast path (video + input direct over UDP) ============ + Negotiated over the relay after the session is live. Same E2E-encrypted + bytes - only the pipe changes. The relay stays connected as fallback and + for files/clipboard/chat, so a failed P2P never costs the session. */ +(function () { + function rtcTeardown() { + const r = S._rtc; + S._rtc = null; S._rtcTried = false; + if (r && r.pc) { try { r.pc.close(); } catch (e) {} } + } + const _reset = resetSessionState; + resetSessionState = function () { rtcTeardown(); _reset(); }; + + // ask the server for ICE servers (STUN + the nearest TURN relay) + function fetchIce() { + return new Promise((res) => { + const fallback = [{ urls: ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"] }]; + const t = setTimeout(() => { S._iceWait = null; res(fallback); }, 2500); + S._iceWait = (servers) => { clearTimeout(t); S._iceWait = null; res(servers && servers.length ? servers : fallback); }; + send({ t: "rtc.config.req" }); + }); + } + window.__onRtcConfig = (m) => { if (S._iceWait) S._iceWait(m.iceServers); }; + + async function rtcConnect() { + if (S._rtcTried || S.dead || S.direct || !window.RTCPeerConnection) return; + S._rtcTried = true; + try { + const iceServers = await fetchIce(); + if (S.dead) return; + const pc = new RTCPeerConnection({ iceServers }); + // fresh frames beat late ones: unreliable-ish, 150ms max lifetime + const media = pc.createDataChannel("media", { ordered: false, maxPacketLifeTime: 150 }); + const ctl = pc.createDataChannel("ctl", { ordered: true }); + // pure mouse motion: unordered + no retransmits, so one lost packet on a + // lossy link never holds the pointer back (clicks/keys stay on "ctl") + const move = pc.createDataChannel("move", { ordered: false, maxRetransmits: 0 }); + media.binaryType = "arraybuffer"; + S._rtc = { pc, media, ctl, move, up: false, moveOk: false }; + // only engines that answer "move.ok" read the motion channel (older + // ones ignore its label, so motion must stay on "ctl" for them) + move.onmessage = (ev) => { try { if (JSON.parse(ev.data).t === "move.ok") S._rtc.moveOk = true; } catch (e) {} }; + media.onmessage = (ev) => { + const bytes = new Uint8Array(ev.data); + decodeQueue = decodeQueue.then(() => handleBinary(bytes)) + .catch((e) => console.error("p2p binary", e)); + }; + const maybeUp = () => { + const r = S._rtc; + if (r && r.media.readyState === "open" && r.ctl.readyState === "open" && !r.up) { + r.up = true; + updateStats(); + showBanner("⚡ Direct P2P connection active — lowest latency path", true, 3000); + } + }; + const down = () => { + const r = S._rtc; + if (r && r.up) { r.up = false; updateStats(); showBanner("P2P dropped — continuing over the relay", true, 2200); } + }; + media.onopen = maybeUp; ctl.onopen = maybeUp; + media.onclose = down; ctl.onclose = down; + pc.onconnectionstatechange = () => { + if (["failed", "disconnected", "closed"].indexOf(pc.connectionState) >= 0) down(); + }; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await new Promise((res) => { // non-trickle ICE, capped at 2.5s + if (pc.iceGatheringState === "complete") return res(); + const t = setTimeout(res, 2500); + pc.addEventListener("icegatheringstatechange", () => { + if (pc.iceGatheringState === "complete") { clearTimeout(t); res(); } + }); + }); + send({ t: "rtc.offer", sdp: pc.localDescription.sdp, ice: iceServers }); + } catch (e) { /* relay path continues untouched */ } + } + // which tier won? (direct P2P vs TURN relay) - read the selected pair + async function rtcRoute() { + const r = S._rtc; + if (!r || !r.up || !r.pc.getStats) return; + try { + const st = await r.pc.getStats(); + let pairId = null; + st.forEach((v) => { if (v.type === "transport" && v.selectedCandidatePairId) pairId = v.selectedCandidatePairId; }); + st.forEach((v) => { + if (!pairId && v.type === "candidate-pair" && v.nominated && v.state === "succeeded") pairId = v.id; + }); + const pair = pairId && st.get(pairId); + if (!pair) return; + const lc = st.get(pair.localCandidateId), rc = st.get(pair.remoteCandidateId); + r.route = ((lc && lc.candidateType === "relay") || (rc && rc.candidateType === "relay")) ? "TURN" : "P2P"; + if (pair.currentRoundTripTime) r.rttMs = Math.round(pair.currentRoundTripTime * 1000); + } catch (e) {} + } + setInterval(rtcRoute, 2000); + setInterval(() => { if (S.started && !S._rtcTried && !S.dead) rtcConnect(); }, 600); + + // latency-critical traffic rides the direct channel when it's up + const _send = send; + send = function (obj) { + const r = S._rtc; + const motion = obj._mv; delete obj._mv; + if (r && r.up && r.ctl.readyState === "open" && + (obj.t === "i.mouse" || obj.t === "i.key" || obj.t === "keyframe")) { + obj.sid = obj.sid || S.sid; + const ch = (motion && r.moveOk && r.move && r.move.readyState === "open") ? r.move : r.ctl; + try { ch.send(JSON.stringify(obj)); return true; } catch (e) {} + } + return _send(obj); + }; + + // link badge in the stats line + const _stats = updateStats; + updateStats = function () { + _stats(); + const el = $("#tbStats"); + if (el && el.textContent && S.started) { + const r = S._rtc; + el.textContent += (r && r.up) + ? (" · ⚡" + (r.route || "P2P") + (r.rttMs ? " " + r.rttMs + "ms" : "")) + : " · relay"; + } + }; +})(); + +/* ===== pinch view-zoom (mobile): transform on the canvas - every coordinate + path keeps working because it reads getBoundingClientRect, which already + reflects the transform. When zoomed, the viewport follows the cursor. ===== */ +const Z = { s: 1, ox: 0, oy: 0 }; +function applyView() { + const st = $("#stage"); + const vw = st.clientWidth, vh = st.clientHeight; + Z.s = Math.max(1, Math.min(4, Z.s)); + Z.ox = Math.min(0, Math.max(vw - vw * Z.s, Z.ox)); + Z.oy = Math.min(0, Math.max(vh - vh * Z.s, Z.oy)); + canvas.style.transformOrigin = "0 0"; + canvas.style.transform = (Z.s === 1 && !Z.ox && !Z.oy) ? "" : + "translate(" + Z.ox + "px," + Z.oy + "px) scale(" + Z.s + ")"; + try { positionCursor(); } catch (e) {} +} +function zoomBy(f, cx, cy) { + const sr = $("#stage").getBoundingClientRect(); + const px = cx - sr.left, py = cy - sr.top; + const ns = Math.max(1, Math.min(4, Z.s * f)); + const r = ns / Z.s; + Z.ox = px - (px - Z.ox) * r; + Z.oy = py - (py - Z.oy) * r; + Z.s = ns; + if (Z.s < 1.04) { Z.s = 1; Z.ox = 0; Z.oy = 0; } // snap back to full view + applyView(); +} +function zoomReset() { Z.s = 1; Z.ox = 0; Z.oy = 0; applyView(); } +function followCursor() { + if (Z.s <= 1) return; + const st = $("#stage"); + const vw = st.clientWidth, vh = st.clientHeight; + const r = canvas.getBoundingClientRect(), sr = st.getBoundingClientRect(); + const iw = S.dims.w || 1, ih = S.dims.h || 1; + const k = Math.min(r.width / iw, r.height / ih) || 1; + const cxv = (r.left - sr.left) + (r.width - iw * k) / 2 + T.vx * k; + const cyv = (r.top - sr.top) + (r.height - ih * k) / 2 + T.vy * k; + const mL = vw * 0.25, mR = vw * 0.75, mT = vh * 0.25, mB = vh * 0.75; + if (cxv < mL) Z.ox += mL - cxv; else if (cxv > mR) Z.ox -= cxv - mR; + if (cyv < mT) Z.oy += mT - cyv; else if (cyv > mB) Z.oy -= cyv - mB; + applyView(); +} + +/* SSO (SAML): reveal the "Sign in with SSO" button when the server has it enabled */ +(function () { + try { + fetch("/api/auth/sso", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (d) { + if (d && d.saml) { + var w = document.getElementById("ssoWrap"); if (w) w.hidden = false; + var b = document.getElementById("ssoBtn"); if (b) b.href = (d.login_url || "/saml/login") + "?RelayState=/app"; + // enforce: lead with SSO by de-emphasizing the password form + if (d.enforce) { + var sub = document.querySelector("#pane-in .auth-sub"); + if (sub) sub.textContent = "Your organization uses single sign-on. Continue with SSO."; + } + } + }).catch(function () {}); + } catch (e) {} +})(); diff --git a/server/web/contact.html b/server/web/contact.html new file mode 100644 index 0000000..92a42a4 --- /dev/null +++ b/server/web/contact.html @@ -0,0 +1,50 @@ + + + + + +Contact — NetDesk + + + + + + + + + + + + + + +
+
Contact

Get in touch

Sales, support, security or partnerships — we usually reply within one business day.

+
+
+
+
+
+
+ +

+
+
+ + + + +

Prefer to self-host and never talk to us? That's the whole point — just download it.

+
+
+
+ + + + + \ No newline at end of file diff --git a/server/web/download.html b/server/web/download.html new file mode 100644 index 0000000..c987e20 --- /dev/null +++ b/server/web/download.html @@ -0,0 +1,202 @@ + + + + + +Download — NetDesk + + + + + + + + + + + + + + + +
+
Download

Get NetDesk

Portable, no installation required. Double-click and go.

+
+
+
+

Windows 10 / 11 (x64)

+

The native NetDesk app — host panel and remote viewer in one window. No browser.

+ ⬇ Download Installer +

or the portable .zip — no install needed

+
+
+
+

Android 7+ (phone, tablet, BlueStacks)

+

Control your computers from your Android phone or tablet. Installs on devices and in BlueStacks.

+ ⬇ Download APK +

≈63 MB · all CPU types · Google Play soon

+
+
+
+

macOS (Apple Silicon M1–M5 & Intel)

+

Native build for Apple Silicon and Intel Macs. Control your Mac or connect from it.

+ Join the macOS beta +

Coming soon · notify me

+
+
+
+

No download needed on the viewer side — open the web client in any browser.

+

Self-hosting your own server? See the Self-Hosting Console and the deployment guide.

+
+
+ + +
+
One unified app

Host & remote control — together

This is NetDesk after you install it. The home screen is your host panel; type a Partner ID and the same window becomes the remote viewer. No separate apps, no browser required.

+ +
+ + + + \ No newline at end of file diff --git a/server/web/features.html b/server/web/features.html new file mode 100644 index 0000000..8737e9f --- /dev/null +++ b/server/web/features.html @@ -0,0 +1,41 @@ + + + + + +Features — NetDesk + + + + + + + + + + + + + + +
+
Features

Everything TeamViewer Pro does — self-hosted

+

A complete, production-grade remote desktop, running on infrastructure you own.

+
+

End-to-end encryption

AES-256-GCM with ephemeral ECDH P-256 keys, per frame. The relay only ever sees opaque bytes — even your own server can never watch the screen.

+

H.264 up to 1080p60

x264 ultrafast / zero-latency encoding, WebCodecs hardware decode in the browser, adaptive quality that steps with your network.

+

Unattended service access

Boot-time Windows service (SYSTEM): reachable at the logon screen and across UAC prompts, surviving reboots, no auto-logon needed.

+

Multi-monitor & real cursor

Switch displays live, see the actual remote cursor shape, keyboard-lock for system shortcuts, and full mouse + wheel + side buttons.

+

File transfer & folders

Browse, download, upload, rename, delete, make folders, drag-and-drop whole directories — atomic and end-to-end encrypted.

+

Clipboard, chat & recording

Two-way clipboard sync, in-session chat, and one-click session recording to a local .webm file.

+

Remote power & processes

Lock, sign out, reboot, shut down, run commands, and a full process manager to inspect and end tasks.

+

Zero-install web client

Connect from any browser on laptop or phone — nothing to install on the viewer side. Installable as a PWA.

+

2FA, accounts & devices

Optional TOTP two-factor, email accounts, and a "My Computers" device list that follows you across the web.

+
+ +
+ + + + + \ No newline at end of file diff --git a/server/web/host.js b/server/web/host.js new file mode 100644 index 0000000..fa20a47 --- /dev/null +++ b/server/web/host.js @@ -0,0 +1,307 @@ +/* NetDesk BROWSER HOST — share this screen (view-only) with an ID + password, + * no install. Speaks the exact NetDesk host protocol so any existing viewer + * (web or native app) can connect: reg.host -> session.challenge -> verify proof + * -> session.accept -> stream E2E-sealed H.264 (getDisplayMedia + WebCodecs). + * + * Hard limit (browser sandbox): VIEW-ONLY — a web page cannot inject OS mouse/ + * keyboard, so viewers see+hear this screen but cannot control it. Everything + * else (ID+password, end-to-end encryption, audio) works like the installed host. + * + * Reuses globals from app.js: enc, toHex, b64ToBytes, sha256Hex. + */ +(function () { + "use strict"; + const H = { + ws: null, id: null, pw: null, salt: null, iters: 200000, verifier: null, + sid: null, nonce: null, ecdh: null, e2eKey: null, authed: false, + stream: null, venc: null, aenc: null, vreader: null, areader: null, + forceKey: false, running: false, dims: { w: 0, h: 0 }, fcount: 0, + }; + const $ = (s) => document.querySelector(s); + + function supported() { + return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia && + window.VideoEncoder && window.MediaStreamTrackProcessor && window.WebSocket); + } + function rand9() { + let s = String(1 + Math.floor(Math.random() * 9)); + for (let i = 0; i < 8; i++) s += Math.floor(Math.random() * 10); + return s; + } + function genPw() { + const a = "abcdefghjkmnpqrstuvwxyz23456789"; + let s = ""; + const r = crypto.getRandomValues(new Uint8Array(10)); + for (let i = 0; i < 10; i++) s += a[r[i] % a.length]; + return s; + } + function b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } + function status(msg) { const e = $("#hostStatus"); if (e) e.textContent = msg; } + + async function deriveCreds(password) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const k = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]); + const dk = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: H.iters }, k, 256); + H.salt = salt; H.verifier = toHex(dk); + } + + async function start() { + if (!supported()) { status("This browser can't host — use Chrome/Edge (needs WebCodecs + screen capture)."); return; } + try { + // getDisplayMedia must be in the click gesture — prompt for the screen first. + H.stream = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: 30 }, audio: true, + }); + } catch (e) { status("Screen share cancelled."); return; } + H.id = $("#hostIdOut").textContent.trim() || rand9(); + H.pw = $("#hostPwOut").textContent.trim() || genPw(); + await deriveCreds(H.pw); + // the user clicked "Stop sharing" in the browser's share bar + H.stream.getVideoTracks()[0].addEventListener("ended", stop); + + const wsUrl = location.origin.replace(/^http/, "ws") + "/ws"; + H.ws = new WebSocket(wsUrl); + H.ws.binaryType = "arraybuffer"; + H.running = true; + H.ws.onopen = () => H.ws.send(JSON.stringify({ t: "reg.host", id: H.id, name: "Browser host", takeover: true })); + H.ws.onmessage = onMessage; + H.ws.onclose = () => { if (H.running) status("Disconnected from server."); }; + H.ws.onerror = () => status("Server connection error."); + $("#btnHostStart").hidden = true; + $("#btnHostStop").hidden = false; + status("Registering… share the ID + password below."); + } + + async function onMessage(ev) { + if (typeof ev.data !== "string") return onVoiceFrame(ev.data); // viewer mic (CHAN_VOICE) + let m; try { m = JSON.parse(ev.data); } catch { return; } + switch (m.t) { + case "host_status": + status(m.ok ? "✅ Live — anyone with the ID + password can view this screen." : ("Register failed: " + (m.msg || ""))); + break; + case "session.begin": return challenge(m); + case "session.auth": return auth(m); + case "keyframe": H.forceKey = true; break; + case "session.end": case "session.deny": case "viewer.left": + status("✅ Live — viewer disconnected; ready for the next."); + break; + // input / sys.cmd / clip.set / monitor.switch / file.* -> view-only: ignored + } + } + + async function challenge(m) { + H.sid = m.sid || toHex(crypto.getRandomValues(new Uint8Array(16))); + H.nonce = toHex(crypto.getRandomValues(new Uint8Array(16))); + H.ecdh = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + const pub = b64(new Uint8Array(await crypto.subtle.exportKey("raw", H.ecdh.publicKey))); + H.ws.send(JSON.stringify({ + t: "session.challenge", sid: H.sid, salt: b64(H.salt), iters: H.iters, + nonce: H.nonce, e2e: true, totp: false, pub, + })); + status("A viewer is connecting…"); + } + + async function auth(m) { + const expect = await sha256Hex(H.nonce + H.verifier); + if (m.proof !== expect) { + H.ws.send(JSON.stringify({ t: "session.deny", sid: H.sid, reason: "wrong password" })); + status("✅ Live — a viewer entered the wrong password (denied)."); + return; + } + // E2E: ECDH(host_priv, viewer_pub) -> SHA256(shared || "e2e") -> AES-GCM key + const viewerPub = await crypto.subtle.importKey("raw", b64ToBytes(m.pub), { name: "ECDH", namedCurve: "P-256" }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: viewerPub }, H.ecdh.privateKey, 256)); + const ctx = new Uint8Array(shared.length + 3); + ctx.set(shared, 0); ctx.set(enc.encode("e2e"), shared.length); + const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx)); + H.e2eKey = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); + + const vs = H.stream.getVideoTracks()[0].getSettings(); + H.dims = { w: (vs.width | 0) || 1280, h: (vs.height | 0) || 720 }; + const audio = H.stream.getAudioTracks().length > 0; + H.ws.send(JSON.stringify({ + t: "session.accept", sid: H.sid, + info: { w: H.dims.w, h: H.dims.h, name: "Browser host", e2e: true, audio, + monitors: [{ i: 1, w: H.dims.w, h: H.dims.h }], mon: 1 }, + })); + H.authed = true; + H.forceKey = true; + status("✅ Live — a viewer is now watching this screen."); + await startEncoders(); + } + + // ---- Voice call RECEIVE: the viewer's microphone arrives sealed as + // [CHAN_VOICE=0xa2][opus]. Decrypt, Opus-decode (WebCodecs) and play it on + // this browser-host's speakers, with the same adaptive jitter buffer and the + // REQUIRED planeIndex:0 copyTo the main viewer uses. + let vdec = null, vctx = null, vNext = 0, vLead = 0.12; + async function onVoiceFrame(buf) { + if (!H.e2eKey) return; + try { + const data = new Uint8Array(buf); + const pt = new Uint8Array(await crypto.subtle.decrypt( + { name: "AES-GCM", iv: data.subarray(0, 12) }, H.e2eKey, data.subarray(12))); + if (pt.length < 2 || pt[0] !== 0xa2) return; // CHAN_VOICE only + if (!vdec) setupVoiceDec(); + if (vdec && vdec.state !== "closed") { + vdec.decode(new EncodedAudioChunk({ + type: "key", timestamp: Math.round(performance.now() * 1000), data: pt.subarray(1), + })); + } + } catch (e) {} + } + function setupVoiceDec() { + if (!window.AudioDecoder) return; + try { + if (!vctx) vctx = new (window.AudioContext || window.webkitAudioContext)(); + vNext = 0; vLead = 0.12; + vdec = new AudioDecoder({ output: playVoice, error: () => { try { vdec = null; } catch (e) {} } }); + vdec.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 }); + } catch (e) { vdec = null; } + } + function playVoice(ad) { + const ctx = vctx; if (!ctx) { if (ad.close) ad.close(); return; } + if (ctx.state === "suspended") ctx.resume(); + const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate; + const b = ctx.createBuffer(n, len, rate); + const planar = (ad.format || "").includes("planar"); + for (let ch = 0; ch < n; ch++) { + const dst = b.getChannelData(ch); + if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" }); + else { const tmp = new Float32Array(len * n); ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; } + } + if (ad.close) ad.close(); + const now = ctx.currentTime; + if (vNext === 0) vNext = now + vLead; + else if (vNext < now) { vLead = Math.min(vLead + 0.04, 0.45); vNext = now + vLead; } + else if (vNext > now + vLead + 0.35) vNext = now + vLead; + const src = ctx.createBufferSource(); src.buffer = b; src.connect(ctx.destination); src.start(vNext); vNext += len / rate; + } + + async function sealSend(frameBytes) { + if (!H.e2eKey || !H.ws || H.ws.readyState !== 1) return; + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, H.e2eKey, frameBytes)); + const out = new Uint8Array(12 + ct.length); + out.set(nonce, 0); out.set(ct, 12); + H.ws.send(out.buffer); + } + + async function startEncoders() { + if (H.venc) return; // one viewer at a time (one encoder) + const vt = H.stream.getVideoTracks()[0]; + H.venc = new VideoEncoder({ + output: (chunk) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const frame = new Uint8Array(2 + data.length); + frame[0] = 0x01; // CHAN_VIDEO + frame[1] = chunk.type === "key" ? 1 : 0; // bit0 keyframe, codec 0 = H.264 + frame.set(data, 2); + sealSend(frame); + }, + error: (e) => console.error("video encoder", e), + }); + // Pick a supported H.264 config (profile/level varies by browser); keep + // annexb so the output has in-band SPS/PPS + start codes the viewer parses. + const cfgBase = { width: H.dims.w, height: H.dims.h, bitrate: 4_000_000, framerate: 30, avc: { format: "annexb" } }; + let chosen = null; + for (const codec of ["avc1.42E01F", "avc1.42001F", "avc1.4D401F", "avc1.640028", "avc1.42E01E"]) { + try { + const sup = await VideoEncoder.isConfigSupported(Object.assign({ codec }, cfgBase)); + if (sup && sup.supported) { chosen = sup.config || Object.assign({ codec }, cfgBase); break; } + } catch (e) { /* try next */ } + } + if (!chosen) { + status("This browser can't H.264-encode the screen — try Chrome/Edge."); + try { H.venc.close(); } catch { } H.venc = null; + return; + } + H.venc.configure(chosen); + const vproc = new MediaStreamTrackProcessor({ track: vt }); + H.vreader = vproc.readable.getReader(); + (async function pump() { + while (H.running && H.venc) { + let r; try { r = await H.vreader.read(); } catch { break; } + if (r.done) break; + const frame = r.value; + try { + if (H.venc && H.venc.state === "configured" && H.venc.encodeQueueSize < 3) { + const key = H.forceKey || (H.fcount % 120 === 0); + H.forceKey = false; + H.venc.encode(frame, { keyFrame: key }); + H.fcount++; + } + } catch (e) { /* encoder closed */ } + frame.close(); + } + })(); + + // audio (system/tab sound captured by getDisplayMedia), best-effort + const at = H.stream.getAudioTracks()[0]; + if (at && window.AudioEncoder) { + try { + H.aenc = new AudioEncoder({ + output: (chunk) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const frame = new Uint8Array(1 + data.length); + frame[0] = 0xa1; // CHAN_AUDIO + frame.set(data, 1); + sealSend(frame); + }, + error: (e) => console.error("audio encoder", e), + }); + H.aenc.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2, bitrate: 96000 }); + const aproc = new MediaStreamTrackProcessor({ track: at }); + H.areader = aproc.readable.getReader(); + (async function apump() { + while (H.running && H.aenc) { + let r; try { r = await H.areader.read(); } catch { break; } + if (r.done) break; + try { H.aenc.encode(r.value); } catch (e) { } + r.value.close(); + } + })(); + } catch (e) { console.warn("audio unavailable", e); } + } + } + + function stop() { + H.running = false; + try { H.venc && H.venc.close(); } catch { } H.venc = null; + try { H.aenc && H.aenc.close(); } catch { } H.aenc = null; + try { H.vreader && H.vreader.cancel(); } catch { } + try { H.areader && H.areader.cancel(); } catch { } + try { H.stream && H.stream.getTracks().forEach((t) => t.stop()); } catch { } + try { H.ws && H.ws.close(); } catch { } + H.stream = null; H.ws = null; H.e2eKey = null; H.authed = false; + $("#btnHostStart").hidden = false; + $("#btnHostStop").hidden = true; + status("Stopped sharing."); + } + + // ---- wire the UI ---- + function init() { + const sec = $("#hostShare"); + if (!sec) return; + if (!supported()) { + const b = $("#btnHostStart"); + if (b) { b.disabled = true; b.textContent = "Not supported in this browser"; } + status("Browser hosting needs Chrome or Edge (WebCodecs + screen capture)."); + } + // pre-generate a fresh ID + password to display + $("#hostIdOut").textContent = rand9(); + $("#hostPwOut").textContent = genPw(); + $("#btnHostStart").onclick = start; + $("#btnHostStop").onclick = stop; + const regen = $("#btnHostRegen"); + if (regen) regen.onclick = () => { + if (H.running) return; + $("#hostIdOut").textContent = rand9(); + $("#hostPwOut").textContent = genPw(); + }; + } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); + else init(); +})(); diff --git a/server/web/index.html b/server/web/index.html new file mode 100644 index 0000000..3ef65af --- /dev/null +++ b/server/web/index.html @@ -0,0 +1,329 @@ + + + + + +NetDesk — The Fast, Secure, Self-Hosted Remote Desktop + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ ● Open source · Self-hosted · End-to-end encrypted +

Remote access,
finally on your terms.

+

NetDesk is the fast, secure remote desktop you fully own. Your server, your keys, + your data — never a third-party cloud. The alternative to TeamViewer, AnyDesk and RustDesk, + free for individuals.

+ +
🔒 AES-256 end-to-end⚡ H.264 60fps🖥️ Multi-monitor💸 No subscription to self-host
+ + + +
+ + +
+
AES-256End-to-end on every path
+
<4 msDirect P2P on the LAN
+
1080p60Hardware-encoded, adaptive
+
$0Self-host · no per-seat fees
+
+ + +
+

The private alternative to

+
TeamViewerAnyDeskRustDeskChrome Remote DesktopSplashtop
+
+ + +
+
Everything you need
+

A complete remote desktop, self-hosted

+

All the power of TeamViewer Pro and RustDesk — running on infrastructure you own.

+
+

End-to-end encrypted

AES-256-GCM with ephemeral ECDH keys. Even your own server can't see the screen — only the two endpoints hold the keys.

+

Blazing fast

Hardware H.264 up to 1080p60, WebCodecs decode in the browser, adaptive quality. Buttery-smooth even over the internet.

+

Unattended access

Install as a boot-time service. Reach the logon screen and UAC prompts, across reboots — no one has to be at the PC.

+

File transfer & sync

Browse, drag-and-drop, upload whole folders, two-way clipboard sync, and remote printing — all end-to-end encrypted.

+

Multi-monitor & cursor

Switch displays live, see the real remote cursor, record the session, chat, and manage processes and power remotely.

+

Zero-install web client

Connect from any browser on any laptop or phone — nothing to install on the viewer side. Install the PWA for one-tap access.

+
+
+ + +
+
+
<10ms
control latency on LAN
+
60fps
1080p H.264 streaming
+
AES-256
end-to-end encryption
+
$0
to self-host, forever
+
+
+ + +
+
Why NetDesk

Own it, don't rent it

+

TeamViewer and AnyDesk route your sessions through their cloud and charge per seat. NetDesk runs on your server.

+
+ + + + + + + + +
CapabilityNetDeskTeamViewerRustDesk (SaaS)
Self-hosted, your server✓ Included✗Paid tier
End-to-end encryption✓ Default✓✓
Free for individuals✓Trial only✓
No per-seat licensing✓✗Self-host
Web client (no install)✓✓Limited
Open source✓✗✓
+
+ +
+ + +
+
Open source
+

Read every line. Then run it yourself.

+

The whole system — relay server, Windows host, zero-install web viewer, and the native Rust client — is published right here on netdesk.space. Browse it, download it, or clone it. No GitHub account, no third-party, no lock-in.

+ +
+
+ git clone https://netdesk.space/git/akronlogic/netdesk.git + +
+

Source-available — free to self-host & modify. Browse it like GitHub, straight from our own server.

+
+
+ + +
+

Take control of your remote access today.

+

Free for individuals. Self-host in minutes. No credit card, no cloud lock-in.

+ +
+ + + + + + diff --git a/server/web/manifest.webmanifest b/server/web/manifest.webmanifest new file mode 100644 index 0000000..b046b07 --- /dev/null +++ b/server/web/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "NetDesk", + "short_name": "NetDesk", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0e14", + "theme_color": "#3d7eff", + "icons": [{"src": "/ndicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}] +} \ No newline at end of file diff --git a/server/web/ndicon.svg b/server/web/ndicon.svg new file mode 100644 index 0000000..9c571e1 --- /dev/null +++ b/server/web/ndicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/web/portal.html b/server/web/portal.html new file mode 100644 index 0000000..7b45e82 --- /dev/null +++ b/server/web/portal.html @@ -0,0 +1,290 @@ + + + + + +NetDesk Console + + + + + + + + + + + + + + + diff --git a/server/web/portal.js b/server/web/portal.js new file mode 100644 index 0000000..8fef8ed --- /dev/null +++ b/server/web/portal.js @@ -0,0 +1,349 @@ +/* NetDesk management portal. */ +"use strict"; + +const $ = (s) => document.querySelector(s); + +function fmtBytes(n) { + if (n > 1e9) return (n / 1e9).toFixed(2) + " GB"; + if (n > 1e6) return (n / 1e6).toFixed(1) + " MB"; + if (n > 1e3) return (n / 1e3).toFixed(1) + " KB"; + return n + " B"; +} +function fmtUptime(s) { + const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); + return (d ? d + "d " : "") + (h ? h + "h " : "") + m + "m"; +} +function fmtAgo(ts) { + const d = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (d < 60) return d + "s ago"; + if (d < 3600) return Math.floor(d / 60) + "m ago"; + if (d < 86400) return Math.floor(d / 3600) + "h ago"; + return Math.floor(d / 86400) + "d ago"; +} + +async function api() { + const r = await fetch("/portal/api/overview", { cache: "no-store" }); + if (r.status === 401) { showLogin(); return null; } + return r.json(); +} + +function showLogin() { + $("#viewDash").hidden = true; + $("#viewLogin").hidden = false; + setTimeout(() => $("#pwPortal").focus(), 50); +} +function showDash() { + $("#viewLogin").hidden = true; + $("#viewDash").hidden = false; + refresh(); +} + +async function login() { + const err = $("#loginErr"); + err.hidden = true; + const r = await fetch("/portal/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "password=" + encodeURIComponent($("#pwPortal").value), + redirect: "manual", + }); + if (r.ok || r.status === 0) { $("#pwPortal").value = ""; showDash(); return; } + err.textContent = r.status === 401 ? "Wrong password." : "Login failed (" + r.status + ")"; + err.hidden = false; +} + +function flag(cc) { + if (!cc || cc.length !== 2 || !/^[a-z]{2}$/i.test(cc)) return "🌐"; + return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); +} +// Escape untrusted values (viewer IP from X-Forwarded-For, host names, geo, …) +// before they touch innerHTML — prevents XSS in the admin console. +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, + c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +// A compact labelled load bar for host telemetry (CPU / RAM / Disk). +function bar(label, pct, title) { + pct = Math.max(0, Math.min(100, Math.round(pct || 0))); + const col = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e"; + return `
${label}` + + `` + + `${pct}%
`; +} + +function render(d) { + d.ownerEmail = (d.users || []).find(u => u.email.toLowerCase() === "owner@example.com"); + window.__ownerSeen = !!d.owner; + $("#stOnline").textContent = d.hosts_online; + $("#stTotal").textContent = d.hosts.length; + $("#stSessions").textContent = d.sessions_total; + $("#stTraffic").textContent = fmtBytes(d.bytes_relayed); + $("#stUptime").textContent = fmtUptime(d.uptime); + $("#metaServer").textContent = "server v" + d.version; + $("#kvReg").textContent = d.reg_key || "-"; + $("#kvServer").textContent = location.origin.replace(/^http/, "ws") + "/ws"; + + const tb = $("#tblHosts tbody"); + tb.innerHTML = ""; + for (const h of d.hosts) { + const tr = document.createElement("tr"); + const dot = document.createElement("td"); + dot.innerHTML = `${h.online ? (h.in_session ? "in session" : "online") : "offline"}`; + const name = document.createElement("td"); + name.textContent = h.name || "(unnamed)"; + if (h.hidden) { + const badge = document.createElement("span"); + badge.className = "pill-hidden"; + badge.textContent = "HIDDEN"; + name.append(" ", badge); + } + const id = document.createElement("td"); + id.className = "mono"; + id.textContent = h.id; + const ver = document.createElement("td"); + ver.textContent = h.ver || ""; + const seen = document.createElement("td"); + seen.textContent = h.online ? "now" : fmtAgo(h.last_seen); + // live telemetry (CPU / RAM / Disk bars + battery) + const met = document.createElement("td"); + met.className = "metrics-cell"; + const tm = h.telemetry || {}; + if (h.online && tm.cpu != null) { + const ramPct = tm.ram_total ? (100 * tm.ram_used / tm.ram_total) : 0; + const diskPct = tm.disk_total ? (100 * tm.disk_used / tm.disk_total) : 0; + let bat = ""; + if (tm.battery != null && tm.battery >= 0) { + bat = `
${tm.charging ? "⚡" : "🔋"} ${tm.battery}%
`; + } + met.innerHTML = + bar("CPU", tm.cpu) + + bar("RAM", ramPct, fmtBytes(tm.ram_used) + " / " + fmtBytes(tm.ram_total)) + + bar("SSD", diskPct, fmtBytes(tm.disk_used) + " / " + fmtBytes(tm.disk_total)) + + bat; + } else { + met.innerHTML = '—'; + } + const act = document.createElement("td"); + act.style.whiteSpace = "nowrap"; + if (h.online && d.owner) { + const disc = document.createElement("button"); + disc.className = "tb"; + disc.textContent = h.in_session ? "Disconnect" : ""; + disc.style.marginRight = "6px"; + disc.onclick = async () => { + await fetch("/portal/api/session/disconnect", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id }) }); + refresh(); + }; + act.append(disc); + } + { + // Hide/unhide is a portal-admin action (the console is password-gated), + // so it's available to any authenticated portal session. + const hd = document.createElement("button"); + hd.className = "tb"; + hd.textContent = h.hidden ? "Unhide" : "Hide"; + hd.style.marginRight = "6px"; + hd.onclick = async () => { + await fetch("/portal/api/host/hide", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id, hidden: !h.hidden }) }); + refresh(); + }; + act.append(hd); + } + if (d.owner) { + const rm = document.createElement("button"); + rm.className = "tb danger"; + rm.textContent = "Remove"; + rm.onclick = async () => { + if (!confirm("Remove " + h.name + " from the server?")) return; + await fetch("/portal/api/host/delete", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: h.id }) }); + refresh(); + }; + act.append(rm); + } + if (h.online) { + const a = document.createElement("a"); + a.className = "tb connect"; + a.href = "/app?id=" + h.id; + a.textContent = "Connect"; + act.append(a); + } + tr.append(dot, name, id, ver, seen, met, act); + tb.append(tr); + } + $("#noHosts").hidden = d.hosts.length > 0; + + // users section (owner only) + const usec = document.querySelector("#sec-users"); + if (usec) usec.hidden = !d.owner; + if (d.owner) { + const utb = document.querySelector("#tblUsers tbody"); + if (utb) { + utb.innerHTML = ""; + for (const u of d.users || []) { + const tr = document.createElement("tr"); + const em = document.createElement("td"); + em.className = "mono"; + em.textContent = (u.email.toLowerCase() === (d.ownerEmail || "").toLowerCase()) ? "⭐ " + u.email : u.email; + const vf = document.createElement("td"); + vf.textContent = u.verified ? "yes" : "pending"; + const cr = document.createElement("td"); + cr.textContent = fmtAgo(u.created); + const del = document.createElement("td"); + const db = document.createElement("button"); + db.className = "tb danger"; + db.textContent = "Delete"; + db.onclick = async () => { + if (!confirm("Delete " + u.email + "?")) return; + await fetch("/portal/api/user/delete", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: u.email }) }); + refresh(); + }; + del.append(db); + tr.append(em, vf, cr, del); + utb.append(tr); + } + } + } + + const dl = $("#dlList"); + if (d.files.length) { + dl.innerHTML = ""; + dl.classList.remove("muted"); + for (const f of d.files) { + const a = document.createElement("a"); + a.className = "dl-row"; + a.href = "/dl/" + encodeURIComponent(f.name); + a.innerHTML = `⬇${f.name}${fmtBytes(f.size)}`; + dl.append(a); + } + } + + // (the #feed audit log is populated by loadAudit(), below) + + // ---- live visitors (IP intelligence + location) -------------------- + const vtb = document.querySelector("#tblVisitors tbody"); + if (vtb) { + vtb.innerHTML = ""; + for (const v of (d.visitors || [])) { + const g = v.geo || {}; + const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "—"; + const tr = document.createElement("tr"); + tr.innerHTML = + `${v.active ? "live" : "connecting"}` + + `${esc(v.ip) || "—"}` + + `${flag(g.cc)} ${esc(loc)}
${esc(g.isp)}
` + + `${esc(v.host_name || v.host)}` + + `${v.email ? esc(v.email) : 'guest'} ${esc(v.plan)}` + + `${fmtAgo(v.since)}`; + vtb.append(tr); + } + const nv = document.querySelector("#noVis"); + if (nv) nv.hidden = (d.visitors || []).length > 0; + } + const vr = document.querySelector("#visRecent"); + if (vr) { + vr.innerHTML = ""; + for (const v of (d.visitors_recent || []).slice(0, 30)) { + const g = v.geo || {}; + const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : ""; + const row = document.createElement("div"); + row.className = "feed-row"; + row.innerHTML = + `${fmtAgo(v.t)}` + + `${flag(g.cc)} ${esc(v.ip) || "?"}${loc ? " · " + esc(loc) : ""} → ` + + `${esc(v.host_name || v.host)} ${esc(v.plan)}`; + vr.append(row); + } + if (!(d.visitors_recent || []).length) vr.innerHTML = '
no visitors yet
'; + } +} + +async function refresh() { + const d = await api(); + if (d) { showDash(); render(d); } +} + +function applyTheme(t) { + document.documentElement.dataset.theme = t; + const b = document.querySelector("#btnThemeP"); + if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light"; +} +applyTheme(localStorage.getItem("nd.theme") || "dark"); +if ($("#btnThemeP")) $("#btnThemeP").onclick = () => { + const t = document.documentElement.dataset.theme === "light" ? "dark" : "light"; + localStorage.setItem("nd.theme", t); + applyTheme(t); +}; + +// null-safe binding: a missing element must never kill the whole script +function on(sel, fn) { const el = $(sel); if (el) el.onclick = fn; return el; } + +on("#btnLogin", login); +if ($("#pwPortal")) $("#pwPortal").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); }); +on("#btnCopyReg", () => { + navigator.clipboard.writeText($("#kvReg").textContent); + $("#btnCopyReg").textContent = "Copied"; + setTimeout(() => ($("#btnCopyReg").textContent = "Copy"), 1200); +}); +on("#btnCopySrv", () => { + navigator.clipboard.writeText($("#kvServer").textContent.replace("/ws", "")); + $("#btnCopySrv").textContent = "Copied"; + setTimeout(() => ($("#btnCopySrv").textContent = "Copy"), 1200); +}); +on("#btnLogout", async () => { + await fetch("/portal/logout", { redirect: "manual" }); + location.href = location.pathname.replace(/\/portal$/, "/"); +}); + +/* ---- audit log console (full persistent log + filters + CSV export) ---- */ +function auditParams() { + const p = new URLSearchParams(); + const k = ($("#auKind") || {}).value, q = (($("#auQ") || {}).value || "").trim(), lim = ($("#auLimit") || {}).value; + if (k) p.set("kind", k); + if (q) p.set("q", q); + if (lim) p.set("limit", lim); + return p; +} +async function loadAudit() { + const feed = $("#feed"); + if (!feed) return; + const p = auditParams(); + const csv = $("#auCsv"); + if (csv) csv.href = "/portal/api/audit.csv?" + p.toString(); + try { + const r = await fetch("/portal/api/audit?" + p.toString(), { cache: "no-store" }); + if (!r.ok) return; + const d = await r.json(); + const sel = $("#auKind"); + if (sel && sel.options.length <= 1 && Array.isArray(d.kinds)) { + for (const k of d.kinds) { const o = document.createElement("option"); o.value = k; o.textContent = k; sel.append(o); } + } + feed.innerHTML = ""; + if (!d.events.length) { feed.innerHTML = '
no matching events
'; return; } + for (const e of d.events) { + const row = document.createElement("div"); + row.className = "feed-row kind-" + String(e.kind || "").replace(/[^a-z-]/g, ""); + const dt = new Date((e.ts || 0) * 1000); + const t = dt.toLocaleDateString() + " " + dt.toLocaleTimeString(); + const act = e.actor ? ` · ${esc(e.actor)}` : ""; + row.innerHTML = `${t}${esc(e.kind || "")}${esc(e.msg || "")}${act}`; + feed.append(row); + } + } catch (err) {} +} +(function wireAudit() { + const r = $("#auRefresh"); if (r) r.addEventListener("click", loadAudit); + const k = $("#auKind"); if (k) k.addEventListener("change", loadAudit); + const l = $("#auLimit"); if (l) l.addEventListener("change", loadAudit); + const q = $("#auQ"); let t; if (q) q.addEventListener("input", () => { clearTimeout(t); t = setTimeout(loadAudit, 300); }); +})(); +setInterval(loadAudit, 8000); + +refresh().then(() => { loadAudit(); setInterval(refresh, 3000); }).catch((e) => { showLogin(); }); diff --git a/server/web/pricing.html b/server/web/pricing.html new file mode 100644 index 0000000..d0e1aa3 --- /dev/null +++ b/server/web/pricing.html @@ -0,0 +1,79 @@ + + + + + +Pricing — NetDesk + + + + + + + + + + + + + + +
+
Pricing

Simple, honest pricing

+

Free for individuals, self-hosted forever. Upgrade for teams, unlimited sessions and priority support.

+
+
+

Individual

For personal use, self-hosted
+
$0/forever
+
    +
  • Self-host on your own server
  • +
  • End-to-end encryption
  • +
  • 1 registered computer
  • +
  • Sessions up to 60 min
  • +
  • File transfer & clipboard
  • +
  • Community support
  • +
+ Download free +
+
Most popular +

Pro

For power users & small teams
+
$9/month
+
    +
  • Everything in Individual
  • +
  • Unlimited computers
  • +
  • Unlimited session length
  • +
  • Address book & device groups
  • +
  • 2FA & audit log
  • +
  • Priority email support
  • +
+ Start Pro +
+
+

Business

For organizations at scale
+
Custom
+
    +
  • Everything in Pro
  • +
  • SSO / SAML & SCIM
  • +
  • Central policy & permissions
  • +
  • On-prem & air-gapped deploy
  • +
  • SLA & dedicated support
  • +
  • Custom branding
  • +
+ Contact sales +
+
+

+ Self-hosting NetDesk is always free and open source — you only pay for the hosted convenience tiers. + Prices in USD. Cancel anytime. See our refund policy.

+
+

Questions

+
+

Is NetDesk really free?

Yes. The full software is open source and free to self-host, forever. The paid tiers add hosted convenience, unlimited sessions and team features.

+

What's the session time limit on the free tier?

Individual (unregistered) sessions are capped at 60 minutes; simply reconnect to continue, or sign up / upgrade to remove the limit — the same model RustDesk uses.

+

Do you see my screen?

Never. Sessions are end-to-end encrypted; even the relay server only sees opaque encrypted bytes.

+

Can I use it commercially?

Self-hosting is permitted for commercial use under the open-source license. For managed/hosted commercial use, choose Pro or Business.

+
+ + + + \ No newline at end of file diff --git a/server/web/privacy.html b/server/web/privacy.html new file mode 100644 index 0000000..b52b8d8 --- /dev/null +++ b/server/web/privacy.html @@ -0,0 +1,44 @@ + + + + + +Privacy Policy — NetDesk + + + + + + + + + + + + + + +
+

Privacy Policy

Last updated: September 27, 2026

+

NetDesk is built privacy-first. This policy explains what the hosted service at netdesk.space collects. If you +self-host, none of this applies — your data stays entirely on your own server.

+

What we cannot see

We can never see the contents of your remote sessions. All screen, input, file and +clipboard data is end-to-end encrypted between the two endpoints; our relay only forwards opaque ciphertext.

+

What the hosted service stores

+
    +
  • Account data: your email and a salted password hash (PBKDF2), if you create an account.
  • +
  • Device registry: the IDs and names of computers you bind to your account, and online/offline status.
  • +
  • Operational logs: connection timestamps, IP addresses and coarse events for security and abuse prevention, retained for a limited period.
  • +
+

We do not store your host passwords, session contents, or screen data.

+

Cookies

We use a single essential cookie to keep you signed in. No third-party advertising or tracking cookies.

+

Sharing

We do not sell your data. We share it only as required by law or to operate the service (e.g., our mail +provider for verification emails).

+

Your rights

You may access, export or delete your account data at any time from the console, or by emailing +privacy@netdesk.space.

+

Contact

Questions about privacy? Email privacy@netdesk.space.

+
+ + + + \ No newline at end of file diff --git a/server/web/refund.html b/server/web/refund.html new file mode 100644 index 0000000..21ecbeb --- /dev/null +++ b/server/web/refund.html @@ -0,0 +1,32 @@ + + + + + +Refund Policy — NetDesk + + + + + + + + + + + + + + +
+

Refund Policy

Last updated: September 27, 2026

+

We want you to be happy with NetDesk Pro and Business.

+

14-day money-back guarantee

If you're not satisfied with a paid plan, request a full refund within 14 days +of your first payment — no questions asked. Email billing@netdesk.space.

+

Renewals

Renewal charges are refundable within 7 days of the renewal date. Cancel anytime to stop future charges.

+

Self-hosting

The self-hosted software is free and open source — there is nothing to refund.

+
+ + + + \ No newline at end of file diff --git a/server/web/security.html b/server/web/security.html new file mode 100644 index 0000000..ac940e6 --- /dev/null +++ b/server/web/security.html @@ -0,0 +1,48 @@ + + + + + +Security — NetDesk + + + + + + + + + + + + + + +
+

Security & Trust

Our commitment to keeping your remote sessions private.

+

End-to-end encryption by default

+

Every NetDesk session negotiates an ephemeral ECDH P-256 key exchange and encrypts all media with +AES-256-GCM, per frame, with a fresh nonce. The relay server forwards only opaque ciphertext — it +cannot decrypt your screen, input, files or clipboard. Only the two endpoints hold the keys.

+

Authentication

+

The host password never leaves your machine in the clear: the viewer proves knowledge of it with a +PBKDF2-HMAC-SHA256 (200,000 iterations) challenge/response, throttled on failure. Optional +TOTP two-factor adds an authenticator code at connect. An optional registration key restricts which +hosts may register with your server.

+

Transport

+

All traffic runs over TLS (WSS) to your server. We recommend a real certificate (Let's Encrypt); +NetDesk ships automation for it. HTTP is available only for localhost/LAN testing.

+

You own the data

+

NetDesk is self-hosted. There is no NetDesk cloud in the path. Your session metadata, accounts and audit logs live on +your server. Nothing is sent to a third party.

+

Open source

+

The entire stack is open source and auditable. Don't trust our word — read the code at +netdesk.space/git/akronlogic/netdesk.

+

Responsible disclosure

+

Found a vulnerability? Email security@netdesk.space. We respond within 72 +hours and credit reporters.

+
+ + + + \ No newline at end of file diff --git a/server/web/site.css b/server/web/site.css new file mode 100644 index 0000000..0bb9224 --- /dev/null +++ b/server/web/site.css @@ -0,0 +1,289 @@ +/* ================================================================ + NetDesk marketing site — production design system + ================================================================ */ +:root { + --bg: #0a0d14; --bg2: #10141e; --bg3: #161c28; --card: #141a26; + --fg: #eaeff8; --muted: #9aa4b6; --faint: #6b7688; + --accent: #3d7eff; --accent2: #6aa1ff; --accent-dim: rgba(61,126,255,.12); + --ok: #2fbf71; --border: #212a3a; --border-soft: #1a2130; + --r: 14px; --r-sm: 9px; --maxw: 1160px; + --grad: linear-gradient(135deg, #3d7eff 0%, #6aa1ff 55%, #2fbf71 130%); + --shadow: 0 24px 60px rgba(0,0,0,.45); +} +:root[data-theme="light"] { + --bg: #f6f8fc; --bg2: #ffffff; --bg3: #eef2f8; --card: #ffffff; + --fg: #131a26; --muted: #55617a; --faint: #97a1b4; + --border: #e2e8f1; --border-soft: #edf1f7; --accent-dim: rgba(61,126,255,.08); + --shadow: 0 24px 60px rgba(24,39,75,.10); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } +body { + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg); color: var(--fg); line-height: 1.6; font-size: 16px; + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } + +/* ---- header ---- */ +.nz { position: sticky; top: 0; z-index: 50; backdrop-filter: blur(14px); + background: color-mix(in srgb, var(--bg) 82%, transparent); border-bottom: 1px solid var(--border-soft); } +.nz-in { display: flex; align-items: center; justify-content: space-between; height: 66px; } +.nz-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 20px; letter-spacing: -.4px; } +.nz-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--grad); + display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 14px rgba(61,126,255,.35); } +.nz-links { display: flex; align-items: center; gap: 4px; } +.nz-links a { padding: 9px 14px; border-radius: 8px; color: var(--muted); font-weight: 500; font-size: 15px; transition: .15s; } +.nz-links a:hover { color: var(--fg); background: var(--bg3); } +.nz-links a.active { color: var(--fg); } +.nz-cta { display: flex; align-items: center; gap: 10px; } +.nz-burger { display: none; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--fg); cursor: pointer; } +.btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; border-radius: 10px; + font-weight: 600; font-size: 15px; cursor: pointer; border: 1px solid transparent; transition: .15s; white-space: nowrap; } +.btn-primary { background: var(--grad); color: #fff; box-shadow: 0 8px 22px rgba(61,126,255,.32); } +.btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); } +.btn-ghost { background: var(--bg2); color: var(--fg); border-color: var(--border); } +.btn-ghost:hover { border-color: var(--accent); } +.btn-lg { padding: 15px 28px; font-size: 16.5px; border-radius: 12px; } + +/* ---- hero ---- */ +.hero { position: relative; overflow: hidden; padding: 92px 0 70px; text-align: center; } +.hero::before { content: ""; position: absolute; inset: 0; z-index: -1; + background: radial-gradient(760px 420px at 50% -8%, rgba(61,126,255,.22), transparent 62%), + radial-gradient(680px 420px at 88% 24%, rgba(47,191,113,.12), transparent 60%); } +.tagpill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; + background: var(--accent-dim); border: 1px solid var(--border); color: var(--accent2); font-size: 13.5px; font-weight: 600; margin-bottom: 22px; } +.hero h1 { font-size: clamp(38px, 6vw, 66px); line-height: 1.05; letter-spacing: -1.6px; font-weight: 850; margin-bottom: 20px; } +.hero h1 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.hero p.sub { font-size: clamp(17px, 2.2vw, 21px); color: var(--muted); max-width: 720px; margin: 0 auto 32px; } +.hero-cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } +.hero-note { margin-top: 18px; color: var(--faint); font-size: 14px; } +.hero-shot { max-width: 940px; margin: 54px auto 0; border-radius: 16px; border: 1px solid var(--border); + box-shadow: var(--shadow); overflow: hidden; background: var(--bg2); } + +/* ---- sections ---- */ +.section { padding: 74px 0; } +.section.alt { background: var(--bg2); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); } +.sec-head { text-align: center; max-width: 660px; margin: 0 auto 48px; } +.sec-head .eyebrow { color: var(--accent2); font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase; } +.sec-head h2 { font-size: clamp(28px, 4vw, 42px); letter-spacing: -1px; font-weight: 800; margin: 10px 0 12px; } +.sec-head p { color: var(--muted); font-size: 18px; } + +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.grid2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } +.feat { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 26px; transition: .18s; } +.feat:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); } +.feat .ic { width: 46px; height: 46px; border-radius: 11px; background: var(--accent-dim); color: var(--accent2); + display: flex; align-items: center; justify-content: center; margin-bottom: 16px; } +.feat h3 { font-size: 18.5px; font-weight: 700; margin-bottom: 8px; } +.feat p { color: var(--muted); font-size: 15px; } + +/* ---- pricing ---- */ +.price-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: stretch; } +.plan { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 32px 28px; display: flex; flex-direction: column; } +.plan.pop { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); position: relative; } +.plan .badge { position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--grad); + color: #fff; font-size: 12.5px; font-weight: 700; padding: 5px 14px; border-radius: 999px; } +.plan h3 { font-size: 20px; font-weight: 800; } +.plan .who { color: var(--muted); font-size: 14.5px; margin: 4px 0 18px; } +.plan .price { font-size: 44px; font-weight: 850; letter-spacing: -1.5px; } +.plan .price small { font-size: 16px; color: var(--muted); font-weight: 500; } +.plan ul { list-style: none; margin: 22px 0; display: grid; gap: 12px; } +.plan li { display: flex; gap: 10px; font-size: 15px; color: var(--fg); } +.plan li svg { flex-shrink: 0; color: var(--ok); margin-top: 3px; } +.plan .btn { justify-content: center; margin-top: auto; } + +/* ---- prose (legal/about) ---- */ +.prose { max-width: 820px; margin: 0 auto; } +.prose h1 { font-size: 40px; letter-spacing: -1px; margin-bottom: 8px; } +.prose .updated { color: var(--faint); font-size: 14px; margin-bottom: 34px; } +.prose h2 { font-size: 24px; margin: 34px 0 12px; letter-spacing: -.4px; } +.prose h3 { font-size: 18px; margin: 22px 0 8px; } +.prose p, .prose li { color: var(--muted); margin-bottom: 12px; } +.prose ul, .prose ol { padding-left: 22px; margin-bottom: 14px; } +.prose a { color: var(--accent2); } +.prose strong { color: var(--fg); } + +/* ---- contact ---- */ +.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; } +.field { margin-bottom: 16px; } +.field label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; } +.field input, .field textarea, .field select { width: 100%; background: var(--bg2); border: 1px solid var(--border); + border-radius: 10px; padding: 12px 14px; color: var(--fg); font-size: 15px; font-family: inherit; outline: none; } +.field input:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.info-row { display: flex; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border-soft); } +.info-row .ic { color: var(--accent2); } + +/* ---- cta band ---- */ +.ctaband { text-align: center; padding: 70px 0; background: var(--bg2); border-top: 1px solid var(--border-soft); } +.ctaband h2 { font-size: clamp(28px, 4vw, 40px); letter-spacing: -1px; margin-bottom: 12px; } +.ctaband p { color: var(--muted); font-size: 18px; margin-bottom: 26px; } + +/* ---- footer ---- */ +.ft { background: var(--bg2); border-top: 1px solid var(--border); padding: 56px 0 28px; } +.ft-top { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 30px; } +.ft-brand p { color: var(--muted); font-size: 14.5px; margin: 14px 0; max-width: 300px; } +.ft-col h4 { font-size: 13px; text-transform: uppercase; letter-spacing: .8px; color: var(--faint); margin-bottom: 14px; } +.ft-col a { display: block; color: var(--muted); font-size: 15px; padding: 5px 0; transition: .12s; } +.ft-col a:hover { color: var(--fg); } +.ft-bot { display: flex; align-items: center; justify-content: space-between; margin-top: 40px; padding-top: 22px; + border-top: 1px solid var(--border-soft); color: var(--faint); font-size: 14px; flex-wrap: wrap; gap: 12px; } +.ft-social { display: flex; gap: 12px; } +.ft-social a { color: var(--muted); } .ft-social a:hover { color: var(--fg); } +.theme-toggle { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--fg); + padding: 8px 12px; cursor: pointer; font-size: 13.5px; } + +/* ---- misc ---- */ +.logos { display: flex; gap: 34px; justify-content: center; align-items: center; flex-wrap: wrap; opacity: .8; color: var(--muted); font-weight: 600; } +.compare { width: 100%; border-collapse: collapse; } +.compare th, .compare td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border-soft); font-size: 15px; } +.compare th { color: var(--faint); font-weight: 600; font-size: 13px; text-transform: uppercase; } +.compare td.y { color: var(--ok); font-weight: 700; } +.compare td.n { color: var(--faint); } +.compare tr td:first-child { color: var(--fg); font-weight: 600; } + +@media (max-width: 900px) { + .nz-links { display: none; } + .nz-burger { display: block; } + .nz-links.open { display: flex; position: absolute; top: 66px; left: 0; right: 0; flex-direction: column; + background: var(--bg2); border-bottom: 1px solid var(--border); padding: 12px; gap: 4px; } + .grid3, .price-grid, .ft-top { grid-template-columns: 1fr; } + .grid2, .contact-grid { grid-template-columns: 1fr; } + .ft-top { gap: 24px; } +} + +@media (max-width: 600px) { + /* Phones: keep the top bar to logo + primary CTA + burger so the page never + scrolls sideways. The secondary "Sign in" link stays reachable in-page. */ + .nz-cta .btn-ghost { display: none; } + .nz-cta { gap: 8px; } +} + +/* ================================================================ + ELEVATION LAYER v2 — award-winning, enterprise-grade polish. + Component-scoped overrides of the base system above (cascade wins), + so every marketing page that links site.css is lifted at once. + ================================================================ */ +:root{ + --accent-grad: linear-gradient(135deg,#4d8bff 0%,#6aa1ff 42%,#34d399 115%); + --glass: color-mix(in srgb, var(--card) 70%, transparent); + --hair: rgba(255,255,255,.07); + --hair-strong: rgba(255,255,255,.14); + --shadow-sm: 0 2px 10px rgba(0,0,0,.28); + --shadow-lg: 0 34px 90px -28px rgba(0,0,0,.70); + --ring: 0 0 0 4px rgba(77,139,255,.14); + --r: 16px; --r-sm: 11px; +} +:root[data-theme="light"]{ + --glass: rgba(255,255,255,.72); + --hair: rgba(16,24,40,.06); --hair-strong: rgba(16,24,40,.12); + --shadow-lg: 0 34px 90px -30px rgba(24,39,75,.22); +} + +/* fine film-grain for tactile depth — fixed, never intercepts input */ +body::after{ + content:""; position:fixed; inset:0; z-index:9998; pointer-events:none; opacity:.03; + mix-blend-mode:overlay; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ---- scroll reveal (universal; site.js adds .in on intersect) ---- */ +.reveal{ opacity:0; transform:translateY(20px); transition:opacity .7s cubic-bezier(.2,.7,.2,1), transform .7s cubic-bezier(.2,.7,.2,1); will-change:opacity,transform; } +.reveal.in{ opacity:1; transform:none; } +@media (prefers-reduced-motion: reduce){ .reveal{ opacity:1 !important; transform:none !important; } } + +/* ---- nav: crisper glass + gradient hairline ---- */ +.nz{ background: color-mix(in srgb, var(--bg) 68%, transparent); border-bottom:1px solid var(--hair); } +.nz::after{ content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.4),rgba(52,211,153,.3),transparent); opacity:.6; } +.nz-logo{ box-shadow:0 6px 18px -4px rgba(77,139,255,.5), inset 0 1px 0 rgba(255,255,255,.3); } +.nz-links a:hover{ background:var(--hair); color:var(--fg); } +.nz-links a.active{ color:var(--fg); } +.nz-links a.active::after{ content:""; display:block; height:2px; margin-top:7px; border-radius:2px; background:var(--accent-grad); } + +/* ---- buttons: gradient + inner highlight + hover sheen ---- */ +.btn{ border-radius:12px; letter-spacing:-.1px; transition:transform .16s ease, box-shadow .16s ease, filter .16s ease, border-color .16s ease; } +.btn-primary{ background:var(--accent-grad); position:relative; overflow:hidden; + box-shadow:0 10px 28px -8px rgba(77,139,255,.55), inset 0 1px 0 rgba(255,255,255,.28); } +.btn-primary::before{ content:""; position:absolute; top:0; left:-120%; width:60%; height:100%; + background:linear-gradient(100deg,transparent,rgba(255,255,255,.35),transparent); transform:skewX(-18deg); transition:left .6s ease; } +.btn-primary:hover::before{ left:140%; } +.btn-primary:hover{ transform:translateY(-2px); box-shadow:0 16px 40px -10px rgba(77,139,255,.6), inset 0 1px 0 rgba(255,255,255,.3); filter:none; } +.btn-ghost{ background:var(--glass); backdrop-filter:blur(10px); border-color:var(--hair-strong); } +.btn-ghost:hover{ border-color:var(--accent); background:var(--card); transform:translateY(-1px); } +.btn:focus-visible{ outline:none; box-shadow:var(--ring); } + +/* ---- feature cards: gradient border + cursor spotlight + lifted hover ---- */ +.feat{ position:relative; border:1px solid transparent; border-radius:var(--r); padding:28px; overflow:hidden; + background:linear-gradient(var(--card),var(--card)) padding-box, + linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box; + transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; } +.feat::before{ content:""; position:absolute; inset:0; border-radius:inherit; opacity:0; transition:opacity .3s; pointer-events:none; + background:radial-gradient(420px circle at var(--mx,50%) var(--my,-10%), rgba(77,139,255,.16), transparent 42%); } +.feat:hover{ transform:translateY(-5px); box-shadow:var(--shadow-lg); } +.feat:hover::before{ opacity:1; } +.feat .ic{ width:50px; height:50px; border-radius:13px; color:#a8c6ff; border:1px solid var(--hair); + background:linear-gradient(135deg, rgba(77,139,255,.20), rgba(52,211,153,.10)); + box-shadow:inset 0 1px 0 rgba(255,255,255,.14), 0 8px 20px -8px rgba(77,139,255,.45); } +.feat h3{ font-size:19px; letter-spacing:-.3px; } +.feat p{ font-size:15px; line-height:1.62; } + +/* ---- generic card (pricing/other) spotlight hover parity ---- */ +.plan{ position:relative; border-radius:var(--r); transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; } +.plan:hover{ transform:translateY(-4px); box-shadow:var(--shadow-lg); } +.plan.pop{ border:1px solid transparent; + background:linear-gradient(var(--card),var(--card)) padding-box, var(--accent-grad) border-box; + box-shadow:0 0 70px -24px rgba(77,139,255,.6), var(--shadow-lg); } +.plan .price{ background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.plan.pop .badge{ box-shadow:0 8px 22px -6px rgba(77,139,255,.6); } + +/* ---- section heads: gradient eyebrow + tighter type ---- */ +.sec-head .eyebrow{ display:inline-block; background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.sec-head h2{ letter-spacing:-1.2px; } +.section.alt{ background:color-mix(in srgb,var(--bg2) 92%, #000 8%); } + +/* ---- comparison table: container + highlighted NetDesk column + icons ---- */ +.compare{ border-collapse:separate; border-spacing:0; border:1px solid var(--hair); border-radius:var(--r); overflow:hidden; background:var(--card); box-shadow:var(--shadow-sm); } +.compare th, .compare td{ border-bottom:1px solid var(--hair); } +.compare tr:last-child td{ border-bottom:none; } +.compare th:nth-child(2), .compare td:nth-child(2){ background:color-mix(in srgb,var(--accent) 8%, transparent); position:relative; } +.compare th:nth-child(2){ color:var(--accent2); } +.compare thead th:nth-child(2)::after, .compare tr:first-child td:nth-child(2)::before{ } +.compare td.y{ color:var(--ok); } +.compare tr td:first-child{ color:var(--fg); } + +/* ---- enterprise trust: stats band ---- */ +.statband{ border-top:1px solid var(--hair); border-bottom:1px solid var(--hair); + background:color-mix(in srgb,var(--bg2) 60%, transparent); } +.statband .wrap{ display:grid; grid-template-columns:repeat(4,1fr); gap:18px; padding:30px 22px; } +.stat{ text-align:center; position:relative; } +.stat:not(:last-child)::after{ content:""; position:absolute; right:-9px; top:14%; height:72%; width:1px; background:var(--hair); } +.stat b{ display:block; font-size:clamp(26px,3.4vw,36px); font-weight:850; letter-spacing:-1px; line-height:1; + background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; } +.stat span{ display:block; margin-top:8px; color:var(--muted); font-size:13.5px; font-weight:500; } + +/* ---- logos strip: refined ---- */ +.logos{ gap:40px; opacity:1; } +.logos span{ color:var(--faint); font-weight:650; font-size:15px; letter-spacing:.2px; transition:color .2s, filter .2s; filter:grayscale(1) opacity(.7); } +.logos span:hover{ color:var(--muted); filter:none; } + +/* ---- hero shot: layered depth ---- */ +.hero-shot, .hz-shot{ box-shadow:var(--shadow-lg), 0 0 0 1px var(--hair); } + +/* ---- footer: subtle top glow ---- */ +.ft{ position:relative; border-top:1px solid var(--hair); } +.ft::before{ content:""; position:absolute; top:-1px; left:0; right:0; height:1px; + background:linear-gradient(90deg,transparent,rgba(77,139,255,.35),transparent); } +.theme-toggle:hover{ border-color:var(--accent); } + +/* ---- form fields: softer focus ---- */ +.field input:focus, .field textarea:focus, .field select:focus{ box-shadow:var(--ring); } + +/* ---- responsive: stats stack ---- */ +@media (max-width:760px){ + .statband .wrap{ grid-template-columns:repeat(2,1fr); gap:24px 12px; } + .stat:nth-child(2)::after{ display:none; } +} diff --git a/server/web/site.js b/server/web/site.js new file mode 100644 index 0000000..20cabe8 --- /dev/null +++ b/server/web/site.js @@ -0,0 +1,103 @@ +/* NetDesk marketing site: shared header + footer, theme, mobile nav. */ +(function () { + "use strict"; + var YEAR = 2026; + var NAV = [ + ["Features", "/features"], ["Pricing", "/pricing"], + ["Security", "/security"], ["Download", "/download"], + ["Source", "/git/akronlogic/netdesk"] + ]; + var page = (document.body.getAttribute("data-page") || "").toLowerCase(); + + function svgLogo() { + return '' + + ''; + } + + function header() { + var links = NAV.map(function (n) { + var active = page === n[0].toLowerCase() ? " class=\"active\"" : ""; + return '' + n[0] + ""; + }).join(""); + return '
' + + 'NetDesk" + + '' + + '
' + + 'Sign in' + + 'Get NetDesk' + + '' + + "
"; + } + + function footer() { + return '"; + } + + function applyTheme(t) { + document.documentElement.setAttribute("data-theme", t); + try { localStorage.setItem("nd.theme", t); } catch (e) {} + } + + document.addEventListener("DOMContentLoaded", function () { + var h = document.getElementById("siteHeader"); + var f = document.getElementById("siteFooter"); + if (h) h.innerHTML = header(); + if (f) f.innerHTML = footer(); + + var burger = document.getElementById("nzBurger"); + if (burger) burger.onclick = function () { document.getElementById("nzLinks").classList.toggle("open"); }; + var ft = document.getElementById("ftTheme"); + if (ft) ft.onclick = function () { + applyTheme(document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"); + }; + // reflect signed-in state in the header + fetch("/api/auth/me").then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { + var b = document.getElementById("btnSignin"); + if (d && d.email && b) { b.textContent = "My Computers"; b.href = "/app"; } + }).catch(function () {}); + + // --- scroll reveal (every page that uses .reveal) --- + var revs = document.querySelectorAll(".reveal"); + if (revs.length && "IntersectionObserver" in window) { + var io = new IntersectionObserver(function (es) { + es.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }); + }, { threshold: 0.1, rootMargin: "0px 0px -8% 0px" }); + revs.forEach(function (el) { io.observe(el); }); + } else { + revs.forEach(function (el) { el.classList.add("in"); }); + } + + // --- cursor spotlight on cards (sets --mx/--my for the ::before glow) --- + function spot(e) { + var r = e.currentTarget.getBoundingClientRect(); + e.currentTarget.style.setProperty("--mx", (e.clientX - r.left) + "px"); + e.currentTarget.style.setProperty("--my", (e.clientY - r.top) + "px"); + } + document.querySelectorAll(".feat, .plan").forEach(function (c) { + c.addEventListener("mousemove", spot, { passive: true }); + }); + }); + + try { applyTheme(localStorage.getItem("nd.theme") || "dark"); } catch (e) { applyTheme("dark"); } +})(); diff --git a/server/web/sitemap.xml b/server/web/sitemap.xml new file mode 100644 index 0000000..e02f9ef --- /dev/null +++ b/server/web/sitemap.xml @@ -0,0 +1,13 @@ + + + https://netdesk.space/weekly1.0 + https://netdesk.space/pricingweekly0.7 + https://netdesk.space/featuresweekly0.7 + https://netdesk.space/securityweekly0.7 + https://netdesk.space/downloadweekly0.7 + https://netdesk.space/contactweekly0.7 + https://netdesk.space/aboutweekly0.7 + https://netdesk.space/termsweekly0.7 + https://netdesk.space/privacyweekly0.7 + https://netdesk.space/refundweekly0.7 + diff --git a/server/web/terms.html b/server/web/terms.html new file mode 100644 index 0000000..4574bd9 --- /dev/null +++ b/server/web/terms.html @@ -0,0 +1,44 @@ + + + + + +Terms of Service — NetDesk + + + + + + + + + + + + + + +
+

Terms of Service

Last updated: September 27, 2026

+

These Terms govern your use of NetDesk software and the optional hosted services provided at netdesk.space +("Service"). By downloading, self-hosting or using NetDesk, you agree to these Terms.

+

1. License

NetDesk's source code is provided under its open-source license. You may self-host, modify and +use it, including commercially, subject to that license. The hosted Pro and Business services are provided under these Terms.

+

2. Acceptable use

You may only use NetDesk to access computers you own or are explicitly authorized to +administer. You must not use NetDesk to gain unauthorized access to any system, to distribute malware, or for any +unlawful purpose. You are responsible for complying with all applicable laws in your jurisdiction.

+

3. Accounts

You are responsible for safeguarding your account credentials, host passwords and 2FA secrets. +Notify us of any unauthorized use.

+

4. Free tier & limits

The free Individual tier is provided as-is and may include session time limits and +device limits. We may adjust free-tier limits with reasonable notice.

+

5. Payment

Paid plans are billed in advance and renew automatically until cancelled. Fees are non-refundable +except as stated in our Refund Policy.

+

6. Disclaimer

THE SOFTWARE AND SERVICE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. To the maximum extent +permitted by law, NetDesk disclaims all warranties and is not liable for any indirect, incidental or consequential damages.

+

7. Changes

We may update these Terms; material changes will be posted here with a new date.

+

8. Contact

Questions? Contact us or email hello@netdesk.space.

+
+ + + + \ No newline at end of file diff --git a/tools/patch_accounts.py b/tools/patch_accounts.py new file mode 100644 index 0000000..c290e70 --- /dev/null +++ b/tools/patch_accounts.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""One-shot patch: add user accounts + email auth to netdesk_server.py.""" +import re + +p = "server/netdesk_server.py" +s = open(p, encoding="utf-8").read() + +ANCHOR = "TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket\nTUNNEL_TID = [1]" +assert ANCHOR in s, "tunnel anchor missing" + +BLOCK = ''' +# ---------------------------------------------------------------- user accounts (email sign-in for viewers) +import sqlite3 +import smtplib +from email.message import EmailMessage as _MailMsg + +ACCOUNTS_DB = os.path.join(_server_root, "accounts.db") +USER_SESSIONS = {} # token -> email + +def _acct_db(): + conn = sqlite3.connect(ACCOUNTS_DB) + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + return conn + +def _pbkdf(password, salt): + import hashlib as _h + return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex() + +def _mail_html(title, body_html, link=None, link_text=None): + link_html = "" + if link: + link_html = ( + '

' + '' + link_text + "

" + '

Or paste this link: ' + link + "

") + logo = ('' + '' + '' + '') + return ( + '
' + '
' + logo + + 'NetDesk
' + '

' + title + "

" + '
' + body_html + "
" + + link_html + + '

' + "If you did not request this, ignore this email.

") + +def send_mail(to, subject, html): + if not ARGS.mail_from: + raise RuntimeError("email not configured on this server") + msg = _MailMsg() + msg["From"] = "NetDesk <" + ARGS.mail_from + ">" + msg["To"] = to + msg["Subject"] = subject + msg.set_content("Your mail client does not support HTML.") + msg.add_alternative(html, subtype="html") + host = ARGS.smtp_host or "127.0.0.1" + with smtplib.SMTP(host, 25, timeout=20) as smtp: + smtp.send_message(msg) + +def user_from_cookie(req): + if not ARGS.mail_from: + return None + tok = req.cookies.get("nd_user", "") + return USER_SESSIONS.get(tok) + +async def auth_signup(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + if "@" not in email or len(email) < 5: + return web.json_response({"error": "enter a valid email"}, status=400) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + salt = _secrets.token_bytes(16) + vtok = _secrets.token_urlsafe(24) + now = int(time.time()) + with _acct_db() as conn: + try: + conn.execute( + "INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) " + "VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now)) + except sqlite3.IntegrityError: + row = conn.execute("SELECT verified FROM users WHERE email=?", (email,)).fetchone() + return web.json_response( + {"error": "email already registered - sign in or reset password", + "unverified": bool(row and not row[0])}, status=409) + link = ARGS.public_url + "/api/auth/verify?token=" + vtok + send_mail(email, "Verify your NetDesk account", + _mail_html("Confirm your email", + "Welcome to NetDesk. Click below to activate your account " + "and start connecting to your computers.", link, "Verify my email")) + log_event("auth", "signup: " + email) + return web.json_response({"ok": True, "msg": "verification email sent"}) + +async def auth_verify(req): + tok = req.query.get("token", "") + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone() + if not row: + return web.Response(status=400, text="invalid or used link") + conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],)) + log_event("auth", "email verified") + raise web.HTTPFound(ARGS.public_url + "/?verified=1") + +async def auth_login(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + pw = str(body.get("password", "")) + with _acct_db() as conn: + row = conn.execute("SELECT pw_salt,pw_hash,verified FROM users WHERE email=?", (email,)).fetchone() + if not row: + return web.json_response({"error": "wrong email or password"}, status=401) + if _pbkdf(pw, bytes.fromhex(row[0])) != row[1]: + return web.json_response({"error": "wrong email or password"}, status=401) + if not row[2]: + return web.json_response({"error": "check your inbox - email not verified yet"}, status=403) + tok = _secrets.token_urlsafe(24) + USER_SESSIONS[tok] = email + resp = web.json_response({"ok": True, "email": email}) + resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=30 * 86400) + log_event("auth", "viewer signed in: " + email) + return resp + +async def auth_me(req): + email = user_from_cookie(req) + if not email: + return web.json_response({"error": "not signed in"}, status=401) + return web.json_response({"email": email}) + +async def auth_logout(req): + tok = req.cookies.get("nd_user", "") + USER_SESSIONS.pop(tok, None) + resp = web.json_response({"ok": True}) + resp.del_cookie("nd_user") + return resp + +async def auth_forgot(req): + body = await req.json() + email = str(body.get("email", "")).strip().lower() + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE email=? AND verified=1", (email,)).fetchone() + if row: + tok = _secrets.token_urlsafe(24) + conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?", + (tok, int(time.time()) + 1800, row[0])) + link = ARGS.public_url + "/?reset=" + tok + send_mail(email, "Reset your NetDesk password", + _mail_html("Password reset", + "Click below to choose a new password. " + "The link expires in 30 minutes.", link, "Reset password")) + return web.json_response( + {"ok": True, "msg": "if that account exists, a reset email was sent"}) + +async def auth_reset(req): + body = await req.json() + tok, pw = str(body.get("token", "")), str(body.get("password", "")) + if len(pw) < 8: + return web.json_response({"error": "password needs 8+ characters"}, status=400) + with _acct_db() as conn: + row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?", + (tok, int(time.time()))).fetchone() + if not row: + return web.json_response({"error": "link expired or invalid"}, status=400) + salt = _secrets.token_bytes(16) + conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL " + "WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0])) + log_event("auth", "password reset") + return web.json_response({"ok": True}) + +''' + +# insert block before portal section +s = s.replace(ANCHOR, BLOCK + ANCHOR, 1) + +# CLI args +s = s.replace( + ' ap.add_argument("--access-path", dest="access_path",\n' + ' help="secret URL segment hosting the UI (random hash); root serves 404")', + ' ap.add_argument("--access-path", dest="access_path",\n' + ' help="secret URL segment hosting the UI (random hash); root serves 404")\n' + ' ap.add_argument("--mail-from", dest="mail_from", default="",\n' + ' help="sender address enabling email accounts, e.g. noreply@netdesk.space")\n' + ' ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1",\n' + ' help="SMTP relay for verification mail (default localhost postfix)")\n' + ' ap.add_argument("--public-url", dest="public_url", default="",\n' + ' help="public base URL used in email links, e.g. https://netdesk.space")') + +# middleware-free auth routes (public) +s = s.replace( + ' app.router.add_get("/tunnel/{hid}", tunnel_ws)', + ' app.router.add_get("/tunnel/{hid}", tunnel_ws)\n' + ' # viewer email accounts\n' + ' app.router.add_post("/api/auth/signup", auth_signup)\n' + ' app.router.add_post("/api/auth/login", auth_login)\n' + ' app.router.add_post("/api/auth/forgot", auth_forgot)\n' + ' app.router.add_post("/api/auth/reset", auth_reset)\n' + ' app.router.add_get("/api/auth/verify", auth_verify)\n' + ' app.router.add_get("/api/auth/me", auth_me)\n' + ' app.router.add_post("/api/auth/logout", auth_logout)') + +# print line in main +s = s.replace( + ' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")', + ' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")\n' + ' if ARGS.mail_from:\n' + ' print(f"[netdesk] viewer accounts ON - mail from {ARGS.mail_from} via {ARGS.smtp_host}:25")') + +open(p, "w", encoding="utf-8").write(s) +print("server accounts patch applied") diff --git a/tools/patch_plans.py b/tools/patch_plans.py new file mode 100644 index 0000000..e5b54ad --- /dev/null +++ b/tools/patch_plans.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Patch: guest/user/owner plans with server-side session limits, +owner admin access, keyless host registration support, host/user +management endpoints, public host download.""" +import re + +p = "server/netdesk_server.py" +s = open(p, encoding="utf-8").read() + +# ---------- 1. plans + owner ---------- +old = "TUNNEL_TID = [1]" +new = """TUNNEL_TID = [1] + +# session plans: guests 20 min, verified users 45 min, owner unlimited +PLAN_LIMITS = {"owner": None, "user": 45 * 60, "guest": 20 * 60} + +def plan_for(email): + if email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower(): + return "owner", None + if email: + return "user", PLAN_LIMITS["user"] + return "guest", PLAN_LIMITS["guest"]""" +assert old in s, "p1" +s = s.replace(old, new, 1) + +# ---------- 2. owner + plan on session.begin; drop hard signin gate ---------- +old = """ elif t == "session.begin": + if ARGS.mail_from and not user_from_cookie(req): + log_event("auth", "connect blocked: viewer not signed in") + await send_json(ws, {"t": "session.deny", "sid": None, "reason": "signin"}) + continue + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid)""" +new = """ elif t == "session.begin": + vuser = user_from_cookie(req) + vplan, vlimit = plan_for(vuser) + hid = str(m.get("id", "")).strip() + h = HOSTS.get(hid)""" +assert old in s, "p2" +s = s.replace(old, new, 1) + +# after sess created, attach plan info +old = """ sess = Session(sid, h, ws) + h.session = sess + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} connecting to host {hid}") + log.info("session %s: viewer %s -> host %s", sid, peer, hid) + await send_json(h.ws, {"t": "session.begin", "sid": sid})""" +new = """ sess = Session(sid, h, ws) + sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser + h.session = sess + viewer_sid = sid + STATS.sessions_total += 1 + log_event("session", f"viewer {peer} ({vplan}) connecting to host {hid}") + log.info("session %s: viewer %s (%s) -> host %s", sid, peer, vplan, hid) + await send_json(h.ws, {"t": "session.begin", "sid": sid})""" +assert old in s, "p3" +s = s.replace(old, new, 1) + +# on accept: tell viewer the plan + start the enforcement timer +old = """ if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}") + await send_json(sess.viewer_ws, m)""" +new = """ if t == "session.accept" and host is not None: + sess.active = True + log_event("session", f"session accepted on host {sess.host.id}") + plan_msg = {"t": "session.plan", "plan": sess.plan, + "limit": sess.vlimit or 0, "email": sess.vemail or ""} + await send_json(sess.viewer_ws, plan_msg) + if sess.vlimit: + async def _limit_killer(s=sess): + await asyncio.sleep(s.vlimit) + if s.active: + log_event("session", f"time limit on host {s.host.id}") + await relay_session_end( + s, "session time limit reached - reconnect or sign up", + None) + if s.host: + s.host.session = None + asyncio.ensure_future(_limit_killer(sess)) + await send_json(sess.viewer_ws, m)""" +assert old in s, "p4" +s = s.replace(old, new, 1) + +# ---------- 3. Session fields ---------- +old = """class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False""" +new = """class Session: + def __init__(self, sid, host, viewer_ws): + self.sid = sid + self.host = host + self.viewer_ws = viewer_ws + self.active = False + self.plan = "guest" + self.vlimit = None + self.vemail = "" + self.started = time.time()""" +assert old in s, "p5" +s = s.replace(old, new, 1) + +# ---------- 4. owner-aware portal auth + admin endpoints ---------- +old = """def portal_authorized(req) -> bool: + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS""" +new = """def owner_from_cookie(req) -> bool: + email = user_from_cookie(req) + return bool(email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) + + +def portal_authorized(req) -> bool: + if owner_from_cookie(req): + return True + if not ARGS.portal_password: + return False + tok = req.cookies.get("ndportal", "") + return tok in PORTAL_SESSIONS""" +assert old in s, "p6" +s = s.replace(old, new, 1) + +# admin endpoints +old = """async def portal_download(req):""" +new = """async def portal_users(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + return web.json_response({"users": users}) + + +async def portal_user_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + body = await req.json() + email = str(body.get("email", "")).lower() + if ARGS.owner_email and email == ARGS.owner_email.lower(): + return web.json_response({"error": "cannot delete the owner"}, status=400) + with _acct_db() as conn: + conn.execute("DELETE FROM users WHERE email=?", (email,)) + for tok, em in list(USER_SESSIONS.items()): + if em == email: + USER_SESSIONS.pop(tok, None) + log_event("admin", f"user deleted: {email}") + return web.json_response({"ok": True}) + + +async def portal_host_delete(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None: + return web.json_response({"error": "host offline (removed from registry)"}, status=404) + try: + await h.ws.close() + except Exception: + pass + log_event("admin", f"host {hid} removed by admin") + return web.json_response({"ok": True, "msg": "host removed; it must re-register"}) + + +async def portal_session_disconnect(req): + if not portal_authorized(req): + return web.json_response({"error": "unauthorized"}, status=401) + hid = str((await req.json()).get("id", "")) + h = HOSTS.get(hid) + if h is None or not h.session: + return web.json_response({"error": "no active session"}, status=404) + await relay_session_end(h.session, "disconnected by admin", None) + h.session = None + log_event("admin", f"session on {hid} disconnected by admin") + return web.json_response({"ok": True}) + + +async def portal_download(req):""" +assert old in s, "p7" +s = s.replace(old, new, 1) + +# overview: users + richer host info +old = """ return web.json_response({ + "version": VERSION, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started),""" +new = """ users = [] + with _acct_db() as conn: + for row in conn.execute("SELECT email, verified, created FROM users " + "ORDER BY created DESC LIMIT 500"): + users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) + for k in known: + h = HOSTS.get(k["id"]) + k["plan"] = h.session.plan if (h and h.session) else "" + return web.json_response({ + "version": VERSION, + "owner": bool(ARGS.owner_email and user_from_cookie(req) and + user_from_cookie(req).lower() == ARGS.owner_email.lower()), + "users": users, + "reg_key": ARGS.reg_key or "", + "uptime": int(now - STATS.started),""" +assert old in s, "p8" +s = s.replace(old, new, 1) + +# routes +old = """ app.router.add_get("/portal/api/overview", portal_overview)""" +new = """ app.router.add_get("/portal/api/overview", portal_overview) + app.router.add_get("/portal/api/users", portal_users) + app.router.add_post("/portal/api/user/delete", portal_user_delete) + app.router.add_post("/portal/api/host/delete", portal_host_delete) + app.router.add_post("/portal/api/session/disconnect", portal_session_disconnect)""" +assert old in s, "p9" +s = s.replace(old, new, 1) + +# public host download (homepage button) +old = """async def tunnel_ws(req):""" +new = """async def public_download(req): + name = os.path.basename(req.match_info.get("name", "")) + path = os.path.join(FILES_DIR, name) + if not os.path.isfile(path): + return web.Response(status=404, text="not found") + with open(path, "rb") as f: + body = f.read() + resp = web.Response(body=body, content_type="application/octet-stream") + resp.headers["Content-Disposition"] = f'attachment; filename="{name}"' + return resp + + +async def tunnel_ws(req):""" +assert old in s, "p10" +s = s.replace(old, new, 1) +old = """ app.router.add_get("/tunnel/{hid}", tunnel_ws)""" +new = """ app.router.add_get("/tunnel/{hid}", tunnel_ws) + app.router.add_get("/download/{name}", public_download)""" +assert old in s; s = s.replace(old, new, 1) + +# ---------- 5. CLI owner arg ---------- +old = """ ap.add_argument("--mail-from", dest="mail_from", default="",""" +new = """ ap.add_argument("--owner-email", dest="owner_email", default="", + help="email with unrestricted access + admin panel") + ap.add_argument("--mail-from", dest="mail_from", default="",""" +assert old in s; s = s.replace(old, new, 1) + +open(p, "w", encoding="utf-8").write(s) +print("plans/owner/admin patch applied") diff --git a/tools/smoke_test.py b/tools/smoke_test.py new file mode 100644 index 0000000..07a7e46 --- /dev/null +++ b/tools/smoke_test.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +NetDesk smoke test - validates the full pipeline against a live server+host: + + connect -> challenge/auth (with E2E) -> accept -> H.264 stream decode -> PNG + + ping/pong, clipboard sync, quality change, monitor info, clean session end. + +Run: python tools/smoke_test.py [server_ws_url] +Assumes server on 127.0.0.1:8443 and a host already registered +(host's config.json supplies the ID + verifier). +""" + +import asyncio +import base64 +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +import aiohttp +import av +import numpy as np +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "host")) +from netdesk_host import appdata_dir, clip_get, clip_set, totp_now # noqa: E402 + +SERVER = sys.argv[1] if len(sys.argv) > 1 else "ws://127.0.0.1:8443/ws" +OUT_PNG = Path(__file__).resolve().parent / "smoke_frame.png" +TEST_TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" + +# point this at a portable exe's folder to test the packaged build +_cfg_path = (Path(os.environ["ND_CONFIG_DIR"]) / "config.json") if os.environ.get("ND_CONFIG_DIR") \ + else (appdata_dir() / "config.json") +cfg = json.loads(_cfg_path.read_text("utf-8")) +HOST_ID = cfg["id"] +VERIFIER = cfg["verifier"] + +PASS, FAIL = [], [] + + +def check(name, ok, detail=""): + (PASS if ok else FAIL).append(name) + print(f" [{'PASS' if ok else 'FAIL'}] {name} {('- ' + str(detail)) if detail else ''}") + + +async def recv_until(ws, want_t, timeout=8, pred=None): + """Receive JSON messages until one matches t (and pred); skip others.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + msg = await ws.receive(timeout=max(0.1, deadline - time.time())) + except asyncio.TimeoutError: + break + if msg.type == aiohttp.WSMsgType.TEXT: + try: + m = json.loads(msg.data) + except Exception: + continue + if m.get("t") == want_t and (pred is None or pred(m)): + return m + return None + + +async def connect_and_auth(ws, totp_code=None, strict=True): + """session.begin -> challenge -> auth -> accept/deny. Returns + (sid, info_or_None, e2e_key_or_None, deny_reason).""" + await ws.send_json({"t": "session.begin", "id": HOST_ID, "direct": False}) + ch = await recv_until(ws, "session.challenge") + if strict: + check("session.begin -> challenge", ch is not None) + if not ch: + raise SystemExit(1) + check("challenge advertises 2FA", ch.get("totp") is bool(cfg.get("totp"))) + if not ch: + return None, None, None, "no challenge" + proof = hashlib.sha256((ch["nonce"] + VERIFIER).encode()).hexdigest() + priv = ec.generate_private_key(ec.SECP256R1()) + pub_b = priv.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + await ws.send_json({ + "t": "session.auth", "sid": ch["sid"], "proof": proof, + "pub": base64.b64encode(pub_b).decode(), + "totp": totp_code or "", + }) + # wait for accept or deny, keeping media messages out of the way + deadline = time.time() + 8 + deny_reason = None + while time.time() < deadline: + try: + msg = await ws.receive(timeout=max(0.1, deadline - time.time())) + except asyncio.TimeoutError: + break + if msg.type != aiohttp.WSMsgType.TEXT: + continue + m = json.loads(msg.data) + if m.get("t") == "session.accept": + host_pub = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), base64.b64decode(ch["pub"])) + shared = priv.exchange(ec.ECDH(), host_pub) + e2e = AESGCM(hashlib.sha256(shared + b"e2e").digest()) + return ch["sid"], m.get("info", {}), e2e, None + if m.get("t") == "session.deny": + deny_reason = m.get("reason") + break + return ch["sid"], None, None, deny_reason + + +async def main(): + print(f"smoke test against {SERVER} (host ID {HOST_ID})") + saved_clip = clip_get() + try: + # ---- shared login (viewer accounts required when mail is on) ------- + async with aiohttp.ClientSession() as http: + import sqlite3 + acct_db = Path(os.environ.get( + "ND_ACCOUNTS_DB", + str(Path(__file__).resolve().parents[1] / "server" / "accounts.db"))) + try: + c = sqlite3.connect(acct_db) + c.execute("CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," + " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," + " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," + " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") + salt = os.urandom(16).hex() + import hashlib as _hh + h = _hh.pbkdf2_hmac("sha256", b"testpass123", bytes.fromhex(salt), 200_000).hex() + c.execute("INSERT OR IGNORE INTO users(email,pw_salt,pw_hash,verified,created) " + "VALUES('smoketest@netdesk.space',?,?,1,strftime('%s','now'))", (salt, h)) + c.execute("UPDATE users SET verified=1, verify_token=NULL, " + "pw_salt=?, pw_hash=? WHERE email='smoketest@netdesk.space'", + (salt, h)) + c.commit(); c.close() + except Exception: + pass + base = SERVER.split("/ws")[0] + base = base.replace("wss://", "https://").replace("ws://", "http://") + try: + async with http.post(base + "/api/auth/login", + json={"email": "smoketest@netdesk.space", + "password": "testpass123"}, + allow_redirects=False) as lr: + USER_COOKIE = lr.headers.get("Set-Cookie", "").split(";")[0] + except Exception: + USER_COOKIE = "" + # 2FA wrong-code rejection test + if cfg.get("totp"): + async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024, + headers={"Cookie": USER_COOKIE}) as ws: + sid, info, key, reason = await connect_and_auth(ws, totp_code="000000") + check("2FA wrong code rejected", info is None and reason == "bad 2FA code", + str(reason)) + + async with aiohttp.ClientSession() as http: + async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024, + headers={"Cookie": USER_COOKIE}) as ws: + code = totp_now(cfg["totp"]) if cfg.get("totp") else None + sid, info, e2e_key, reason = await connect_and_auth(ws, totp_code=code) + check("session.auth -> accept", info is not None, reason) + if info is None: + raise SystemExit(1) + check("session info has dims", info.get("w", 0) > 0 and info.get("h", 0) > 0, + f"{info.get('w')}x{info.get('h')}") + check("monitors reported", len(info.get("monitors", [])) >= 1, + f"{len(info.get('monitors', []))} monitor(s)") + check("E2E negotiated", info.get("e2e") is True) + check("lock_on_end setting present", "lock_on_end" in info) + + # normalize state leaked from previous runs (never touches the + # display unless ND_DISPLAY_TESTS=1) + await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False, + "lock_on_end": False}) + await recv_until(ws, "sys.res", timeout=6, pred=lambda x: x.get("msg") == "config saved") + + # 3. ping/pong + t0 = time.time() + await ws.send_json({"t": "ping", "sid": sid, "c": t0}) + m = await recv_until(ws, "pong") + rtt = (time.time() - t0) * 1000 + check("ping/pong", m is not None, f"{rtt:.0f} ms") + check("control latency sane", rtt < 1500, f"{rtt:.0f} ms < 1500 ms") + + # 4. clipboard host->viewer (host polls and pushes changes) + marker = f"netdesk-smoke-{int(time.time())}" + clip_set(marker) + m = await recv_until(ws, "clip.set", timeout=6, + pred=lambda x: x.get("text") == marker) + check("clipboard host->viewer", m is not None) + + # 5. quality change -> expect session.info + await ws.send_json({"t": "set.quality", "sid": sid, + "fps": 15, "bitrate": 2000, "scale": 0.75}) + m = await recv_until(ws, "session.info", timeout=6) + check("quality change -> session.info", m is not None, + f"{m['info'].get('w')}x{m['info'].get('h')} @ {m['info'].get('fps')}fps" + if m else "timeout") + + # 6. collect + decode video (E2E decrypt each frame). + # Static screens correctly send nothing, so force keyframes. + dec = av.CodecContext.create("h264", "r") + frames = [] + video_msgs = 0 + video_bytes = 0 + for _ in range(30): + await ws.send_json({"t": "keyframe", "sid": sid}) + await asyncio.sleep(0.25) + for _ in range(20): + try: + msg = await ws.receive(timeout=0.15) + except asyncio.TimeoutError: + break + if msg.type != aiohttp.WSMsgType.BINARY: + continue + data = bytes(msg.data) + data = e2e_key.decrypt(data[:12], data[12:], None) + if not data or data[0] != 0x01: + continue + video_msgs += 1 + video_bytes += len(data) + try: + for f in dec.decode(av.Packet(data[2:])): + frames.append(f) + except Exception: + pass # waiting for keyframe + if len(frames) >= 3: + break + check("E2E video packets received", video_msgs >= 3, + f"{video_msgs} msgs, {video_bytes//1024} KB") + check("H.264 decodes", len(frames) >= 1, f"{len(frames)} frame(s)") + if frames: + img = frames[-1].to_image() + img.save(OUT_PNG) + arr = np.asarray(img.convert("L")) + check("frame not black", float(arr.std()) > 3.0, + f"std={arr.std():.1f} size={img.size}") + print(f" saved {OUT_PNG} ({img.size[0]}x{img.size[1]})") + + # 7. mouse move to current cursor position (harmless) + import ctypes + from ctypes import wintypes + pt = wintypes.POINT() + ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) + await ws.send_json({"t": "i.mouse", "sid": sid, + "x": pt.x, "y": pt.y, "b": 0, "w": 0, "hw": 0}) + await asyncio.sleep(0.3) + check("input event accepted", True) + + # 7b. process manager + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "processes"}) + m = await recv_until(ws, "sys.res", timeout=20, + pred=lambda x: x.get("kind") == "processes") + n_proc = len(m["items"]) if m and m.get("items") else 0 + check("process list", m is not None and m.get("ok") and n_proc > 3, + f"{n_proc} processes") + + # 7c/d. display-affecting tests are OPT-IN (ND_DISPLAY_TESTS=1) + # so the suite never blanks the screen of a machine in use + if os.environ.get("ND_DISPLAY_TESTS") == "1": + await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False, + "privacy_on_session": True, "allow_display_power": True}) + m = await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + check("set.cfg saved", m is not None and m.get("ok") is True) + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("privacy mode on", m is not None and m.get("on") is True) + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("privacy mode off", m is not None and m.get("on") is False) + await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False, + "allow_display_power": False}) + await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + else: + # gate check: with display power disabled the host must refuse + await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"}) + m = await recv_until(ws, "sys.res", timeout=8, + pred=lambda x: x.get("kind") == "privacy") + check("display control gated off by default", + m is not None and m.get("ok") is False, + str(m and m.get("msg"))) + await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False, + "privacy_on_session": False}) + m = await recv_until(ws, "sys.res", timeout=6, + pred=lambda x: x.get("msg") == "config saved") + check("set.cfg saved", m is not None and m.get("ok") is True) + + # 7e. system audio: host should advertise it and stream Opus + check("audio advertised", info.get("audio") is True, str(info.get("audio"))) + audio_pkts = 0 + dec_a = av.CodecContext.create("opus", "r") + aframes = 0 + deadline = time.time() + 8 + while time.time() < deadline and audio_pkts < 5: + try: + msg = await ws.receive(timeout=1) + except asyncio.TimeoutError: + # nudge video traffic so the ws stays warm; audio flows regardless + await ws.send_json({"t": "keyframe", "sid": sid}) + continue + if msg.type != aiohttp.WSMsgType.BINARY: + continue + data = bytes(msg.data) + data = e2e_key.decrypt(data[:12], data[12:], None) + if data and data[0] == 0xA1: + audio_pkts += 1 + try: + aframes += len(dec_a.decode(av.Packet(data[1:]))) + except Exception: + pass + check("system audio packets received", audio_pkts >= 5, f"{audio_pkts} pkts") + check("audio decodes (Opus)", aframes >= 1, f"{aframes} frames") + + # 7f. TCP tunnel round trip: echo server on this PC, through + # server -> (broker) -> host -> TCP -> back + async def _handle(reader, writer): + try: + while True: + d = await reader.read(4096) + if not d: + break + writer.write(d) + await writer.drain() + finally: + writer.close() + + echo_srv = await asyncio.start_server(_handle, "127.0.0.1", 0) + echo_port = echo_srv.sockets[0].getsockname()[1] + + portal_pw = os.environ.get("ND_PORTAL_PW") or os.environ.get("PORTAL_PASSWORD", "portaltest123") + async with http.post(f"{SERVER.replace('/ws', '')}/portal/login".replace("ws://", "http://"), + data={"password": portal_pw}, + allow_redirects=False) as resp: + if resp.status != 303: + raise SystemExit(f"portal login failed: {resp.status}") + cookie = resp.headers.get("Set-Cookie", "").split(";")[0] + tunnel_ws = await http.ws_connect( + f"{SERVER.replace('/ws', '')}/tunnel/{HOST_ID}".replace("ws://", "http://"), + headers={"Cookie": cookie}) + await tunnel_ws.send_json({"t": "open", "port": echo_port}) + # wait for tunnel.ok or err + tok = None + deadline = time.time() + 10 + while time.time() < deadline and tok is None: + try: + msg = await tunnel_ws.receive(timeout=1) + except asyncio.TimeoutError: + continue + if msg.type == aiohttp.WSMsgType.TEXT: + m = json.loads(msg.data) + if m.get("t") in ("tunnel.ok", "tunnel.err"): + tok = m + check("tunnel opened", tok is not None and tok.get("t") == "tunnel.ok", str(tok)) + if tok and tok.get("t") == "tunnel.ok": + payload = b"netdesk-tunnel-ping-12345" + await tunnel_ws.send_bytes(payload) + got = b"" + deadline = time.time() + 8 + while time.time() < deadline and len(got) < len(payload): + try: + msg = await tunnel_ws.receive(timeout=1) + except asyncio.TimeoutError: + break + if msg.type == aiohttp.WSMsgType.BINARY: + got += msg.data + check("TCP tunnel round trip", got == payload, f"{len(got)}/{len(payload)} bytes") + await tunnel_ws.close() + echo_srv.close() + + # 8. clean end, then reconnect (auto-privacy reconnect test + # only when display tests are explicitly enabled) + await ws.send_json({"t": "session.end", "sid": sid}) + await asyncio.sleep(0.6) + sid2, info2, e2e2, reason2 = None, None, None, None + for attempt, wait in enumerate((0, 3, 6), start=1): + if attempt > 1: + await asyncio.sleep(wait) + sid2, info2, e2e2, reason2 = await connect_and_auth(ws, strict=False) + if info2 is not None: + break + check("reconnect works", info2 is not None, reason2) + if info2 is not None: + check("no display sleep without opt-in", + info2.get("privacy_active") is False, + str(info2.get("privacy_active"))) + await ws.send_json({"t": "session.end", "sid": sid2}) + await ws.send_json({"t": "session.end", "sid": sid}) + check("session.end sent", True) + finally: + if saved_clip is not None: + clip_set(saved_clip) + + print() + print(f"RESULT: {len(PASS)} passed, {len(FAIL)} failed") + if FAIL: + print("failed:", ", ".join(FAIL)) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())
+ + +
+ Open source · self-hosted +

The complete NetDesk source

+

Every line — the relay server, the Windows host, the zero-install web viewer, and the native Rust client. Clone it or download it straight from here. No GitHub account required.

+ +

Version 1.5.0 · AES-256 end-to-end · Windows + Web today, macOS/Linux/Android via the native client build

+
+ +
+

Clone with git

+

Served straight from netdesk.space over HTTPS:

+
# get the full source +git clone https://netdesk.space/git/akronlogic/netdesk.git +cd netdesk
+
+ +
+

What's inside

+

A single repository with every component of the system.

+
+
server/Python aiohttp signaling + relay + REST API, SSO (SAML/OIDC/LDAP), SCIM, admin console, and the web/ browser viewer.
+
host/Windows host/agent (runs as a SYSTEM service — reachable at the logon screen & across UAC) + a pywebview desktop app.
+
native/From-scratch native client: a Rust core (capture, encode, crypto, P2P) with a Flutter UI. Workspace crates: core, viewer, host, cli, opus, vpx, frb.
+
docker/Dockerfile + compose to run your own relay server with one command.
+
+
+ +
+

Self-host in 60 seconds

+

Run your own relay server (your VPS, your rules — the shop never sees your screen):

+
# 1. get it +git clone https://netdesk.space/git/akronlogic/netdesk.git && cd netdesk +# 2. install deps + run the server +pip install -r requirements.txt +python server/netdesk_server.py --port 8443 --portal-password YOUR_PASSWORD +# 3. put it behind TLS (nginx/Caddy) and point your host + browser at it
+

Full deployment guide in docker/README.md and README.md. Source-available license — free to self-host & modify. See LICENSE.

+
+ +
+ NetDesk · self-hosted remote desktop · netdesk.space
+ © 2026 Akronlogic. Source-available — free to self-host. +
+

ye_~)B^;Omi&+1jGC#k!a|xWSpT95?E|g|1tj0nB#}8AcRET%+V@z6QUv@Gs%DDW ztzy0y2?%9-Gksgb44W;Ewjb&lI;eibR*<8=Upex$E-E{HlL`*zU@m4T3h#15c${}l zl-YAF-%ZG3)%?t~^ndQ3`TH$Yf{eN(NPc&F>0uj`eH&RZf&H=CR#Fee9}5PPs@=1tCa zkY;f7s;pzN0K1cv%6z>=i}G#g$=PBnfF_-``LsdR*j1ipLQq!w6QZ^{Zfv_+Exv3} z8=lp}h}XDjBGf0ibE+dW_GLRU0#IxffTW1CKm$XZud1-}-N2Eefec1Z43Qm?4eFHB z>E5kw@hQUxdV)Z0pv9-4oZwaDdr-T107_%}QASGRGnoX(j%dFDA`WBN94ruoXzYNg zCw&FH&xmxZ$lZc4b3!wwamYbcv+bELv0X3w2V3F~RDHmB(MNCjJ7zoyzaTrVUNZH< zl=K?E2s!RGLqDzmE{#{$5u0Ur*_|r+pxH@USK^d<5{`1Ya2VRb-}onQ#JPoQ){_Qf zi=W(fQii7`N@69?P(IH50BIr1No{m!JjZIr@Yx=+9-=f3id+mwY&*G4qpCsvQdqhC3JvZ8u^l#K6L0jdR0KFpIQ!y`8*5`L=G(;{O#@w&9J(yD!Vth(@+j^@-caH3 zMgZi;6TJ;Mqq>yjWs_8XC#i}lP)vbhS`_0|=PKMq7|}2{b3;-cCRa#Y#%xgp1%Ilaii~SOg~F z8JRn01P|^seUAS^GS0@rK(&zCRF=-yS003`I4WjaOw7?eYR1jv=_f{p7BCz#t)ODl z!WcbyCkySnv{1%635z(PA-jd4i#}-yc^E~}N|RSiRD1t}S#TED{Ms<4 z*M3IzT5q-d;R%BLt>hO)CqMh+$ghLbbBV~PXxwgA1EAb6P|H7f*$E(S^T0HV%SJSz zHzH;*8jfS{B7^Ifa1TfDY&x0XoK$7O19(}hp!K8f-A>q`M##9qD9%Sfi`8PY;)L8A zM1Uy-&1O`wW>?@!l(x_v@53cgO_!?%i5RhWu_$ZTTPjh=QEG#e!Z(X0mY5vfX86u5 z=X)@hIjQVMYL>G!Od>%I;4lc)>i8eY89@Ww-^HY#ga#v=F*fYlYxzGJ-5`7Ucf5ze z9<dO_Wym9P6yDx#!xRb;x4;$Dx4B3j} z+z(NH>;Y4&K95P-6Q7qJd(CWicjfVTQa;gBGjCFc#OD!#qI~g|Pr6b0ly5TX_ZbT+ zi<>otp?GqYEZ?R}r!;+>N*P0q#^Jm`E|ib1P*pFb6lr0*mD;QfB4qb<6ZJuyH$1aH=aj6(o+frMp410& zQ_ik|TF?aDZ{`X3slHfDEhb%%Um3u)9JY)}JW zw(i$)`Eh>f--GNr>0OsWZkC~fc<87y*13$p>e|sVw05ZWiBnpq{nnTyr&es@T|LlQ zf|@s3FQ@&N(q9*m+;w_D-~3~w65Op=%}6=phd9P;;uRh57Jr6b^X9Bm2ynDbW&*w$ zsk`%rwE(f2XHtS8s@!Gpr!9q%MjJ)HX#WEZ+=ruY_o3!^Ky&oGS#4UOjv8pV9t1Jp zPwx*t!Z*qnh8AEYdNYG{a6Ex)TtN>q5ci`5rpgVfh17B0crBXejHoFhmg9{dO^owm zO(W_JeoJbbisQZvWN78U83jms^un?OVw(C?-uekuNpU7`!tHiP%4 z{7-!T9M+#>BnNH~C{-_)ULZT+OeBxMb|F^VKYq*)LfW zFn13?97Mq%lnNMY!PkEXC{9fky4BwyeyPk%*~cHk^Z>8 zH^+Aiuy6oiS{+}^F=p^OabHUzKcfr}>zwf>@sc7el>mK3M~@|6fxk7{Bq-xLXFDDe z;srNaxl0VD#KScuq3`-venTWwcSEPfi@D_^fYZO3a5z`Y+zc^uKHCB_qLXYGN$onabM42Gm?0ww}tMQX}56k_+SbuGtfxhs7E(XfG&E@}N(6=jm(5$$wV zQRS;hkJ@!#W5*uicdJ#GYREp(bFH zi|eGU0|1*W2nu>|yEtyQyJWpCnecwePs)gF_z=f|8DTO@CUl#eZW#6f9l4!B+nq!| zR@t=vHCGew#GlUhG#e$~Q_rPUYf+~Uo4Lm|8sNe^)We~9vXn0X#G$^^!7(R*jYgQi zgJIG}y-&6a571gSprc5iq7$L}bvtxT-W_D*3<~x>7;3EX7_DRyHuUBKV%U(taP%2 z=O4lv?KzXSc=s{wgHjv?GojJf$eUE|kNz^W#%=~AosltX2An;a2ELp$6+X{?X+6Ie zB%OkH4W|)wcMu%9Wo)o9(%dNUq5?#@>9PqF_$HU`-x8o^!C;_8*<^dcD%I#<)1fK3lwQAEkG$TV(O4|J>Hy=Dmj^~4slV;s!`+0X& zVb~l}VxKimBOpMVIxUourt2rtsxq_S*Ge~Es4t?1Qf$7M)Lyi4U-OE!E`HZ3d+1H_ zj2k{xldR9{=rd`Mnqnymki;^BErMpf+@gR@M%s-~3(*@*a#w#2iw-QX(u~k(oJAkX zwT^Vk$IuGRQ?MQ6zM&3D26gIh0x#G%3TfVlS_?~+rN(snD&DC$L_0KQHWO^VN}XzE zW@4j#VT%H6CZtu_7@DCfvu8poFE?6zsV}kLDrb4s8+gHe)nE|i(A&r^{ zAL!_J(q&1H1yPhj#0VJ0Lg=N*;zsK@PDQlEMaJScT;Y@f$OHO&9uWLN4`JnYe!$2~u-!3sS#M z$mMvQP}s_g#e%q>5pt#c`%K*b9?!{S)Td!&-gzkugX91GH-78D_Lss8dBJ@ZnDXM0VDf`va!wMO_a`I4 z40*vFLwDxU!At8Z%#`ydz_i>v63o>Zm?$un%;lx!40*V{4;cNag`;!z^dkrbBlHW^ zHKMmz`7v~V{{fgy%>AWx4SB(>1*V93zZ7Q3!)=RSJr0L2+n$erS<4E!w49(dIXPqf z#%p#)>Q}ahMpZdFcVEK!WgIFa+dHF*8}3Ef!DV}99DO;MEU%1OQKNH*#?6)Dp5OA@ z%rXN;EBw+wUG~?XX37Qc_hU3-F1Xf@QQuwg@BNsZoKipLrA$l!v-gsiw}-`CfswlaPR7S>I@vpek$a}K=!vW3oY4qz{rary|!Sxp_P^r8{H{19YvU(}|o z_@PpRiOD1~E!NbFste$Tjn<`O8@ONnor|#E2h%I3!ofPYw0}X$$W8gwR98!OkyZCS zuq@O3VV!cYBUh4Lf?Id^hMh5^H0$rT`G&n^#!a_4aQTLH)M4!eFcP~_bSxY5D5ees znFk7r0;50e{?h*ke8ZMgPXoTqv;WF-Do6AUo9DMJfHfboBlw1$4&0+Ytgxzl!$$7m z=7E3QoSO2tcm_OmcSZQbo$)76P5{A(_Y%Tt>__fgK_&vPgw`ss8Wn;#u!cxkZyj&W z{<9%6?a^l5kU||02C*)HL97d45bFXM#JT_mvDCpgXB!8Wrz76x+=e$ETiw?9*z!(Z zavJI%Uo)!Vw#Qb#MNAiWB~Vi|s;^kdD*6=u3b}r8MMYm*JT>uYr8GoVkDGYSY?K9w z7g9FFE%9ScNw_(!iaWk)!5}jUt;C#$zgs;n zG@33i4i(|#y$2c}|2b1>qYan^^y@ z@yyEO`4-@%q5iSupUh5v(0I6QAFE=)g^F-bWAVuojrAwbHq@6@9w65RM(m4?-8igm ztjALyYT_-8W!((+1E*T)BsQ2z%%!45gS2GodyQv0b~QvIC*NvJrdt55rKP*PV{zHe32Dk`CxgeWR%n{+Z%Fs4zG zfMU|6jro;Ug-)UoGbU+3ltQq%Xg4j;ASz|^r%^!~?dE;H`U^$nXxZ~nyGuRjOmaF82kjRvIjiP8X|L5n zvWF9v|0T%XdwzsIbY@}6S%iXY0cz=-ge&@6F^(G2@0kE1TLHx3FuMlhr z+TN%P;)TMd-p%I0p#7J#&${KxNTIl6Q=y+$KlT7!n%3Q0c#sOHsboU{{2(;xdEeVKP+@DczsySHq+_G*9i1^z?^kNU%xBF7bu2913<9- zTIgS}g>PrX5H1+VNw_NDx(nx!_7if;kOAQOLezj!d=03!&_5u;1(H{XNsJqKrSOzL zG)lCyG#BCnn+h8T-eE@QEc}|gpRIzWe|DR94Ujk^aADH`ojp154m=xJSI7+fj-&!5 zaR`Sr>dIeta}+f6#cvJtGBgLe46Of-z3OxODXoLj4i^p$z`ZzE$)IQ>IrkFtQlSWi z4BkpQ?xs<6waug26W_WGc2S2~&2WcqqrVQkTId>h0TSF+Kw&ARSHmV}oGJA8iUs=V2XSc zckVA7J^M7lwbLDX-z`k<1FXiMok1mQzObto(qE~d+*4DWN!20-^lCFox`}ir zjRgnXVCiL6z2}v&ENP+0fvv}h9N2nRi5W-FKAox3NgM=1VFP=Q6E?8-bO>&FRVZwL ztIx$aVM9AC$Q;8=L9q+#A~*QEm}CBv&YNc6u)P*LfStI3-AI!z64&4OT?t6)TfvGw zJp}8}F8!vCiAwr5QK>Qs%Qutqb}G9$h(Ct~KBDyf zNG=uayYD;qlln8Y0wHFaLSxwWoLH;S#9B5&CbZAJ@7&4>DGE|!ao+Zv_N(qY_uTf+ zkm;Nt+J^h&(fX(v%{g2=((!UH%v8*bX`ytMVoz71WBY#vmpslzoI~`&0QJt3P`ICg zZvm}NYZWsS>PDUL_|sV!L~-V zyK-4TxvG?8BgDR6^OGiLTx6QY=^wxhBm{-~*a*v$&JgYFVaRqK zBCj(#Ld;6nv47Ef#7eCbqQr-9AqN(j-MZgnPU!4ItY|At@7$&tCu-@7b}Q#Dm?1U9T~!JNNJl;7!Y3r*w{psZz;KZ0`!}>fBE3 z8v7vovhKnZB$rK{tcFp@?K=3DVHcB_PJ8!3S+AyIq!rP@R8rWuyO)5~H1+g{M0Aw2 z!S|&xFhNZ@_%dq@W?U7RL0nXTDRytCGQQ$mJR?bUE9d4}ISH`Qy+;bW_U#D&hbMO& zN&K@8LKijx$YOm&nGYoFc0(b)Z%bk0zIPS%X5yc9{u#n-eZJ_H%|g3hfA$dB{TjYaw#+D~~5WEbrrloq>dN8XUATdx(@g-!Vc?jk@W z`m8gCdHsi)x~;u}(M&_k>D-39_fsOa)~J6WgVz~8XqlC({5&fRx=;U(_QJQi-);Yk z`_8@i^U3TpuxzE9=C$f;zE8&wm=IL0VN&cotOCZYHJUBzXPsTMau=@^Y8-d})X1fX z>vCf*(O}#~Z+lJXYc6r<-KJMMK1kW?!R*9&Fn`FTwG`et_qBzeb-rUCRVsI5@B5mj zW-JvBN=&xBSxFsxUrWZHG3na+G^I@anu$d{Hb!=I7TOra83tAoMjq;XwcQ57VACh6 z)hhkH>zD!DWT(;F-ahxrNL%~dFGu=CT>I`DBE|OI-;eAn+z(e8t@q#4xvqV8I&!Fe z_sj_T;XTXNx6fT1=`uWZD)oM5q8?$tpBb$c;+5WJ>D5XfWG+>Jyxq^V&;4Aa2&5O} zl%-kqy&s^wfXKVa@Qilmt3+2{)a|biMD{#hc;mKi>n*LKb^pMd(7Rbl-H%hmE@HOY zEZwMN!?B-vq)-^xX^8gUX25$MSC-9>8>VJ~9z#s8Q{Y8J-}@QTJ$#4-gN4KWZdZzc zbKPO-c}t%^aHQ~Q+^*@X@QHy#P`uON#GLlFPayjiK8@6!H->)Vwj=FV4@8bUZk@AP zy<;)5uM#2r6%f2v*iLDVYs%c7D9^)*g!aS@{U}K{^!FA{85n49JB4d#?TJ%vJ1)e> zg;?VA`69`RJY`_;tUmoaHk2nCHfdT8A#x76o`TcLY0<6;*-NL`Cu$7BvtN~32t)%-y0eb6g`_)|g zAEzhvzeQ8QPjSC(KAlJm-#WmGeOLcRRq16HBQ;OU@+$H6%L7 zAajHbjKydb;v@O`A%Ix-;h1;jK)cG`T_{KoDbQ+dS4ye*+(4H~f0-J74u{lmQd&ee z75dz5-6AIcI33eTSo-WXLh{VMUUOQE=2%Th?QM(uUn!ik(~B8z47|oFc<{K$X4n92 z>iio6+X`Pq#r_34h!&l^dLm;CZhR4?Sy|rKKtpIj{`ketBO-3g6ksWzN~@mP(eI-j^}AJdlnQ`geLY0<)QYXjyAd z{N#a&HH9g?8!gJOoo8SSDZ-5u3eT|zzC__D3*zdyZX_C6$rAVM#4xtB_eIjjB4DH# z0y6&eHw}=_>AYaX5&)t$6qrVlcI<)4x`JnFj-Xl?xK*l+9FnyNf=Sjvhon_ypD)3dS>Yi?8x-Pp^Sjg-E+!7&}7_q39R4X*)`fYZD zJodn7OK(-Rr}wtmN3}>x{N#ZOR6cA;WRIu@{oU&nTg9^0Ryg*6ZOfY)!nXV!g`+<< zeXS08lEBmEh#K`P)Opa=mMBTOTC)q#(k>9{Jm_j`T?|u~3wWI;H-7_)TH@x!_Y*%z z{P6qhDQz#kB*M1+$h~<*5Bt{~ZLVk@)m+(pO7p4Br!|jmKE3&j<};heG=HLbY;&yn zU%HonJDk(LFB;D2?4Mfb##k74_|swBQ|u$K`3#)?X@?h{@~_tZ>WjTU7v*C>?EhWI zZxz^2gb5)ck=uRnkKa-KCn!2)2mzsgyB*cKVdaD$5Bq?q_^2J#pL>|0-BRuKyMJ)Z z0_(yEek&rsbG%Nw=DQIOp!=LwlDeZcw@O5u0=;;vPna+loCA zs658@#mXbxHrJP?)=n?may5T95_yfZ zM{hI$>!1G?yId!G+3;pxrYkuT`KaBihk081wvpW}bIT9TJbJA2i%Va2i0%g$YK?2OpTuP{w;f@*1BC3lG5uT=0*rdw`J`{00Jo?_17z}u3;kswOK6E!`h z1m4?W2kcx+6lgjMbTkqbRI@>m%Oz}hsyWgOAC~EC9kHPeN2z!P!z5%(Y9&`H&LK@e zRAW4exYMZVTus1H-%*OZ%aU|__+ieFvQ4Gy;k8QnLU8*AT>Bzza;erFm0_;tOq(3( zH*MGB0_>_?JRQz1s;NTRn;dTE31+^b9m{5(Ho|l{E}5HpH?TD|O0KC%Ms+^Xx#9oX zt+6g$2jDt(eXNTcmfoQ?^t>$~ovCfwnaU#hfJiX9RHncYpvUF&qBJWnm@=CPG~KA`|z{dNKraSNCKWB6?lqvWB! zX^_vAHO;`QXbyfBU*LZ^dnkgjh(~lM#u28JBCheBxmnO$x3D2qY*)Khs{!46D00Cj z?BL;%LRHOebSPSv@8_!tdDj6NbZNL-7s)xINDXzc7!NtI;-j{|%a^=T>7fL=rt5sQ z6V>6ux!z5if0QEa{Xrfo_?>ADe3Q4(1OajBhj!Sqji+Se233Y4OL3XS2X0Y|h&2Uj|EYEO7 z4T$;nK8BH|7SS6s=5M20QN8Nzy~@7RMyPH~s~vWYGCKYW;mjKAm#!xcHI}(Sb}ZQ< zmZ-AE64zWRq=)J_w-h6J15sjOw|Qal%)wEBM!gbKp#+Y^%O2st-_1^{$PVkdoV>8qM9U{P$p zy^n&0#f=%UsV7P_{`*VbH#kJAQ5oAQ#}&nmM)n(=el#Y|a7@IC1dLj>-a5I8D|W(o zt#)#bq%jKTYE=$Na(FRc=Ya({zl%RSJWFoCyTvZ`8=eM*Z}Q&In+FnXj(4Kfh?o=- z@t2NwjtJnJtaBSBu5)WhJ8}~L$m(cQj5^sJ;)p%cyW}lu2&G1wBTEAv(#okK2CAI* zjv>l6h+(u{O^vwL10w)olIoJwCBN_<93_NQ3mQ_**|ettf%@l_Igd%E9z>JO#l|h`iVm(VDjvtVJC$g0$ zXEMqYL>Kc%ms~NCk@7UXvas<-6A_u*!qBxvUr=RkL0tQgPkxhZbcwJ78hQtgT6^5e z;ZAFh7ik!xyX4Y})sE)8%efg^Vn$@eN@TzxO5W8-%j>`v@Gi+ma0a!yrd3SCx0Q%u z16@}c?Z&p^BeFiKH@}tNixXNQKt+@7h+<=A* zK&rNUR^X{!g_u(+$3}aw4EJ43A(i~W52gxK<6a7YmnAKq7Znaup1` z)lD){B^~l%yU?OaJE6+_75565hVS>Dw}yZlg&mw4H~si$;;(p4j0s&@qsho02~M2t zuo9M#DJ+2`33)g*C*&Q#8BntaGNc1I*TIpfkvvrRsuG?mB{AmVq)|Eq(^`&(0}IvE zf@3!Pz1R7N)AJ@!2gU0812()lX(K$=g>YD7UdH8^j&z0wIZftvBN{+^=nAj%FzptQ zQaFCYfz^DBYyp5Sf60R>bs!b5b$q_5KU#S-Z$eX@p!W&JA=1F6L%EkJ$i-`TL~u{U zI83%qeKWy1^6-MqCp}HaL=hQVevjdnx<`%U?nzZ zLp$%)m;}4JDHmNnFOC?Q=2Q%mF7d$X0b6LMB0|k`4|tq3Q4^yu`Q0HV@Bi3JkndG& zn;n)NY1KjRWE}-qeY_+Aiz0zm`M$xi(ZhdLF?_}y? zP7^95>Q3@aq#e6E2*AbOEgp}!Omm!V&`SjEoWH4#neroV&s+P1Ccq+7c?(tvdVY4X@KlcG%fS5qHPs(?@;b4 z@SaefI;)k}O5k6uyz{L*RT}Y_CH8~jBS)ct9w#YQN>^7~@$ix3$L)qR<+~v!022@W zBIwnhAqlbAbr>{d{B!vXN z8pc=0pBs&Ss@+v6 z{8d}uuDoEZ+_R_tq#GsjgF#Kzbo z{OA$TAeF^*lf_A?9beC0Uo&HG@7u?Za~jJ)xG{=rvg@h>>}ATT2Px~gkvbz-(cZwc zQv7Ft>C9#2efLS(0afOZ-^JAJ+s1MhE}7jHWM2uQD3m?rx-u}U33S5V!$MxCDW!_eER7lqL~vRu*zmmY)92D;FLz-AG6Ig0&DEv0Rgu_ zNh`TL9o>da@^7gW_Q)N{=oZCZD%~ZDRq%hx=Ohcv9UBzG5w-C@z+|~mt;I!mHrCQv z7K?)$Q>+R-6wA+Eev1Q#1?Fi078^X0?{ zeaUE1>@k352VmN=nH-VGy7Y2@p2G5=UACU*nU&TP)rGkUC5#nb&MVF5E~*maMA`4g zf^(O2lY^$k;g8VNa+eXF1BBXTX%bH{CCX-sS|v{cOE*XPyM3L;#`PwNnxc2N)RLvI zFsqJNyZ&M2m9-X` ziEN5HV8HTAOg&KoWoa{K!Qhjj6Y8aWQUqDeQMbIt8qDKsDu$Of-5aMlT_NPaS6f;n5pz)r#s#mR0~`xPG|zyStG0>4r+D z{&ZH-NJ7D2SPd!TXiesIyC~JmNL6n6z4az(bSU`IgrK-qAS>%6KvLasW=1VMR;LS&`!5(IgLb~}zqK@@)F`ttZHcn&3$7~`; zG(}U=1$+8-tlMmu`x#2^f9nU`r}%#Gg?}|_?%pqI998y6pp-tntw3|597-G54@ojd zFWrJyzx}TnCGn7wTBBs?R*7$7Nl~vUu>+!ztFsiF;s9aP#QErwQYMTi8f`MMb!&Lw zjWuS@a2fFo$3EcT;nFuoS#f_==_~#-%B2}LhYhelX{(cEQI-hPm%b1mHy<LIp+K`$2VbPB3E`htEVV_Zl^9*+6`D&=-$f29q4e`OfTtQ$&{#e*VDJQE%Bcku^P zv@B?jvL-o@=!LiH#?ZwS4YT#a8|G;v&d8Cj+AE6{4@9b`=QXL=QzX5w8==!sf{b5( z9}-Gs)re3^S!IHx+R=>n=B-#y7P$Qk$?t0D`7-!+%XSp*XV}UkdT1TZ1|0xsV@0fs z?c4%E;k+VKK<_pp{IrmW?bpg4TbV&}F!DNJD11Ebx&Yl;c6S!qw?v1k54G5v%^&n; z(V$%o_y-eZUg|cxxu$Y6$XHLK`J1G-)6*SoJEigH3G@9JmO=&9mX#z52T zVw_lpwkl!p&wqdeW0SMxoQoc#iZ4B4+Uk4u_Z<6{+snG*JAHgYxa!Btw~IW9u{mA5ze)zZ9Pz zP@yZ~n+-=uD~*N{xCW*TgyK6N4x`6xW|U&4q@nR!yaF(%Cpvs|uBBx&5QyCjRJ`Yd z6Y&AdEE!Iag0%^*X){L(1&8)mz4uxaM$_p~^pe>k zs-EDI*`ukf6BbbR>YNR!cmc2@^u7Arc+;Zr9iGfS|DE)_&87jSvg^}P8t9I^X?m&n zCOjH`5`}LAV_ce;CJL00qx=z!Q}I@sHBIW0WVzL$0>`_!f+pH2C2rn8uUlovoTZiw z&UjA%(=;groM{uLPDw>&j2p*~H=0s3o*Tyzd67tvwN-60mgkB7cOy)2bxfNtNH3d^ zj;@2PqghHcIcV>uYoih_{&vZwH`_L}WS1=urcYKdhYHg6>btPvdh@Iypf+0Hy0ov@ zW&5sgs~@l&YVNhY*RCy8fg{DV2lLy9FyP>RpnH#rP`xtLxD|la*63o|Ig0^df2OyR%ojVCFO1Ym<&WR2}}kmq^8Dz ze8&bz*-y8)D&JHVxAioOi2GU1)zcIanSy*o-6m3Jnn^oEHdiyt~ zV8pgdZh#Gq9 z2_Q~W03;zDg`eO-3>VvVXy?(IX3rd!^e%5*v*A56c9)*L)QXXa-KFLEpf*8}@u;W? zc-uc<)qR=I1Gd$wWn3qRy5xMT*F;9G=Ut2(skYWGz53{3OAj7tFHkx9+2)RYk8#{oMThb$;rp=oFXC+KzRL1a{&$wO~c}O2E09-HupM>OL=w5|3(Sj~q#!*pErj{coj@4NJel zMEScnmr>xCH)N;bRJb>y{s>Zrisx)V><+47FIamiYhI{De9slVaahrI=7tJd(a>xR zcxMCyAWhG#h77nHCW-%?(u(aw;n2`sx_K-SSXX?h1td;m_tQ>P57~`6sP!2(T8Xrz zD-oicRCSV~Q6j^ltyn$q=0q`HKC-p+QckXbTS|^K7k!ksHRygy=}`*Xo-(sY=jztp zu=d3r_wBHPT7b6S4?CoiEq$Fl+(6R+5iuv>}jwG4>#w$Fr-d;!UPO%wOSO5^4om%+G zu)L?>F*UM7K=fC4ehP=IO_Feny9~W1&e(pP7Kjz4H^Y!JjmS2Oyh7ykA<8x-Nc~;1 z_5K&Ej(3Eb#8y|P`tMX5K`PjZXOtif9Skbdr0@MJYnqK;!*ul?>Wa;vRIZdRmCF!E zE2qrgS((oQ`D z*fen+ny>=(%GLgMy&yCWPx9l=msZ~b6d)(!VR`D9aNq+VVeu%OzWZ4kP zUs_{?^>XlrvX1UJvEWrJ`?-X8`xasn@2ZbHBKR4U)GK;@5M!K1^<4^46-n~aRU z$;93vI^4z@1=;za5l!|BBg6Vx32z<>Z1c`_HSp@3MOjxX48vc_=enHeA3n1cFB*dX zm+K$DVO#5u)j$3k*x~vIYDeP6Aj;YOWcDRFShyDt^4D`}{cPOGSy0Eu(_$O7jpt1#Q8J@{q5Ft*`s-a|f=3`_$h^=M_UR_vP8XqHTQM!&9$k4 zYRW7_8LU)`Wb9hZ0=rn&`9;FK_^{a33zY9t%-3J@nUeR{URmr{U^3n zseiqk*3zFF_zSE2+c12vgb0^xSFt%V7gMt4rd$7 ze;3rvl@DM}2k9uFv&+9nlzDy+vME~%QeJYiGwC1PEM%Vs~T&gdp z5$DKCpIOHfV>Iv{GQGo)zvgH%S9vZwvZFM-Mr-solQGC1WgGS!ialK&9d{#B&lNxg z{Q%$V;MpbI7x32E1=gGzFpBxQ3X(XrP&%4>*4>o-{V7;7n=(9+q3S90Tx7|)uTkyujWwSy>w)(4SU{GrD{dG68^Zo*t+Gl;C}xi^YW^DH zMv!~hVx;-jk|H`9?02OZX5ge1Ii`lth5SF`3RhNBvA)G!Lza!Cm3F7*)>N>Ovcx>t z6Lfr{(TG>Kt+wj!{YRf=x%_i${#$8NUcizVq{yhQuHo6tFR8dCA|T@U zaLvLT=uJS9J=8GbEfr*k5dMam+Z;DJC{2v8iE_0wtkJ=BcW9w@FHs`+gEoFNgh@I9@fWBHFiw+CXjL zCQGYdkEOesy?nmVFjO%)6X7m{+Oe1&*>(d(pafsB)KgG@XUH}3lk$g?@}afP~x zCQv;DGA~x;v@R_rYpryYx4a946LwWNVY8IgfMlW}bPr+AEa+zm2~`NIRDul6fxY>tNC%*$l% zb@1hgg;VnA(Vvu}Bw07e&G-VUZk0@6t4wG_D{3Wxva1)~xERXEH**(X$_Iy^_^|hX z`Jywq40xY#fhWxC8%YS~#F&Clr%Lom-;SAzucPUDAg<|DeSY6`AIVsD%6BxtUz6NV zOqpW2N6JCBwEIS6aFqH`&y?adTZ!67_bR3|98=KCR7@3MrhpArZ8ro=pTvlGxyWsG zab94TisBUTP$@Tk+8Kio-em}KI%UA~l+p{N)A0XdRB=;3oPq-R)&6+sEV`Uyt;2g2Z>`)!5Xh4*6dU3f}B%QxbpgMq3#w;^oh+|$A z_Y5-@E$j`s#BObN{n7Ol?lkY8_Eb9YG1YG8I*v8j#%R8WKct~8}@@2KB+ zN!Q*fUjVC|-P>N=q8xNU*}71}rIhvGL=+jr-_qizcL;nJ~X}4bBFk3k7F7`4695+j$`7A zP{X6ks0KT;-j)`!##vrvb@$_Txc#B@e%Q%56dU&yds1v1xb4+VO^$j~%#`hBKBR1~ zG6GPxTV24-hbYo+iZi=yhf50QLzL}47vttb47d~M;U_BFx2`IGZNvGHKFYSRH2=m^ zG;_OGa8p?0UP042 z6W6M&4A*i+CXm)M33FLSOw*YhEn_lx+l#Jh0sXBgWD`*i{$Wx_6ox;lG)iNX()h zAayYA>2Lzxq~|QGC93FF#sj5lw9tB&nNKI!i9PcPXD?)Dom)?TL6$W(9jg)pd_Csv zy^nb_r?da1w63ZZCe-IDzpcP&`g2xtUS8~!ok-tT%*stXV`fys!&T7FMhRG%4cIy% zpq8TX)Nb%SJugor?M3UzT+^am)HxWyw>stB1DLs4TDV(g_T`Ej&>!8n zX=+Qs^k%geteuscaV?RHK1^j-oBJ(37Msr%+PkSIplM}c6<_OfWBwDVbJtUG_He~H z?QD^m-Lbj5)AQ`S-^|E$pj7=nDPnaW;Oio%V;0rq5t(Z>oU|$kWE2(P00t*fk7`A3Z0xW2HhltDlUV&b3Wz)$@4;PHs9B1UWCe=Kn0&QzctH)wpbm&!a6Zo3b z0hF6_D3{GHu_d7MKdx%RPs=Bt19M@Xf2$K1_18JrFf%)~O%cGY4axwRdCO^6)}rQ@ zCtdU+9c)|74yCupQ>wEss$vts<3tvo$(hWD+g(+E>v5g%vtr6tkY=>W$!^UIO&#R1 ziaTpRteN{Bv1@9U(51*Qm)^r0s(MtELVQ<^HLsaFV^!$%yaqdRO5oU4+>WS#RKIM|wV9!&`|z?ya_GgA(kB-1!gH=0`~ zX^usdk<&^NO6xlvnMxVyPE;|ppBI95{>*unWOeg}b|g`K3`ZfXhq_*{P-f$~wzsS6 zPm5lnK1RS-h^M;%t6OA?KY*>zc*3ZbdhJTH*%E&M!m%@Fz7FVERIsMqg5urzIj#)p3a>u(jb6s`;frh9g<6t(iu zMB>xdyM?tHX!U_weyx}uBLE59epwwz*n8&xb^Q3qvekuEkC8OkUX_(=rR{zBf5@ET z=242K-#Z4fH$uR+tcXU=;fwC1qp%1xAZA=4nJE_fb?XO-u+@KBhto90Moy2R@!R*r zApR*gnV#3k*scvRt=1YLa$KVRoO#$VQx=EQ1WlTDr#PcMGqzA}kys5}{Nk60S~N%= zT8E+>H`N+Mu@^!V#{hmJq-^LBO=dPk96!&H;$aemkpm)KUtMei($?hF)?RS_3<#e4 zjKK4vl^2a;yq3A0;_OX!pouxz_#Zkb#Y>23lH3J7eGPDf9 z+WFT#0IQ0sKnkrvPSwknhH{e6P`A)RifHVsy7)PfWvhke5z{yp7D#8GZY~ZLM-!!) z#f=NlbFsff@(=M6IW~T*{EuRO0JU7uFoQ z94W3)$Z&;rP^fluVbKsHKq;m2EaFIC+(u4BE6aec=E#LCM~>J*67VMO+oHu}0}Vt~ z>Tm%JaCu9mLZ=ERd2KsFYREBVs+MS`|!>+lVS1j2(iUMcbCZ=`={({xd+3V~R4 zt_pRdDoP>(tiV;AelbUv-LbvK)>OK0{mm-nR z(D^GPk4y9s;k4esvr#gH$pD&{A3t+?1nJ(AMg3;iG(j z4|*8nt%&PYvah&*)jlR(iV@5ed=2xx{YUr!Uy!+N!Q5MZxM<0(KOFAwyYzJMM?wsL z&-dhDWv>B$6u=*YrtJ0PlcO2=pm7;g>5*ZNObsjed?E|JAdp$zSbMQ z7l}-@4tf$_>$APD^_yG|)~f*3ktL1gqspb6__r?7kS~AJ{6q*B(+u26eXaX4!+hYY z`_f5X3Vs;4Yp8?%Y4AB;>%%;(Par;MnI5#qWt?bTZ10M|G!S_PV)d0%osI%03xnudgBH3DcLfX;md_P<5(h>4c!=S3QO>!{;wMEqGI{ zk|(8FvK@x?7aR1GT~t$>iXN5;t}Y$j7qmQ;7c`$7zne2jZjxpPEe}3PWID>p=tVWt z*f--H)}O64xKvGJswFv1ge~An*6C)~Bxgf_aYa?9es#jKoPr-$mfX})LbD`@ua{+U zwAFNJa9wWd7};r>fuV%6!t1xm_$#X8BSg!Z_*4D1WT5G(R)Un&803~+NgBF<(k3Io z>z6z#E@=6+vI?6B#;N*LII_who;LA3Xt`&Bk{vsoXUPRIZ@JOGWqS zJNlM_dll7k4?E`BW`fD=hspR0K@?TzVFy0h`Rr2mK4s%hvS^X%*U$l%SH}2z0icc! z7bR_Jc}TnR8Q}mm1254S6S8anU~)Z1Rmv5sr9F*KP1ZJM)?g}YQec{3JDaKaW;WHn zmTKX&sbFeT@q8*f9tf`Cq;Xt}Dq~fmd(GB6GLC;_zb-ePuBs!$ld(h!S)H)3gLHjO zwR$60t5+LOQ=>3&h(wG;A~ns9o`|Ba)YGv>aon2NYja~Rw})y7gZm088tO8NQ@6M- zS9wJ`&f~=gWvNruU2&7$u}~3UyH|}VVjPvK4kQJNQ4xZnBi}5VA9bV=GfgqoiV=~5 zsa8ytVyx=%!DRek&~mRB=q#kk%=?KiI#?=kGp!PTKgb+cT=3vCk!pV02C?S*p2``_ zhTy?xUHtK}srZ{g=7=H4{j(xd*&TAkAMF;d12MhlXztW>UMl+4kzJsJuOOJ?_`a?3s)Ns?P_^`zqB9ctid%FRT>_KyK} zCgb@ayCs;cW?&KvGS3^!sCA(JTP;vER1n?9^f5(mH$?YpHUX1+0w@{Z8)P;sS2~lo z%n!C%HQiS#H|B~U{y_j8qD_Pc!Gr4@Bl@AwkEHnf$p`Z;_So2T^kpTiRl$s4t3cb) zv8p9K!FBoI6oclg*}$CXB>?O?-dM1KaxX(IJE$V*b*`LLd@wb6OpRu^Ny+GO?L?_( z2HB}FmV>z(hjn5S6Xd&M%jjSf4Wk;*4&cfJ?S0S=+;vOEyHleqM%^v@yyh;=5pgzn z98s9!FX8z=Wv-(Ke1a*#&3-8gU}&f)sx(^jY#u6-*#qhLE1FL>=*#y=W{_3-jJ}1T3oj@~nlzyhU-NpjrJ(<~Z{{&ht z+n)4ol1bH6?`%KLGvw_lHb>fy!F4yp(%CmtsP$skU%Z8-E%(?s*UqV$NtUDSLq>_= z3vy4F(rN`9FoKeqO#%yYt6dtXH7V7J8Ouo&A<4{(f=Org+gI-4(JCS=*0Q(tkDZlU zX^o%C(3xG)N5_6Mx8@51PPPqbuwI1Dn4&V1tbXqM+T5dOI($@8@fu&Vau2&mT9=XD zKbBpR%aU<6^4$uh>1RQb&H$}d*XG8bm5y&oWjZW7Q4=(*7(40%ne9elCa=N+SBL9z zPkhRi$!zFY!I;myVvxBtla!Xu91*%}bCsV-#dm8Rc@ie8z-nQy3#ihp+@l^3x0PI- zMf>WrT%FZSBxtIi##YVc$@sBU_ATuyJpwAN88(>Wuco5A)jc()Q`uL8_{+GQP_EhP zi7XH^YSHei|*CCX?B7QyEx$FS==vVLTPSMkD4PJ=eGf;R~X!p_19} z!4ALB&_)4ml+}EQp+kjG*JggxnN5Z&yCa?1C|o#D)S#V)fM)O0!z6!if!I1W7SBt@ zIgGPRGu|?mK$>z%DmU>GCIE$Jr!t*aw9#)ht~Cc|iW{HfGlQ^p=W7aV)X#OL_F=VJ z$wfdncW+fzy0b_sZ^GKIepy}h1G=qi!XWd$&}hK1ZYed$J@IAnfWZ{AUkzII_`gQ| z*MJJty&dGkpD+kgtBqFc#%sQ)NZT*7F_9M$$=ph*#x&d^y;ZH=k&JhVi8jsKEQ`=c zayq&r9q&r#CQi}}TFoQL_|4yv%x+G`-wC4cXna_of+;|#ljO$FNXFS6exGg?L=UN- zX@W{+pG{5HG>84yI=0sCH8#hkaOEF=hKVMfdDfO!dW_oJuLViSNoW7ESx899>o-%- z=t^dGYRG&uw{n!#9QrMv(SLPaklAhnR8tG6HC}{3AYm4d+i*&f0I?VgVzD%Y5@UuC zuV{q=a2wM#hl5+KHWIh`ag`dv74nV7RHnXWg4mzVt(+i&ed#^mQ|4y<0yF#9UpEr2 z!6jE+ ztT;yF%*u=1_iCKZTw*K}uZ9C{uNg&6vJdqcSM&SeX;>YPWf7Jy5R6V`p84PlL#ZOL z`upd(<^h5A-psSz*sg{n*l`u6Di8$e%=%TIru@v2B{-W!R#U=o*sfg6sM%oDw0)W> ziJ7TaQ%Yu|(n4^RKIx6Ggr(19Hg*aXY<#P_3~qRzd>p*#O#1?islrfO{rQgqy?Q~A z*)|Jqq%-So;VB2?wnuUOi&&w^5*cOalR5eVMW?bW_EJn>YxZ|0Bs0HLDPn)^BQG33 zer;}bg}{^!v8qFm{Tt0K0aN>=b9OQka;vLCkRbb=*fFV3997+qyW$7;S-o1AB;Y{HS4!g1yuSL_Z=;FW zZ>MH>T{)O{WL9qcSLD#?x6!L&za6ub$IjYzRR)jP(VU0!v6Yjt_a%r;tjI?>VS?S@ zRE|N74&of>xI3SW_o0BDcWdm${ zsZv8-XMvE15|zR2p9$N|)MUnwX|x#&M1P{~txEp0N*oX79p`248EI2< zF&cc3&U`4!K`}|vP^MR0PG|Oraw_0zWjg1q+?p?mMz0d3bN8GoxpY?Uv5B77H=Q~w zH|A^#zPJf$$9;otv(#&(Nd#$;+V)1JUkB?E+HXm;?S?+}to>9VJ?{w4%uw7XC*!?n zqi0DXNXOq%txN{MS-B^^DsawtO$*{jrM8xYncVnz5HG@$v(xcoUgh?RN?4yz-9rap;AJllGDlTL zefHa<8O16fmDywjWHu@RnaGW4HGkj*7G+K5Qs*Pj^}4?B?aJ&`W0JYbucYEf*nK+# zvq_Y}14>o%*4QAymxPuvw5!6?#4feh9M#RQnh<2xt4Oc@s1V4i3JX<1%s&RfxSYI*>{R9@QJ2cBcMO;cB_?e;5@t~L zx7oZN#B0NyJ=F86+MtEiQ!HHRblJ%jcYTIg`4QrVZAao803B@Rlzg!Z7b04VXw`$8 znTpd{L5cO4ULMgTTYYt|vMx71qNU;3)w%J_ikueY5>-wo zn|DmVwTi7PD_{06LD+v^pta1I1lCMPW9XDbOi^Cba94Fs+t>^NXW1+k^T02$xMeSWx17J0y+xqSACeeg4H+n(3f$f zB!d(EL<<@{raG1TCo|1wi>u1oduB^(58Wow@_3$~w%o8)@-GZt7XIYkaS3R#g31GTyJ2y!b4sbu5f5 zA-Sm+)1Cq^GPPuv zj;_xpCnRY>8cU66ZRHm#H&6Q|N;8PEFsF@6#h**lj@E2}YqoS|n>Ff+wrXfJcAu;v zt=a0cH_?#RbZEnFrgTlLLMU8KoK1XK>I2?lWqrXGh@19hzx7^fdVlPp+kER)Gqv5I zlPBparV`C>9TzfttW$KgX1i!?+)Sn{-V^XiI&6|=GIU8N=3?4%Nn{_9t07Dx`-rj{ z!z8kg$h9O)BKz=Mf7?vY2~)u^JY!CZWS9z)5qVpOQXguz@QCcv>U{Qx9I6o*@?`+6 z79N42qYS`d8-byx3?Oge5g7W)035b53?XqnBXfoc%Iz;-Xra<1u6q^ZS*SGG4_l~g zX^LG~&q$b;Pi8lHeSyXv#~upDl`zz^a$yFo9LHfiOw%Nd4x$~p5JC`3yN*cTVpqe= zA7M2yEMfg{zDq{}1q=kHD4jgiDaKkCgQbu&dN?ZQXd>tLGCSYBCe}2*PNnGx(?&93 zvLHCfLFM^+?eAc>{_H2=iJE5Bo&(QvZi?4KQ1^j)5&^mA8)VgNdI><58`e%e8aIbS zaGCcxR%B5CP=;P=o3kZWrAmTJ2M05Hg<;NN;6$-Q#8TVD;{{c!CFFp&3d;CfB4g0J zlj?57eEGb{$ZzK^&96z{kG&-H{Dyf{iUFMg5ov^CU=24dX!oSj3&aDIq20w-SiQDsIB@s9mD^4hQ zh-hF2AQdA#brx*yTtL4OYeK(PeK2U+P7pMfH@l0xTAE}>wea+3}xRNy}g;*$w4P+9oSf_(WC{3#J^5neE^t{)Ru&&8f{w+vpsGD^)5CtPWucICT zagwO26(zFMHlQbsLS2ca=XENzPN}n#>;#Yxfsze60|i0A93~Fc1@TQ%r7`hVe#)dx zj1}yfzJ$qtLNeZop(Kc}yAF3&>G(F1rg>|H-=WmPnapgH0FjCxRh8=eNJMXuv&DSSdwiGCr6Z7k)14oLFLp` z{249tXq*xllBjH%jV3QdQHMGt0%FuP6T1^2LKB!Ix^(7M34Dp{cTrqlF%b|hEk^|! zEx`|2Ny%Z3bauBg0X+e0*Qo}f9R@|3IFmgD&1|G1_aTJ=Hz|m|FKu!X+hMAfL|e97 z<3(Sgl9226PyPP3&>6FkaX%Hrx2S(dj9VvDuCEi81X)ijyp3<+us4Lns9kPfclgaK29*Ymn1E5C5 zkdYYPRkHUeYj_ELCNDd(@gdXgfUcQAQ`Ph_6C@{kmG3dwIBgrxcMO#B)wUm?@8pUC zYSS}yz`d57{!HwlzeCm4Tw}9sZjEiA5BVBxnHgzG*99vI7`N`ax4tF!G-DrAV(zAB zOYYuLnqB_$heWjrO@ANoM>+8E#h#|-OKTigYYHydOw4rc+b*4Z z!ctggV=}=)vu0iBbS-NJXqw&T>fB1RUof*{O|^p&sqAxfIZ8hyB6tl0I%V%n#oLn6 zquSb~Z8AqS-=<{E>2q_fGr*Y9CDX47Ho>@OvVBKWDy(upn{BT3)*4gqGsiTO3Yb~- zke^4bbFt#ikyx^iE0OerXT6PP#AqfSpp^Jq`_M`U)wN~u>N+(jCxbg~}Iu3{UUST4dT z3$a!Dv)o5N?5>2#lLoPYk404gAGviyr%{no<(4Q8C?zcLkhtS;koG{vP39k^nHWlR zw-E`TuecbtV5J=D+Fl(oe9YHF6HnWnBIE7Jfk*D9xx<(UbINiOMEc zDsF=hFA$~GuBr&)eAHPzC%GtwpSYwM(_!$?{LH4%)iupZp}r2>ottuyR+CmGtNbv% zj|k}Jf)Ri09kJ!|oIXd7A!gnb#tx5>h{4zSbCAs;wF3PBBt*>TcAGoxLcyM4zL*8j zv@-NZ%AHxt(4AS-Z?Nm-K`YnP>_Pu*^a}4(6#14J%-q%=Qba; zZpl|h^#C7A?!#eRh*{p~J?1)y=L?%R2y}DLP#x5Rj`P{3q@^1?ggq4Lkpkw4Y!IND z{877F&lf~OpXwE&LN!PGS9f!$GABp1WC&Gri7HSDx(wA3L$&JnQ002X5VlrdtP!Rc zWy-K&Zh)}0zlSh)#&-x?zMQt#hGWw$Z>~)JUfR@)hOqUN(^C;CL-O*MKAOPM2 zM{E!e8d%$8U5m|mpTxr?uBpi@w^}X65QNLr02j7{^0e?+O^-|D=A&NZ(7TXxxDGh@ zI*IPQH6Pqsgsb9v4F1IiVTKvDbT@HOZHP-;c;3sOrbCv|=54sarBCYVpk)?YuN++3 zpMz~o+hFW=VYkzhKeFe+4KV`?CHGFNjkAL?RCE3d2<&+w1V%X8t*#uRPB^rw=5S!^ z14m;ygm{dEYB`4+DOEv>U2zFoej#G(vny+)Epin@x~J5x zDwK~=+JZ#nPK@Tj!RO`2(SuMEJZfeN@m7M+VLZMt!3xjlGF}w zOHIXh>FNayfXQChU;<^Y()AhiVlF&DAG1Tc6h@iC;qHNK742b%XS!fk=@WKY^kgS$=G3G)fb-2bU)+t`H*+|qGI3-u0bZ5l=mn$GMr z3ugSqV3dBthvprA&ND|3ah5#PzI`&CV4B^($^F{OrINKNPMqg{ zqfbjg6;4HW$xh9o7&}pu%Kc2K*U1FqVocGcL#pId?kS~0hvqn;;CTB(8)GZ0nKRYv z7}4k<>FjHmE|P8gz8?`NZGimLER5LPQrqouQNCEE@vc?QX@89`}@Rq5%X@&lGirRkCv4VI0t73YvJ1aYUsf}rEb+P*S6QjY|2uY$0*7A*n+9z^5*@ zY-*GvH(#TvvBL7d)&m<#*;kTn2LQPK(kQEoc*=w+D7rmRFr7U!qbrwksiU!6v07?2 z#r!Xd!xYX`x|Gr7DPd%TA0KS+GP=&3#iiGD{CS%kg^g27I^MQkvt~G5*=Smz`=)Ev zLL61JJEQATEKE9(qqFyz%NQ*0aXi^g5pEMv3~45ht{3L{O=ApY)T*KVhDb8ofkiPL z?^B21szN*&#|s!HL2s_ppi=|;`zGn=0lV8Jd-DdvVi(uM$7VjQUENlSnIyvtX>2n^ zcbMgFGOJW*;bi6Nu}qSQWJiO^Ob)JtiDWS#9qmg^rcbGeRR%qkC0ILYtlh*}2g@E1 z3>%d6DBXsF^O#e_)IfJ8Todm|#t(BlEFIk@I6&4|&C=Nyx%HKfV#DLg64zXUQHwxD z@yaHdq*K3K8N)KEoV3Knc8PFy3OgkQRV&*P7Tj;<>UFvjsR3b51^0;HOqZI3#-5>7 zkMoi?!*U?37VD*;S_Q>=OFQ4ftmBU)gLsKI3)@~De0dx|4jO$n8E0Z$FViW#Tb(8P z3Wk;>W>j|xOvvhVw!F$t649K2WIxg9?@5V$XCuZIHI!I9Z?Xcd0U+98>F zO}5fx?ujbR{cQ1tcd?T0E>?&%ohgJU{VgQFW$5_M%&XJEt3Trs-7YlJ#06hmFW*y;B1 z+`Tl3agC`A(zzkg>0p0@E}41RYKi>PY~^v&ly+Lc5ImcP6dhuj1~*;!X&Y>u-y3U0 z=cd*Mj9B35?HmeGPmmmF<0Ct$xkJMu;3{Mk>Bk>Lpa(pn*z7a(yJR$fU9NJnx`oQ& z#M*_)_|8<8qlnznauSX1OrQ zwl25Yl2LXB=!kZN zwkH{VjU&M7<}j@0%w+s|Nl&eiq_`!?EZ5U*8e1-T?5D&hGaKD*H68$*c9F`!K5c{O zI7Txdw6SeSGkBT(dUQvPNl;N-rt{Yx8A;*QG;UfdcUgnl-idZua^Y>V1BOto6K-s; zxmYd8^#Qk=trMC^Us&?p(Mh(wM5a1_V62j$hL1D2t4*xTSN~;<=e3s+VT5_|mS*Msxr(n@ZGKsT$ql;=CsmvBz zqG1078@O)2%gL8NZx*Hub}bEdHeTFbFR*sq)B)XCCx{6eKjjd1lh32cdo*^{uN=r< z8`NU+^HvXJw-C#L{LM1~-N^7|*UNUt!cBRoX|R0}TeE)NV`!XYti7LdOtZDQfxkZX z^9`20L)XVbfFR@cmff8(u-oXA!Nh1%#gRYSU*}puh?_pQRZq(JWAUa|faQNGw zi5s7s_8B%aIV9+wAr*fjGp8I0;_d!z!SuYIAj_16RJSL{t?`Bw%xpMHBFe*>!a0>_a>cWw zlPdM~d7@-tJ^n?*gQAA%Dp%g`k zDORjf6d^0n-hcXkjJrYhP#I7Elpy>!uCHjTM6F4-?frU*^Vx5GD6VhUu?@-!Rvc(f zO+OfW=ofbSoH>#>gc%FJLyh(|)WsiK;v-g3 zzTjsztFN_&U#s-BHvDSUS3dk&tFMmmE3dDf@T)^#>bZuqM_*xwp4q1_>&-8tEU{7I zY`d{%R+>%kr?GL@G+S&POi^qDzlvRDv5h`N^d+b7k3DqFpE@K;ea)ZO&u;ilHQeF6hV7b_^#?Zp z*+A2oa6FhU-4$b#NO#G!c@wZ>rsL$!9X_eAD^QlU8*?fFHNxV4ep{~bFejxSZ^GR6sU8ni@ zeA36~lMoO6iJw*^$dAt_b^b}`lZwwD7$%<-|NPLf?|-lI`6~bKU+QbWw#s>{uZ%3m zh*(Pxtc<3tdUbrOv$uTa;mGBIHtk6NI52 zLJqD1zxoa?M##avcwHEJmym-4RRptRgqf3ng3~`a3K`|1T!a)n|L+_8R#iEeieWIP zh9Hp}eCQN@KgwJE-K4FBCn{?=p3x;d{~MuNxOF1Tkca)%z%;!dKB zDZ%_0nC2T#hNq?k^Vh(vIw{QOOE8ZE(|S@E2Xf-;0?TJdBI8Q=6)5+Et{y(T&pXg^ zALye#0!EN+(4&8q{68u>*}^0Lyx?IpF5HNE)5i$X#k^aJVO$u3O7~;oipW`f{i*K_ z+G?*DT;;RaCTO}6t@lmel^SbT*75KBl{lHn{uevoh(1F>H|1SGaD*HQ}-Jnr2cstG>r@;ofSprx9E#!rRE{9Co3BdOS5W00J0+i+(0v{HS~cn^g%H6fe8A85a`2|CCid! zw`jTjlE^Pt@A8Wz+TxXUJHGV(e*aU?C0lWJ7TDbnynLRjQ>RXyI#qSfsZ;g*j!9QP z=ApMMedG(Qz43L}2IF4t>Ehw%`TIHJLp)+JzWVx*eeEaY`qhut%b(?C*57MCoZmks zx|v8sL$`i~)CXcg5e+;N;>lp6EWV za>+4_1$^BlZqEYaWoZgxJ{XRe*`=3@+F(Bi5ZIx5`1EDFPrY|s%~t4#xa}le$QR!g zmA`NIr?ksc?xi=3wz|vz8427%ZG_<+$Nm|Yp1<|<9UnVD2H)A>s!VPZ;1-LwarHf% zQ)~@*`fs=%ieOak&$upqM#wlY1b1)=MhEk!9gMeG(65L_!PMSM2R74zaZp>ZiV*0q zafwFN_~C4$a5}S1@e~dulfU1zr+#yqpZ7*yh=eB+MMUpSw{qz&2`fpM@-eA_ ziahUy`qN#gX&(diZ!X7m_pw!aV;68{0(kn(fVW)+I6DFS@S6eGE(4sK0Djb`_k`g0 zgrKX^L`MsQnV%%SH_ggudJBm&=FFtkQ;f3T_Vr(gD8l~!V0(WGvJp*Q2H)=r^}Eyj zyx+9UYQGcE-wCEL^mkIxinxAm!pXf*VQ4{xm4QlCd!fQmp>|mrz;JReU>I6}9V-Jk zD+PN2!_WfkSQ)@@axY*Q`rBdRKNktxRQ}rTYm%B`VP#j0EVrp@M&W)k;I9iPEaUli zi_t#9Ieu$Glbv2>HvV19I-`M)pOh~SF;^j%PFS1-dEnS;lYvkW*f$plcatq*I+C9~ z_`=p?&ydq~C=^w7oEK>!u!LG(#-&*advj$Ub~+8af^zO>)fhfSw5|y+t_d~a)<1tU z3A{Vu{`Pbi3C+v@n^_e}XIA}>4?9I(HnHyAY|ncBm}@zc(*MTN?Rx$T`&BN`Bbb

L4F~h#X1Ct~ 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 z%0D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