diff --git a/.cargo/config.vendor.toml b/.cargo/config.vendor.toml new file mode 100644 index 00000000000..e2949d69adc --- /dev/null +++ b/.cargo/config.vendor.toml @@ -0,0 +1,12 @@ +# Phase 2 Cargo contract: registry crates are resolved from the future local +# vendor source; git-backed packages are owned as path dependencies in +# third_party/ and therefore do not need source replacement entries here. + +[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = "vendor" + +[net] +offline = true diff --git a/.gitattributes b/.gitattributes index 176a458f94e..713cc000742 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,16 @@ * text=auto + +# Preserve approved vendor bytes for copied roots whose canonical evidence uses +# CRLF line endings. +/third_party/kcp-sys/kcp/.gitignore -text +/third_party/kcp-sys/kcp/.travis.yml -text +/third_party/kcp-sys/kcp/README.en.md -text +/third_party/kcp-sys/kcp/README.md -text +/third_party/kcp-sys/kcp/ikcp.c -text +/third_party/kcp-sys/kcp/ikcp.h -text +/third_party/kcp-sys/kcp/protocol.txt -text +/third_party/kcp-sys/kcp/test.cpp -text +/third_party/kcp-sys/kcp/test.h -text +/third_party/sysinfo/md_doc/sid.md -text +/third_party/sysinfo/src/windows/sid.rs -text +/third_party/tao/.changes/readme.md -text diff --git a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml index f79a7770b13..ef15c721ec5 100644 --- a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml +++ b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -27,7 +27,8 @@ on: type: string default: 'Windows10' -permissions: {} +permissions: + contents: read env: project_path: WindowInjection/WindowInjection.vcxproj @@ -38,19 +39,30 @@ jobs: strategy: fail-fast: false env: - build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }} + build_output_dir: third_party/windows/RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }} steps: - name: Add MSBuild to PATH uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3 - - name: Download the source code + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate pinned local source + shell: pwsh run: | - git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow + $expectedCommit = 'ecd8d6a139eee76845ea66423fb739af450fda90' + $sourceRoot = Join-Path $env:GITHUB_WORKSPACE 'third_party/windows/RustDeskTempTopMostWindow' + if (-not (Test-Path (Join-Path $sourceRoot 'WindowInjection/WindowInjection.vcxproj'))) { + throw "Pinned RustDeskTempTopMostWindow source is missing: $sourceRoot" + } + python scripts/check-flutter-source-ownership.py --require-window-pin RustDeskTempTopMostWindow $expectedCommit + if ($LASTEXITCODE -ne 0) { + throw "RustDeskTempTopMostWindow ownership pin validation failed: expected record path and ref" + } - name: Build the project run: | - cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90 - msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }} + msbuild third_party/windows/RustDeskTempTopMostWindow/${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }} - name: Archive build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.gitignore b/.gitignore index d2e09a9066c..6e8ee53a5a1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,24 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ + +# Phase 3 exact owned sources, including package assets and licenses. +!third_party/ +!third_party/** +# Owned third-party source must not re-include generated or local artifacts. +third_party/**/.DS_Store +**/.flutter-plugins +**/.flutter-plugins-dependencies +**/.packages +**/Pods/ +**/ephemeral/ +third_party/**/.dart_tool/ +third_party/**/.run/ +third_party/**/__pycache__/ +third_party/**/target/ +third_party/**/build/ +third_party/**/.gradle/ +third_party/**/.generated/ +third_party/**/.idea/ +third_party/**/*.vcxproj.user diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index d80e69aa84a..00000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "libs/hbb_common"] - path = libs/hbb_common - url = https://github.com/rustdesk/hbb_common diff --git a/Cargo.lock b/Cargo.lock index 8ec2d4a5327..2bc68d6783c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,7 +191,6 @@ checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android-wakelock" version = "0.1.0" -source = "git+https://github.com/rustdesk-org/android-wakelock#d0292e5a367e627c4fa6f1ca6bdfad005dca7d90" dependencies = [ "jni", "log", @@ -292,7 +291,6 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arboard" version = "3.4.0" -source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5" dependencies = [ "clipboard-win", "core-graphics 0.23.2", @@ -990,7 +988,6 @@ dependencies = [ [[package]] name = "cacao" version = "0.4.0-beta2" -source = "git+https://github.com/clslaid/cacao?branch=feat/set-file-urls#05e1536b0b43aaae308ec72c0eed703e875b7b95" dependencies = [ "bitmask-enum", "block2 0.2.0-alpha.6", @@ -1216,7 +1213,6 @@ dependencies = [ [[package]] name = "cidre" version = "0.4.0" -source = "git+https://github.com/yury/cidre.git?rev=f05c428#f05c4288f9870c9fab53272ddafd6ec01c7b2dbf" dependencies = [ "cidre-macros", "parking_lot", @@ -1225,7 +1221,6 @@ dependencies = [ [[package]] name = "cidre-macros" version = "0.1.0" -source = "git+https://github.com/yury/cidre.git?rev=f05c428#f05c4288f9870c9fab53272ddafd6ec01c7b2dbf" [[package]] name = "cipher" @@ -1324,7 +1319,6 @@ dependencies = [ [[package]] name = "clipboard-master" version = "4.0.0-beta.6" -source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db" dependencies = [ "objc", "objc-foundation", @@ -1477,7 +1471,6 @@ dependencies = [ [[package]] name = "confy" version = "0.4.0-2" -source = "git+https://github.com/rustdesk-org/confy#83db9ec19a2f97e9718aef69e4fc5611bb382479" dependencies = [ "directories-next", "serde 1.0.228", @@ -1552,7 +1545,6 @@ dependencies = [ [[package]] name = "core-foundation" version = "0.9.3" -source = "git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd#7d593d016175755e492a92ef89edca68ac3bd5cd" dependencies = [ "core-foundation-sys 0.8.6", "libc", @@ -1587,7 +1579,6 @@ checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" [[package]] name = "core-foundation-sys" version = "0.8.6" -source = "git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd#7d593d016175755e492a92ef89edca68ac3bd5cd" dependencies = [ "objc2-encode 2.0.0-pre.2", ] @@ -1626,7 +1617,6 @@ dependencies = [ [[package]] name = "core-graphics" version = "0.23.1" -source = "git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd#7d593d016175755e492a92ef89edca68ac3bd5cd" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.3", @@ -1652,7 +1642,6 @@ dependencies = [ [[package]] name = "core-graphics-types" version = "0.1.2" -source = "git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd#7d593d016175755e492a92ef89edca68ac3bd5cd" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.3", @@ -1740,7 +1729,6 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#6b374bcaed076750ca8fce6da518ab39b882e14a" dependencies = [ "alsa", "cidre", @@ -2129,7 +2117,6 @@ dependencies = [ [[package]] name = "default_net" version = "0.1.0" -source = "git+https://github.com/rustdesk-org/default_net#78f8f70cd85151a3a2c4a3230d80d5272703c02e" dependencies = [ "anyhow", "regex", @@ -2706,7 +2693,6 @@ checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" [[package]] name = "evdev" version = "0.11.5" -source = "git+https://github.com/rustdesk-org/evdev#cec616e37790293d2cd2aa54a96601ed6b1b35a9" dependencies = [ "bitvec", "libc", @@ -2820,7 +2806,6 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.2" -source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" dependencies = [ "libc", "thiserror 1.0.61", @@ -3952,7 +3937,6 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hwcodec" version = "0.7.1" -source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe" dependencies = [ "bindgen 0.59.2", "cc", @@ -4108,7 +4092,6 @@ dependencies = [ [[package]] name = "impersonate_system" version = "0.1.0" -source = "git+https://github.com/rustdesk-org/impersonate-system#2f429010a5a10b1fe5eceb553c6672fd53d20167" dependencies = [ "cc", ] @@ -4344,7 +4327,6 @@ dependencies = [ [[package]] name = "kcp-sys" version = "0.1.0" -source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be" dependencies = [ "anyhow", "auto_impl", @@ -4367,7 +4349,6 @@ dependencies = [ [[package]] name = "keepawake" version = "0.4.3" -source = "git+https://github.com/rustdesk-org/keepawake-rs#64d568586dd16551d02120e19668d2b0fec8e3c9" dependencies = [ "anyhow", "cfg-if 1.0.0", @@ -4685,7 +4666,6 @@ dependencies = [ [[package]] name = "machine-uid" version = "0.3.0" -source = "git+https://github.com/rustdesk-org/machine-uid#381ff579c1dc3a6c54db9dfec47c44bcb0246542" dependencies = [ "bindgen 0.59.2", "cc", @@ -4695,7 +4675,6 @@ dependencies = [ [[package]] name = "magnum-opus" version = "0.4.0" -source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114" dependencies = [ "bindgen 0.59.2", "pkg-config", @@ -5137,7 +5116,6 @@ dependencies = [ [[package]] name = "nokhwa" version = "0.10.7" -source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" dependencies = [ "flume", "image 0.25.1", @@ -5152,7 +5130,6 @@ dependencies = [ [[package]] name = "nokhwa-bindings-linux" version = "0.1.1" -source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" dependencies = [ "nokhwa-core", "v4l", @@ -5161,7 +5138,6 @@ dependencies = [ [[package]] name = "nokhwa-bindings-macos" version = "0.2.2" -source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" dependencies = [ "block", "cocoa-foundation", @@ -5177,7 +5153,6 @@ dependencies = [ [[package]] name = "nokhwa-bindings-windows" version = "0.4.2" -source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" dependencies = [ "dlopen", "lazy_static", @@ -5189,7 +5164,6 @@ dependencies = [ [[package]] name = "nokhwa-core" version = "0.1.5" -source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" dependencies = [ "bytes", "image 0.25.1", @@ -5941,7 +5915,6 @@ dependencies = [ [[package]] name = "pam" version = "0.7.0" -source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762" dependencies = [ "libc", "pam-macros", @@ -5963,7 +5936,6 @@ dependencies = [ [[package]] name = "pam-sys" version = "1.0.0-alpha4" -source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7" dependencies = [ "bindgen 0.59.2", "libc", @@ -5997,7 +5969,6 @@ dependencies = [ [[package]] name = "parity-tokio-ipc" version = "0.7.3-6" -source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01" dependencies = [ "futures", "libc", @@ -6369,7 +6340,6 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-pty" version = "0.8.1" -source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -6920,7 +6890,6 @@ dependencies = [ [[package]] name = "rdev" version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855" dependencies = [ "cocoa 0.24.1", "core-foundation 0.9.4", @@ -7236,7 +7205,6 @@ dependencies = [ [[package]] name = "rust-pulsectl" version = "0.2.12" -source = "git+https://github.com/rustdesk-org/pulsectl#aa34dde499aa912a3abc5289cc0b547bd07dd6e2" dependencies = [ "libpulse-binding", ] @@ -7576,7 +7544,6 @@ dependencies = [ [[package]] name = "sciter-rs" version = "0.5.57" -source = "git+https://github.com/rustdesk-org/rust-sciter?branch=dyn#5322f3a755a0e6bf999fbc60d1efc35246c0f821" dependencies = [ "lazy_static", "libc", @@ -8351,7 +8318,6 @@ dependencies = [ [[package]] name = "sysinfo" version = "0.29.10" -source = "git+https://github.com/rustdesk-org/sysinfo?branch=rlim_max#90b1705d909a4902dbbbdea37ee64db17841077d" dependencies = [ "cfg-if 1.0.0", "core-foundation-sys 0.8.7", @@ -8424,7 +8390,6 @@ dependencies = [ [[package]] name = "tao" version = "0.25.0" -source = "git+https://github.com/rustdesk-org/tao?branch=dev#288c219cb0527e509590c2b2d8e7072aa9feb2d3" dependencies = [ "bitflags 1.3.2", "cc", @@ -8464,7 +8429,6 @@ dependencies = [ [[package]] name = "tao-macros" version = "0.1.2" -source = "git+https://github.com/rustdesk-org/tao?branch=dev#288c219cb0527e509590c2b2d8e7072aa9feb2d3" dependencies = [ "proc-macro2 1.0.93", "quote 1.0.36", @@ -8568,7 +8532,6 @@ dependencies = [ [[package]] name = "tfc" version = "0.7.0" -source = "git+https://github.com/rustdesk-org/The-Fat-Controller?branch=history/rebase_upstream_20240722#78bb80a8e596e4c14ae57c8448f5fca75f91f2b0" dependencies = [ "anyhow", "core-graphics 0.23.2", @@ -8798,7 +8761,6 @@ dependencies = [ [[package]] name = "tokio-socks" version = "0.5.2-3" -source = "git+https://github.com/rustdesk-org/tokio-socks#bdb9aa3de5bac41602d0742b8ef6bbc6bfebd127" dependencies = [ "bytes", "either", @@ -9076,7 +9038,6 @@ dependencies = [ [[package]] name = "tray-icon" version = "0.21.3" -source = "git+https://github.com/tauri-apps/tray-icon#0a5835b0e6828e37a1f781de9c2d671ae7a939e6" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -9515,7 +9476,6 @@ dependencies = [ [[package]] name = "wallpaper" version = "3.2.0" -source = "git+https://github.com/rustdesk-org/wallpaper.rs#ce4a0cd3f58327c7cc44d15a63706fb0c022bacf" dependencies = [ "dirs 5.0.1", "enquote", @@ -9790,7 +9750,6 @@ dependencies = [ [[package]] name = "webm" version = "1.1.0" -source = "git+https://github.com/rustdesk-org/rust-webm#d2c4d3ac133c7b0e4c0f656da710b48391981e64" dependencies = [ "webm-sys", ] @@ -9798,7 +9757,6 @@ dependencies = [ [[package]] name = "webm-sys" version = "1.0.4" -source = "git+https://github.com/rustdesk-org/rust-webm#d2c4d3ac133c7b0e4c0f656da710b48391981e64" dependencies = [ "cc", ] @@ -10872,7 +10830,6 @@ dependencies = [ [[package]] name = "x11" version = "2.19.0" -source = "git+https://github.com/bjornsnoen/x11-rs#c2e9bfaa7b196938f8700245564d8ac5d447786a" dependencies = [ "libc", "pkg-config", @@ -10891,7 +10848,6 @@ dependencies = [ [[package]] name = "x11-clipboard" version = "0.8.1" -source = "git+https://github.com/clslaid/x11-clipboard?branch=feat/store-batch#5fc2e73bc01ada3681159b34cf3ea8f0d14cd904" dependencies = [ "x11rb 0.12.0", ] diff --git a/Cargo.toml b/Cargo.toml index 05c32ab42c1..9990ad829ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ cfg-if = "1.0" lazy_static = "1.4" sha2 = "0.10" repng = "0.2" -parity-tokio-ipc = { git = "https://github.com/rustdesk-org/parity-tokio-ipc" } -magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" } +parity-tokio-ipc = { path = "third_party/parity-tokio-ipc" } +magnum-opus = { path = "third_party/magnum-opus" } dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true } rubato = { version = "0.12", optional = true } samplerate = { version = "0.2", optional = true } @@ -70,7 +70,7 @@ default-net = "0.14" wol-rs = "1.0" flutter_rust_bridge = { version = "=1.80", features = ["uuid"], optional = true} errno = "0.3" -rdev = { git = "https://github.com/rustdesk-org/rdev" } +rdev = { path = "third_party/rdev" } url = { version = "2.3", features = ["serde"] } crossbeam-queue = "0.3" hex = "0.4" @@ -81,25 +81,25 @@ zip = "0.6" shutdown_hooks = "0.1" totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] } stunclient = "0.4" -kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"} +kcp-sys = { path = "third_party/kcp-sys" } reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false } [target.'cfg(not(target_os = "linux"))'.dependencies] # https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux -cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" } +cpal = { path = "third_party/cpal" } ringbuf = "0.3" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] mac_address = "1.1" -sciter-rs = { git = "https://github.com/rustdesk-org/rust-sciter", branch = "dyn" } +sciter-rs = { path = "third_party/sciter-rs" } sys-locale = "0.3" enigo = { path = "libs/enigo", features = [ "with_serde" ] } clipboard = { path = "libs/clipboard" } ctrlc = "3.2" # arboard = { version = "3.4", features = ["wayland-data-control"] } -arboard = { git = "https://github.com/rustdesk-org/arboard", features = ["wayland-data-control"] } -clipboard-master = { git = "https://github.com/rustdesk-org/clipboard-master" } -portable-pty = { git = "https://github.com/rustdesk-org/wezterm", branch = "rustdesk/pty_based_0.8.1", package = "portable-pty" } +arboard = { path = "third_party/arboard", features = ["wayland-data-control"] } +clipboard-master = { path = "third_party/clipboard-master" } +portable-pty = { path = "third_party/portable-pty", package = "portable-pty" } system_shutdown = "4.0" qrcode-generator = "4.1" @@ -140,7 +140,7 @@ winreg = "0.11" windows-service = "0.6" virtual_display = { path = "libs/virtual_display" } remote_printer = { path = "libs/remote_printer" } -impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" } +impersonate_system = { path = "third_party/impersonate_system" } shared_memory = "0.12" tauri-winrt-notification = "0.1" runas = "1.2" @@ -160,15 +160,15 @@ piet-coregraphics = "0.6" foreign-types = "0.3" [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] -tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" } -tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" } +tray-icon = { path = "third_party/tray-icon", version = "0.21.3" } +tao = { path = "third_party/tao" } image = "0.24" [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] -keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" } +keepawake = { path = "third_party/keepawake" } [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] -wallpaper = { git = "https://github.com/rustdesk-org/wallpaper.rs" } +wallpaper = { path = "third_party/wallpaper" } tiny-skia = "0.11" softbuffer = "0.4" fontdb = "0.23" @@ -179,13 +179,13 @@ ttf-parser = "0.25" libxdo-sys = "0.11" psimple = { package = "libpulse-simple-binding", version = "2.27" } pulse = { package = "libpulse-binding", version = "2.27" } -rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" } +rust-pulsectl = { path = "third_party/rust-pulsectl" } async-process = "1.7" -evdev = { git="https://github.com/rustdesk-org/evdev" } +evdev = { path = "third_party/evdev" } dbus = "0.9" dbus-crossroads = "0.5" -pam = { git="https://github.com/rustdesk-org/pam" } -x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} +pam = { path = "third_party/pam" } +x11-clipboard = { path = "third_party/x11-clipboard-0.8.1", optional = true } x11rb = {version = "0.12", features = ["all-extensions"], optional = true} percent-encoding = {version = "2.3", optional = true} once_cell = {version = "1.18", optional = true} @@ -201,11 +201,11 @@ openssl = { version = "0.10", features = ["vendored"] } [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.13" jni = "0.21" -android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" } +android-wakelock = { path = "third_party/android-wakelock" } [workspace] members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"] -exclude = ["vdi/host", "examples/custom_plugin"] +exclude = ["vdi/host", "examples/custom_plugin", "third_party"] # Patch libxdo-sys to use a stub implementation that doesn't require libxdo # This allows building and running on systems without libxdo installed (e.g., Wayland-only) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index c6f8aa1c20a..62e54abb615 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -305,11 +305,8 @@ packages: dash_chat_2: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: bd6b5b41254e57c5bcece202ebfb234de63e6487 - url: "https://github.com/rustdesk-org/Dash-Chat-2" - source: git + path: "../third_party/flutter/dash_chat_2" + source: path version: "0.0.18" dbus: dependency: transitive @@ -338,11 +335,8 @@ packages: desktop_multi_window: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: b47e8385e5a75d38319ad706a64b0ead3108b093 - url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" - source: git + path: "../third_party/flutter/desktop_multi_window" + source: path version: "0.1.0" device_info_plus: dependency: "direct main" @@ -379,11 +373,8 @@ packages: dynamic_layouts: dependency: "direct main" description: - path: "." - ref: "24cb88413fa5181d949ddacbb30a65d5c459e7d9" - resolved-ref: "24cb88413fa5181d949ddacbb30a65d5c459e7d9" - url: "https://github.com/rustdesk-org/dynamic_layouts.git" - source: git + path: "../third_party/flutter/dynamic_layouts" + source: path version: "0.0.1+1" equatable: dependency: transitive @@ -537,11 +528,8 @@ packages: flutter_gpu_texture_renderer: dependency: "direct main" description: - path: "." - ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87" - resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87" - url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer" - source: git + path: "../third_party/flutter/flutter_gpu_texture_renderer" + source: path version: "0.0.1" flutter_keyboard_visibility: dependency: "direct main" @@ -1297,11 +1285,8 @@ packages: texture_rgba_renderer: dependency: "direct main" description: - path: "." - ref: "42797e0f03141dc2b585f76c64a13974508058b4" - resolved-ref: "42797e0f03141dc2b585f76c64a13974508058b4" - url: "https://github.com/rustdesk-org/flutter_texture_rgba_renderer" - source: git + path: "../third_party/flutter/flutter_texture_rgba_renderer" + source: path version: "0.0.16" timing: dependency: transitive @@ -1338,11 +1323,8 @@ packages: uni_links: dependency: "direct main" description: - path: uni_links - ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f - resolved-ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f - url: "https://github.com/rustdesk-org/uni_links" - source: git + path: "../third_party/flutter/uni_links/uni_links" + source: path version: "0.5.1" uni_links_desktop: dependency: "direct main" @@ -1587,20 +1569,14 @@ packages: window_manager: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "85789bfe6e4cfaf4ecc00c52857467fdb7f26879" - url: "https://github.com/rustdesk-org/window_manager" - source: git + path: "../third_party/flutter/window_manager" + source: path version: "0.3.6" window_size: dependency: "direct main" description: - path: "plugins/window_size" - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - url: "https://github.com/google/flutter-desktop-embedding.git" - source: git + path: "../third_party/flutter/window_size/plugins/window_size" + source: path version: "0.1.0" xdg_directories: dependency: transitive diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 7bc77e735dd..3ef83307f59 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -39,8 +39,7 @@ dependencies: url_launcher_ios: ^6.3.2 toggle_switch: ^2.1.0 dash_chat_2: - git: - url: https://github.com/rustdesk-org/Dash-Chat-2 + path: ../third_party/flutter/dash_chat_2 draggable_float_widget: ^0.1.0 settings_ui: ^2.0.2 flutter_breadcrumb: ^1.0.1 @@ -52,18 +51,13 @@ dependencies: back_button_interceptor: ^6.0.1 flutter_rust_bridge: "1.80.1" window_manager: - git: - url: https://github.com/rustdesk-org/window_manager + path: ../third_party/flutter/window_manager desktop_multi_window: - git: - url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window + path: ../third_party/flutter/desktop_multi_window freezed_annotation: ^2.0.3 flutter_custom_cursor: ^0.0.4 window_size: - git: - url: https://github.com/google/flutter-desktop-embedding.git - path: plugins/window_size - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + path: ../third_party/flutter/window_size/plugins/window_size get: ^4.6.5 visibility_detector: ^0.4.0+2 contextmenu: ^3.0.0 @@ -73,10 +67,7 @@ dependencies: file_picker: ^5.1.0 flutter_svg: ^2.0.5 uni_links: - git: - url: https://github.com/rustdesk-org/uni_links - path: uni_links - ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f + path: ../third_party/flutter/uni_links/uni_links uni_links_desktop: ^0.1.6 # use 0.1.6 to make flutter 3.13 works path: ^1.8.1 auto_size_text: ^3.0.0 @@ -86,22 +77,16 @@ dependencies: flutter_launcher_icons: ^0.13.1 flutter_keyboard_visibility: ^5.4.0 texture_rgba_renderer: - git: - url: https://github.com/rustdesk-org/flutter_texture_rgba_renderer - ref: 42797e0f03141dc2b585f76c64a13974508058b4 + path: ../third_party/flutter/flutter_texture_rgba_renderer percent_indicator: ^4.2.2 dropdown_button2: ^2.0.0 flutter_gpu_texture_renderer: - git: - url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer - ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87 + path: ../third_party/flutter/flutter_gpu_texture_renderer uuid: ^3.0.7 auto_size_text_field: ^2.2.1 flex_color_picker: ^3.3.0 dynamic_layouts: - git: - url: https://github.com/rustdesk-org/dynamic_layouts.git - ref: 24cb88413fa5181d949ddacbb30a65d5c459e7d9 + path: ../third_party/flutter/dynamic_layouts pull_down_button: ^0.9.3 device_info_plus: ^9.1.0 qr_flutter: ^4.1.0 diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f3137..0eb46750c61 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -41,12 +41,12 @@ once_cell = {version = "1.18", optional = true} [target.'cfg(target_os = "linux")'.dependencies] percent-encoding = {version ="2.3", optional = true} -x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} +x11-clipboard = { path = "../../third_party/x11-clipboard-0.8.1", optional = true } x11rb = {version = "0.12", features = ["all-extensions"], optional = true} fuser = {version = "0.15", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] -cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} +cacao = { path = "../../third_party/cacao", optional = true } # Use `relax-void-encoding`, as that allows us to pass `c_void` instead of implementing `Encode` correctly for `&CGImageRef` objc2 = { version = "0.5.1", features = ["relax-void-encoding"] } objc2-foundation = { version = "0.2.0", features = ["NSArray", "NSString", "NSEnumerator", "NSGeometry", "NSProgress"] } diff --git a/libs/enigo/Cargo.toml b/libs/enigo/Cargo.toml index 6468eeedd7a..fb809367816 100644 --- a/libs/enigo/Cargo.toml +++ b/libs/enigo/Cargo.toml @@ -22,8 +22,8 @@ appveyor = { repository = "pythoneer/enigo-85xiy" } serde = { version = "1.0", optional = true } serde_derive = { version = "1.0", optional = true } log = "0.4" -rdev = { git = "https://github.com/rustdesk-org/rdev" } -tfc = { git = "https://github.com/rustdesk-org/The-Fat-Controller", branch = "history/rebase_upstream_20240722" } +rdev = { path = "../../third_party/rdev" } +tfc = { path = "../../third_party/tfc" } hbb_common = { path = "../hbb_common" } [features] diff --git a/libs/hbb_common b/libs/hbb_common deleted file mode 160000 index a920d00945e..00000000000 --- a/libs/hbb_common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 diff --git a/libs/hbb_common/.gitignore b/libs/hbb_common/.gitignore new file mode 100644 index 00000000000..693699042b1 --- /dev/null +++ b/libs/hbb_common/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +Cargo.lock diff --git a/libs/hbb_common/Cargo.toml b/libs/hbb_common/Cargo.toml new file mode 100644 index 00000000000..f58e6ca1680 --- /dev/null +++ b/libs/hbb_common/Cargo.toml @@ -0,0 +1,100 @@ +[package] +name = "hbb_common" +version = "0.1.0" +authors = ["open-trade "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = [] +webrtc = ["dep:webrtc"] + +[dependencies] +# new flexi_logger failed on rustc 1.75 +flexi_logger = { version = "0.27", features = ["async"] } +protobuf = { version = "3.7", features = ["with-bytes"] } +tokio = { version = "1.44", features = ["full"] } +tokio-util = { version = "0.7", features = ["full"] } +futures = "0.3" +bytes = { version = "1.10", features = ["serde"] } +log = "0.4" +env_logger = "0.11" +socket2 = { version = "0.3", features = ["reuseport"] } +zstd = "0.13" +anyhow = "1.0" +futures-util = "0.3" +directories-next = "2.0" +rand = "0.8" +serde_derive = "1.0" +serde = "1.0" +serde_json = "1.0" +lazy_static = "1.5" +confy = { path = "../../third_party/confy" } +dirs-next = "2.0" +filetime = "0.2" +sodiumoxide = "0.2" +regex = "1.11" +tokio-socks = { path = "../../third_party/tokio-socks" } +chrono = "0.4" +backtrace = "0.3" +libc = "0.2" +dlopen = "0.1" +toml = "0.7" +uuid = { version = "1.16", features = ["v4"] } +# new sysinfo issue: https://github.com/rustdesk/rustdesk/pull/6330#issuecomment-2270871442 +sysinfo = { path = "../../third_party/sysinfo" } +# new flexi_logger failed on nightly rustc 1.75 for x86 +thiserror = "1.0" +httparse = "1.10" +base64 = "0.22" +url = "2.5" +sha2 = "0.10" +whoami = "1.5" + +tokio-rustls = { version = "0.26", features = [ + "logging", + "tls12", + "ring", +], default-features = false } +tokio-native-tls = "0.3" +tokio-tungstenite = { version = "0.26", features = ["native-tls", "rustls-tls-native-roots", "rustls-tls-webpki-roots"] } +tungstenite = { version = "0.26", features = ["native-tls", "rustls-tls-native-roots", "rustls-tls-webpki-roots"] } +rustls-platform-verifier = "0.6" +rustls-pki-types = "1.11" +rustls-native-certs = "0.8" +webpki-roots = "1.0.4" +async-recursion = "1.1" +webrtc = { version = "0.14.0", optional = true } +libloading = "0.8" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +mac_address = "1.1" +default_net = { path = "../../third_party/default_net" } +machine-uid = { path = "../../third_party/machine-uid" } + +[build-dependencies] +protobuf-codegen = { version = "3.7" } + +[dev-dependencies] +clap = "4.5.51" +webrtc = "0.14.0" + +[target.'cfg(target_os = "windows")'.dependencies] +winapi = { version = "0.3", features = [ + "winuser", + "synchapi", + "pdh", + "memoryapi", + "sysinfoapi", +] } + +[target.'cfg(target_os = "macos")'.dependencies] +osascript = "0.3" + +[target.'cfg(target_os = "linux")'.dependencies] +sctk = { package = "smithay-client-toolkit", version = "0.20.0", default-features = false, features = [ + "calloop", +] } +users = { version = "0.11" } +x11 = "2.21" diff --git a/libs/hbb_common/build.rs b/libs/hbb_common/build.rs new file mode 100644 index 00000000000..5ebc3a28706 --- /dev/null +++ b/libs/hbb_common/build.rs @@ -0,0 +1,14 @@ +fn main() { + let out_dir = format!("{}/protos", std::env::var("OUT_DIR").unwrap()); + + std::fs::create_dir_all(&out_dir).unwrap(); + + protobuf_codegen::Codegen::new() + .pure() + .out_dir(out_dir) + .inputs(["protos/rendezvous.proto", "protos/message.proto"]) + .include("protos") + .customize(protobuf_codegen::Customize::default().tokio_bytes(true)) + .run() + .expect("Codegen failed."); +} diff --git a/libs/hbb_common/examples/config.rs b/libs/hbb_common/examples/config.rs new file mode 100644 index 00000000000..95169df8e2c --- /dev/null +++ b/libs/hbb_common/examples/config.rs @@ -0,0 +1,5 @@ +extern crate hbb_common; + +fn main() { + println!("{:?}", hbb_common::config::PeerConfig::load("455058072")); +} diff --git a/libs/hbb_common/examples/system_message.rs b/libs/hbb_common/examples/system_message.rs new file mode 100644 index 00000000000..0be78842868 --- /dev/null +++ b/libs/hbb_common/examples/system_message.rs @@ -0,0 +1,20 @@ +extern crate hbb_common; +#[cfg(target_os = "linux")] +use hbb_common::platform::linux; +#[cfg(target_os = "macos")] +use hbb_common::platform::macos; + +fn main() { + #[cfg(target_os = "linux")] + let res = linux::system_message("test title", "test message", true); + #[cfg(target_os = "macos")] + let res = macos::alert( + "System Preferences".to_owned(), + "warning".to_owned(), + "test title".to_owned(), + "test message".to_owned(), + ["Ok".to_owned()].to_vec(), + ); + #[cfg(any(target_os = "linux", target_os = "macos"))] + println!("result {:?}", &res); +} diff --git a/libs/hbb_common/examples/webrtc.rs b/libs/hbb_common/examples/webrtc.rs new file mode 100644 index 00000000000..2c993caa16c --- /dev/null +++ b/libs/hbb_common/examples/webrtc.rs @@ -0,0 +1,154 @@ +extern crate hbb_common; + +#[cfg(feature = "webrtc")] +use hbb_common::webrtc::WebRTCStream; + +use std::io::Write; +use anyhow::Result; +use bytes::Bytes; +use clap::{Arg, Command}; +use tokio::time::Duration; + +#[cfg(not(feature = "webrtc"))] +#[tokio::main] +async fn main() -> Result<()> { + println!( + "The webrtc feature is not enabled. \ + Please enable the webrtc feature to run this example." + ); + Ok(()) +} + +#[cfg(feature = "webrtc")] +#[tokio::main] +async fn main() -> Result<()> { + let app = Command::new("webrtc-stream") + .about("An example of webrtc stream using hbb_common and webrtc-rs") + .arg( + Arg::new("debug") + .long("debug") + .short('d') + .action(clap::ArgAction::SetTrue) + .help("Prints debug log information"), + ) + .arg( + Arg::new("offer") + .long("offer") + .short('o') + .help("set offer from other endpoint"), + ); + + let matches = app.clone().get_matches(); + + let debug = matches.contains_id("debug"); + if debug { + println!("Debug log enabled"); + env_logger::Builder::new() + .format(|buf, record| { + writeln!( + buf, + "{}:{} [{}] {} - {}", + record.file().unwrap_or("unknown"), + record.line().unwrap_or(0), + record.level(), + chrono::Local::now().format("%H:%M:%S.%6f"), + record.args() + ) + }) + .filter(Some("hbb_common"), log::LevelFilter::Debug) + .init(); + } + + let remote_endpoint = if let Some(endpoint) = matches.get_one::("offer") { + endpoint.to_string() + } else { + "".to_string() + }; + + let webrtc_stream = WebRTCStream::new(&remote_endpoint, false, 30000).await?; + // Print the offer to be sent to the other peer + let local_endpoint = webrtc_stream.get_local_endpoint().await?; + + if remote_endpoint.is_empty() { + println!(); + // Wait for the answer to be pasted + println!( + "Start new terminal run: \n{} \ncopy remote endpoint and paste here", + format!( + "cargo r --features webrtc --example webrtc -- --offer {}", + local_endpoint + ) + ); + // readline blocking + let line = std::io::stdin() + .lines() + .next() + .ok_or_else(|| anyhow::anyhow!("No input received"))??; + webrtc_stream.set_remote_endpoint(&line).await?; + } else { + println!( + "Copy local endpoint and paste to the other peer: \n{}", + local_endpoint + ); + } + + let s1 = webrtc_stream.clone(); + tokio::spawn(async move { + let _ = read_loop(s1).await; + }); + + let s2 = webrtc_stream.clone(); + tokio::spawn(async move { + let _ = write_loop(s2).await; + }); + + println!("Press ctrl-c to stop"); + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!(); + } + }; + + Ok(()) +} + +// read_loop shows how to read from the datachannel directly +#[cfg(feature = "webrtc")] +async fn read_loop(mut stream: WebRTCStream) -> Result<()> { + loop { + let Some(res) = stream.next().await else { + println!("WebRTC stream closed; Exit the read_loop"); + return Ok(()); + }; + match res { + Err(e) => { + println!("WebRTC stream read error: {}; Exit the read_loop", e); + return Ok(()); + } + Ok(data) => { + println!("Message from stream: {}", String::from_utf8(data.to_vec())?); + } + } + } +} + +// write_loop shows how to write to the webrtc stream directly +#[cfg(feature = "webrtc")] +async fn write_loop(mut stream: WebRTCStream) -> Result<()> { + let mut result = Result::<()>::Ok(()); + while result.is_ok() { + let timeout = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(timeout); + + tokio::select! { + _ = timeout.as_mut() =>{ + let message = webrtc::peer_connection::math_rand_alpha(15); + result = stream.send_bytes(Bytes::from(message.clone())).await; + println!("Sent '{message}' {}", result.is_ok()); + } + }; + } + println!("WebRTC stream write failed; Exit the write_loop"); + + Ok(()) +} diff --git a/libs/hbb_common/protos/message.proto b/libs/hbb_common/protos/message.proto new file mode 100644 index 00000000000..8b213681149 --- /dev/null +++ b/libs/hbb_common/protos/message.proto @@ -0,0 +1,985 @@ +syntax = "proto3"; +package hbb; + +message EncodedVideoFrame { + bytes data = 1; + bool key = 2; + int64 pts = 3; +} + +message EncodedVideoFrames { repeated EncodedVideoFrame frames = 1; } + +message RGB { bool compress = 1; } + +// planes data send directly in binary for better use arraybuffer on web +message YUV { + bool compress = 1; + int32 stride = 2; +} + +enum Chroma { + I420 = 0; + I444 = 1; +} + +message VideoFrame { + oneof union { + EncodedVideoFrames vp9s = 6; + RGB rgb = 7; + YUV yuv = 8; + EncodedVideoFrames h264s = 10; + EncodedVideoFrames h265s = 11; + EncodedVideoFrames vp8s = 12; + EncodedVideoFrames av1s = 13; + } + int32 display = 14; +} + +message IdPk { + string id = 1; + bytes pk = 2; +} + +message DisplayInfo { + sint32 x = 1; + sint32 y = 2; + int32 width = 3; + int32 height = 4; + string name = 5; + bool online = 6; + bool cursor_embedded = 7; + Resolution original_resolution = 8; + double scale = 9; +} + +message PortForward { + string host = 1; + int32 port = 2; +} + +message FileTransfer { + string dir = 1; + bool show_hidden = 2; +} + +message ViewCamera {} + +message OSLogin { + string username = 1; + string password = 2; +} + +message LoginRequest { + string username = 1; + bytes password = 2; + string my_id = 4; + string my_name = 5; + OptionMessage option = 6; + oneof union { + FileTransfer file_transfer = 7; + PortForward port_forward = 8; + ViewCamera view_camera = 15; + Terminal terminal = 16; + } + bool video_ack_required = 9; + uint64 session_id = 10; + string version = 11; + OSLogin os_login = 12; + string my_platform = 13; + bytes hwid = 14; + string avatar = 17; +} + +message Terminal { + string service_id = 1; // Service ID for reconnecting to existing session +} + +message Auth2FA { + string code = 1; + bytes hwid = 2; +} + +message ChatMessage { string text = 1; } + +message Features { + bool privacy_mode = 1; + bool terminal = 2; +} + +message CodecAbility { + bool vp8 = 1; + bool vp9 = 2; + bool av1 = 3; + bool h264 = 4; + bool h265 = 5; +} + +message SupportedEncoding { + bool h264 = 1; + bool h265 = 2; + bool vp8 = 3; + bool av1 = 4; + CodecAbility i444 = 5; +} + +message PeerInfo { + string username = 1; + string hostname = 2; + string platform = 3; + repeated DisplayInfo displays = 4; + int32 current_display = 5; + bool sas_enabled = 6; + string version = 7; + Features features = 9; + SupportedEncoding encoding = 10; + SupportedResolutions resolutions = 11; + // Use JSON's key-value format which is friendly for peer to handle. + // NOTE: Only support one-level dictionaries (for peer to update), and the key is of type string. + string platform_additions = 12; + WindowsSessions windows_sessions = 13; +} + +message WindowsSession { + uint32 sid = 1; + string name = 2; +} + +message LoginResponse { + oneof union { + string error = 1; + PeerInfo peer_info = 2; + } + bool enable_trusted_devices = 3; +} + +message TouchScaleUpdate { + // The delta scale factor relative to the previous scale. + // delta * 1000 + // 0 means scale end + int32 scale = 1; +} + +message TouchPanStart { + int32 x = 1; + int32 y = 2; +} + +message TouchPanUpdate { + // The delta x position relative to the previous position. + int32 x = 1; + // The delta y position relative to the previous position. + int32 y = 2; +} + +message TouchPanEnd { + int32 x = 1; + int32 y = 2; +} + +message TouchEvent { + oneof union { + TouchScaleUpdate scale_update = 1; + TouchPanStart pan_start = 2; + TouchPanUpdate pan_update = 3; + TouchPanEnd pan_end = 4; + } +} + +message PointerDeviceEvent { + oneof union { + TouchEvent touch_event = 1; + } + repeated ControlKey modifiers = 2; +} + +message MouseEvent { + int32 mask = 1; + sint32 x = 2; + sint32 y = 3; + repeated ControlKey modifiers = 4; +} + +enum KeyboardMode{ + Legacy = 0; + Map = 1; + Translate = 2; + Auto = 3; +} + +enum ControlKey { + Unknown = 0; + Alt = 1; + Backspace = 2; + CapsLock = 3; + Control = 4; + Delete = 5; + DownArrow = 6; + End = 7; + Escape = 8; + F1 = 9; + F10 = 10; + F11 = 11; + F12 = 12; + F2 = 13; + F3 = 14; + F4 = 15; + F5 = 16; + F6 = 17; + F7 = 18; + F8 = 19; + F9 = 20; + Home = 21; + LeftArrow = 22; + /// meta key (also known as "windows"; "super"; and "command") + Meta = 23; + /// option key on macOS (alt key on Linux and Windows) + Option = 24; // deprecated, use Alt instead + PageDown = 25; + PageUp = 26; + Return = 27; + RightArrow = 28; + Shift = 29; + Space = 30; + Tab = 31; + UpArrow = 32; + Numpad0 = 33; + Numpad1 = 34; + Numpad2 = 35; + Numpad3 = 36; + Numpad4 = 37; + Numpad5 = 38; + Numpad6 = 39; + Numpad7 = 40; + Numpad8 = 41; + Numpad9 = 42; + Cancel = 43; + Clear = 44; + Menu = 45; // deprecated, use Alt instead + Pause = 46; + Kana = 47; + Hangul = 48; + Junja = 49; + Final = 50; + Hanja = 51; + Kanji = 52; + Convert = 53; + Select = 54; + Print = 55; + Execute = 56; + Snapshot = 57; + Insert = 58; + Help = 59; + Sleep = 60; + Separator = 61; + Scroll = 62; + NumLock = 63; + RWin = 64; + Apps = 65; + Multiply = 66; + Add = 67; + Subtract = 68; + Decimal = 69; + Divide = 70; + Equals = 71; + NumpadEnter = 72; + RShift = 73; + RControl = 74; + RAlt = 75; + VolumeMute = 76; // mainly used on mobile devices as controlled side + VolumeUp = 77; + VolumeDown = 78; + Power = 79; // mainly used on mobile devices as controlled side + CtrlAltDel = 100; + LockScreen = 101; +} + +message KeyEvent { + // `down` indicates the key's state(down or up). + bool down = 1; + // `press` indicates a click event(down and up). + bool press = 2; + oneof union { + ControlKey control_key = 3; + // position key code. win: scancode, linux: key code, macos: key code + uint32 chr = 4; + uint32 unicode = 5; + string seq = 6; + // high word. virtual keycode + // low word. unicode + uint32 win2win_hotkey = 7; + } + repeated ControlKey modifiers = 8; + KeyboardMode mode = 9; +} + +message CursorData { + uint64 id = 1; + sint32 hotx = 2; + sint32 hoty = 3; + int32 width = 4; + int32 height = 5; + bytes colors = 6; +} + +message CursorPosition { + sint32 x = 1; + sint32 y = 2; +} + +message Hash { + string salt = 1; + string challenge = 2; +} + +enum ClipboardFormat { + Text = 0; + Rtf = 1; + Html = 2; + ImageRgba = 21; + ImagePng = 22; + ImageSvg = 23; + Special = 31; +} + +message Clipboard { + bool compress = 1; + bytes content = 2; + int32 width = 3; + int32 height = 4; + ClipboardFormat format = 5; + // Special format name, only used when format is Special. + string special_name = 6; +} + +message MultiClipboards { repeated Clipboard clipboards = 1; } + +enum FileType { + Dir = 0; + DirLink = 2; + DirDrive = 3; + File = 4; + FileLink = 5; +} + +message FileEntry { + FileType entry_type = 1; + string name = 2; + bool is_hidden = 3; + uint64 size = 4; + uint64 modified_time = 5; +} + +message FileDirectory { + int32 id = 1; + string path = 2; + repeated FileEntry entries = 3; +} + +message ReadDir { + string path = 1; + bool include_hidden = 2; +} + +message ReadEmptyDirs { + string path = 1; + bool include_hidden = 2; +} + +message ReadEmptyDirsResponse { + string path = 1; + repeated FileDirectory empty_dirs = 2; +} + +message ReadAllFiles { + int32 id = 1; + string path = 2; + bool include_hidden = 3; +} + +message FileRename { + int32 id = 1; + string path = 2; + string new_name = 3; +} + +message FileAction { + oneof union { + ReadDir read_dir = 1; + FileTransferSendRequest send = 2; + FileTransferReceiveRequest receive = 3; + FileDirCreate create = 4; + FileRemoveDir remove_dir = 5; + FileRemoveFile remove_file = 6; + ReadAllFiles all_files = 7; + FileTransferCancel cancel = 8; + FileTransferSendConfirmRequest send_confirm = 9; + FileRename rename = 10; + ReadEmptyDirs read_empty_dirs = 11; + } +} + +message FileTransferCancel { int32 id = 1; } + +message FileResponse { + oneof union { + FileDirectory dir = 1; + FileTransferBlock block = 2; + FileTransferError error = 3; + FileTransferDone done = 4; + FileTransferDigest digest = 5; + ReadEmptyDirsResponse empty_dirs = 6; + } +} + +message FileTransferDigest { + int32 id = 1; + sint32 file_num = 2; + uint64 last_modified = 3; + uint64 file_size = 4; + bool is_upload = 5; + bool is_identical = 6; + uint64 transferred_size = 7; // For resume. Indicates the size of the file already transferred + bool is_resume = 8; // For resume. Indicates if the transfer is a resume. + // `is_resume` can let the controlled side know whether to check the `.digest` file. + // When `is_resume` is false, `.digest` exists, the same file does not exist, + // the controlled side should not check `.digest`, it should confirm with a new transfer request. +} + +message FileTransferBlock { + int32 id = 1; + sint32 file_num = 2; + bytes data = 3; + bool compressed = 4; + uint32 blk_id = 5; +} + +message FileTransferError { + int32 id = 1; + string error = 2; + sint32 file_num = 3; +} + +message FileTransferSendRequest { + int32 id = 1; + string path = 2; + bool include_hidden = 3; + int32 file_num = 4; + + enum FileType { + Generic = 0; + Printer = 1; + } + FileType file_type = 5; +} + +message FileTransferSendConfirmRequest { + int32 id = 1; + sint32 file_num = 2; + oneof union { + bool skip = 3; + uint32 offset_blk = 4; + } +} + +message FileTransferDone { + int32 id = 1; + sint32 file_num = 2; +} + +message FileTransferReceiveRequest { + int32 id = 1; + string path = 2; // path written to + repeated FileEntry files = 3; + int32 file_num = 4; + uint64 total_size = 5; +} + +message FileRemoveDir { + int32 id = 1; + string path = 2; + bool recursive = 3; +} + +message FileRemoveFile { + int32 id = 1; + string path = 2; + sint32 file_num = 3; +} + +message FileDirCreate { + int32 id = 1; + string path = 2; +} + +// main logic from freeRDP +message CliprdrMonitorReady { +} + +message CliprdrFormat { + int32 id = 2; + string format = 3; +} + +message CliprdrServerFormatList { + repeated CliprdrFormat formats = 2; +} + +message CliprdrServerFormatListResponse { + int32 msg_flags = 2; +} + +message CliprdrServerFormatDataRequest { + int32 requested_format_id = 2; +} + +message CliprdrServerFormatDataResponse { + int32 msg_flags = 2; + bytes format_data = 3; +} + +message CliprdrFileContentsRequest { + int32 stream_id = 2; + int32 list_index = 3; + int32 dw_flags = 4; + int32 n_position_low = 5; + int32 n_position_high = 6; + int32 cb_requested = 7; + bool have_clip_data_id = 8; + int32 clip_data_id = 9; +} + +message CliprdrFileContentsResponse { + int32 msg_flags = 3; + int32 stream_id = 4; + bytes requested_data = 5; +} + +// Try empty clipboard in the following case(Windows only): +// 1. `A`(Windows) -> `B`, `C` +// 2. Copy in `A, file clipboards on `B` and `C` are updated. +// 3. Copy in `B`. +// `A` should tell `C` to empty the file clipboard. +message CliprdrTryEmpty { +} + +// Clipobard file message for audit. +message CliprdrFile { + string name = 1; + uint64 size = 2; +} + +message CliprdrFiles { + repeated CliprdrFile files = 1; +} + +message Cliprdr { + oneof union { + CliprdrMonitorReady ready = 1; + CliprdrServerFormatList format_list = 2; + CliprdrServerFormatListResponse format_list_response = 3; + CliprdrServerFormatDataRequest format_data_request = 4; + CliprdrServerFormatDataResponse format_data_response = 5; + CliprdrFileContentsRequest file_contents_request = 6; + CliprdrFileContentsResponse file_contents_response = 7; + CliprdrTryEmpty try_empty = 8; + CliprdrFiles files = 9; + } +} + +message Resolution { + int32 width = 1; + int32 height = 2; +} + +message DisplayResolution { + int32 display = 1; + Resolution resolution = 2; +} + +message SupportedResolutions { repeated Resolution resolutions = 1; } + +message SwitchDisplay { + int32 display = 1; + sint32 x = 2; + sint32 y = 3; + int32 width = 4; + int32 height = 5; + bool cursor_embedded = 6; + SupportedResolutions resolutions = 7; + // Do not care about the origin point for now. + Resolution original_resolution = 8; +} + +message CaptureDisplays { + repeated int32 add = 1; + repeated int32 sub = 2; + repeated int32 set = 3; +} + +message ToggleVirtualDisplay { + int32 display = 1; + bool on = 2; +} + +message TogglePrivacyMode { + string impl_key = 1; + bool on = 2; +} + +message PermissionInfo { + enum Permission { + Keyboard = 0; + Clipboard = 2; + Audio = 3; + File = 4; + Restart = 5; + Recording = 6; + BlockInput = 7; + PrivacyMode = 8; + } + + Permission permission = 1; + bool enabled = 2; +} + +enum ImageQuality { + NotSet = 0; + Low = 2; + Balanced = 3; + Best = 4; +} + +message SupportedDecoding { + enum PreferCodec { + Auto = 0; + VP9 = 1; + H264 = 2; + H265 = 3; + VP8 = 4; + AV1 = 5; + } + + int32 ability_vp9 = 1; + int32 ability_h264 = 2; + int32 ability_h265 = 3; + PreferCodec prefer = 4; + int32 ability_vp8 = 5; + int32 ability_av1 = 6; + CodecAbility i444 = 7; + Chroma prefer_chroma = 8; +} + +message OptionMessage { + enum BoolOption { + NotSet = 0; + No = 1; + Yes = 2; + } + ImageQuality image_quality = 1; + BoolOption lock_after_session_end = 2; + BoolOption show_remote_cursor = 3; + BoolOption privacy_mode = 4; + BoolOption block_input = 5; + int32 custom_image_quality = 6; + BoolOption disable_audio = 7; + BoolOption disable_clipboard = 8; + BoolOption enable_file_transfer = 9; + SupportedDecoding supported_decoding = 10; + int32 custom_fps = 11; + BoolOption disable_keyboard = 12; +// Position 13 is used for Resolution. Remove later. +// Resolution custom_resolution = 13; +// BoolOption support_windows_specific_session = 14; + // starting from 15 please, do not use removed fields + BoolOption follow_remote_cursor = 15; + BoolOption follow_remote_window = 16; + BoolOption disable_camera = 17; + BoolOption terminal_persistent = 18; + BoolOption show_my_cursor = 19; +} + +message TestDelay { + int64 time = 1; + bool from_client = 2; + uint32 last_delay = 3; + uint32 target_bitrate = 4; +} + +message PublicKey { + bytes asymmetric_value = 1; + bytes symmetric_value = 2; +} + +message SignedId { bytes id = 1; } + +message AudioFormat { + uint32 sample_rate = 1; + uint32 channels = 2; +} + +message AudioFrame { + bytes data = 1; +} + +// Notify peer to show message box. +message MessageBox { + // Message type. Refer to flutter/lib/common.dart/msgBox(). + string msgtype = 1; + string title = 2; + // English + string text = 3; + // If not empty, msgbox provides a button to following the link. + // The link here can't be directly http url. + // It must be the key of http url configed in peer side or "rustdesk://*" (jump in app). + string link = 4; +} + +message BackNotification { + // no need to consider block input by someone else + enum BlockInputState { + BlkStateUnknown = 0; + BlkOnSucceeded = 2; + BlkOnFailed = 3; + BlkOffSucceeded = 4; + BlkOffFailed = 5; + } + enum PrivacyModeState { + PrvStateUnknown = 0; + // Privacy mode on by someone else + PrvOnByOther = 2; + // Privacy mode is not supported on the remote side + PrvNotSupported = 3; + // Privacy mode on by self + PrvOnSucceeded = 4; + // Privacy mode on by self, but denied + PrvOnFailedDenied = 5; + // Some plugins are not found + PrvOnFailedPlugin = 6; + // Privacy mode on by self, but failed + PrvOnFailed = 7; + // Privacy mode off by self + PrvOffSucceeded = 8; + // Ctrl + P + PrvOffByPeer = 9; + // Privacy mode off by self, but failed + PrvOffFailed = 10; + PrvOffUnknown = 11; + } + + oneof union { + PrivacyModeState privacy_mode_state = 1; + BlockInputState block_input_state = 2; + } + // Supplementary message, for "PrvOnFailed" and "PrvOffFailed" + string details = 3; + // The key of the implementation + string impl_key = 4; +} + +message ElevationRequestWithLogon { + string username = 1; + string password = 2; +} + +message ElevationRequest { + oneof union { + bool direct = 1; + ElevationRequestWithLogon logon = 2; + } +} + +message SwitchSidesRequest { + bytes uuid = 1; +} + +message SwitchSidesResponse { + bytes uuid = 1; + LoginRequest lr = 2; +} + +message SwitchBack {} + +message PluginRequest { + string id = 1; + bytes content = 2; +} + +message PluginFailure { + string id = 1; + string name = 2; + string msg = 3; +} + +message WindowsSessions { + repeated WindowsSession sessions = 1; + uint32 current_sid = 2; +} + +// Query messages from peer. +message MessageQuery { + // The SwitchDisplay message of the target display. + // If the target display is not found, the message will be ignored. + int32 switch_display = 1; +} + +message Misc { + oneof union { + ChatMessage chat_message = 4; + SwitchDisplay switch_display = 5; + PermissionInfo permission_info = 6; + OptionMessage option = 7; + AudioFormat audio_format = 8; + string close_reason = 9; + bool refresh_video = 10; + bool video_received = 12; + BackNotification back_notification = 13; + bool restart_remote_device = 14; + bool uac = 15; + bool foreground_window_elevated = 16; + bool stop_service = 17; + ElevationRequest elevation_request = 18; + string elevation_response = 19; + bool portable_service_running = 20; + SwitchSidesRequest switch_sides_request = 21; + SwitchBack switch_back = 22; + // Deprecated since 1.2.4, use `change_display_resolution` (36) instead. + // But we must keep it for compatibility when peer version < 1.2.4. + Resolution change_resolution = 24; + PluginRequest plugin_request = 25; + PluginFailure plugin_failure = 26; + uint32 full_speed_fps = 27; // deprecated + uint32 auto_adjust_fps = 28; + bool client_record_status = 29; + CaptureDisplays capture_displays = 30; + int32 refresh_video_display = 31; + ToggleVirtualDisplay toggle_virtual_display = 32; + TogglePrivacyMode toggle_privacy_mode = 33; + SupportedEncoding supported_encoding = 34; + uint32 selected_sid = 35; + DisplayResolution change_display_resolution = 36; + MessageQuery message_query = 37; + int32 follow_current_display = 38; + } +} + +message VoiceCallRequest { + int64 req_timestamp = 1; + // Indicates whether the request is a connect action or a disconnect action. + bool is_connect = 2; +} + +message VoiceCallResponse { + bool accepted = 1; + int64 req_timestamp = 2; // Should copy from [VoiceCallRequest::req_timestamp]. + int64 ack_timestamp = 3; +} + +message ScreenshotRequest { + int32 display = 1; + // sid is the session id on the controlling side + // It is used to forward the message to the correct remote (session) window. + string sid = 2; +} + +message ScreenshotResponse { + string sid = 1; + // empty if success + string msg = 2; + bytes data = 3; +} + +// Terminal messages - standalone feature like FileAction +message OpenTerminal { + int32 terminal_id = 1; // 0 for default terminal + uint32 rows = 2; + uint32 cols = 3; +} + +message ResizeTerminal { + int32 terminal_id = 1; + uint32 rows = 2; + uint32 cols = 3; +} + +message TerminalData { + int32 terminal_id = 1; + bytes data = 2; + bool compressed = 3; +} + +message CloseTerminal { + int32 terminal_id = 1; +} + +message TerminalAction { + oneof union { + OpenTerminal open = 1; + TerminalData data = 2; + ResizeTerminal resize = 3; + CloseTerminal close = 4; + } +} + +message TerminalOpened { + int32 terminal_id = 1; + bool success = 2; + string message = 3; + uint32 pid = 4; + string service_id = 5; // Service ID for persistent sessions + repeated int32 persistent_sessions = 6; // Used to restore the persistent sessions. + bool replay_terminal_output = 7; // Whether the next data response replays buffered terminal output. +} + +message TerminalClosed { + int32 terminal_id = 1; + int32 exit_code = 2; +} + +message TerminalError { + int32 terminal_id = 1; + string message = 2; +} + +message TerminalResponse { + oneof union { + TerminalOpened opened = 1; + TerminalData data = 2; + TerminalClosed closed = 3; + TerminalError error = 4; + } +} + +message Message { + oneof union { + SignedId signed_id = 3; + PublicKey public_key = 4; + TestDelay test_delay = 5; + VideoFrame video_frame = 6; + LoginRequest login_request = 7; + LoginResponse login_response = 8; + Hash hash = 9; + MouseEvent mouse_event = 10; + AudioFrame audio_frame = 11; + CursorData cursor_data = 12; + CursorPosition cursor_position = 13; + uint64 cursor_id = 14; + KeyEvent key_event = 15; + Clipboard clipboard = 16; + FileAction file_action = 17; + FileResponse file_response = 18; + Misc misc = 19; + Cliprdr cliprdr = 20; + MessageBox message_box = 21; + SwitchSidesResponse switch_sides_response = 22; + VoiceCallRequest voice_call_request = 23; + VoiceCallResponse voice_call_response = 24; + PeerInfo peer_info = 25; + PointerDeviceEvent pointer_device_event = 26; + Auth2FA auth_2fa = 27; + MultiClipboards multi_clipboards = 28; + ScreenshotRequest screenshot_request = 29; + ScreenshotResponse screenshot_response= 30; + TerminalAction terminal_action = 31; + TerminalResponse terminal_response = 32; + } +} diff --git a/libs/hbb_common/protos/rendezvous.proto b/libs/hbb_common/protos/rendezvous.proto new file mode 100644 index 00000000000..a2b1d6527b3 --- /dev/null +++ b/libs/hbb_common/protos/rendezvous.proto @@ -0,0 +1,267 @@ +syntax = "proto3"; +package hbb; + +message RegisterPeer { + string id = 1; + int32 serial = 2; +} + +enum ConnType { + DEFAULT_CONN = 0; + FILE_TRANSFER = 1; + PORT_FORWARD = 2; + RDP = 3; + VIEW_CAMERA = 4; + TERMINAL = 5; +} + +message RegisterPeerResponse { bool request_pk = 2; } + +message PunchHoleRequest { + string id = 1; + NatType nat_type = 2; + string licence_key = 3; + ConnType conn_type = 4; + string token = 5; + string version = 6; + int32 udp_port = 7; + bool force_relay = 8; + int32 upnp_port = 9; + bytes socket_addr_v6 = 10; +} + +message ControlPermissions { + enum Permission { + keyboard = 0; + remote_printer = 1; + clipboard = 2; + file = 3; + audio = 4; + camera = 5; + terminal = 6; + tunnel = 7; + restart = 8; + recording = 9; + block_input = 10; + remote_modify = 11; + privacy_mode = 12; + } + uint64 permissions = 1; +} + +message ControlledContext { + string conn_audit_ref = 1; +} + +message PunchHole { + bytes socket_addr = 1; + string relay_server = 2; + NatType nat_type = 3; + int32 udp_port = 4; + bool force_relay = 5; + int32 upnp_port = 6; + bytes socket_addr_v6 = 7; + ControlPermissions control_permissions = 8; + ControlledContext controlled_context = 9; +} + +message TestNatRequest { + int32 serial = 1; +} + +// per my test, uint/int has no difference in encoding, int not good for negative, use sint for negative +message TestNatResponse { + int32 port = 1; + ConfigUpdate cu = 2; // for mobile +} + +enum NatType { + UNKNOWN_NAT = 0; + ASYMMETRIC = 1; + SYMMETRIC = 2; +} + +message PunchHoleSent { + bytes socket_addr = 1; + string id = 2; + string relay_server = 3; + NatType nat_type = 4; + string version = 5; + int32 upnp_port = 6; + bytes socket_addr_v6 = 7; +} + +message RegisterPk { + string id = 1; + bytes uuid = 2; + bytes pk = 3; + string old_id = 4; + bool no_register_device = 5; +} + +message RegisterPkResponse { + enum Result { + OK = 0; + UUID_MISMATCH = 2; + ID_EXISTS = 3; + TOO_FREQUENT = 4; + INVALID_ID_FORMAT = 5; + NOT_SUPPORT = 6; + SERVER_ERROR = 7; + NOT_DEPLOYED = 8; + } + Result result = 1; + int32 keep_alive = 2; +} + +message PunchHoleResponse { + bytes socket_addr = 1; + bytes pk = 2; + enum Failure { + ID_NOT_EXIST = 0; + OFFLINE = 2; + LICENSE_MISMATCH = 3; + LICENSE_OVERUSE = 4; + } + Failure failure = 3; + string relay_server = 4; + oneof union { + NatType nat_type = 5; + bool is_local = 6; + } + string other_failure = 7; + int32 feedback = 8; + bool is_udp = 9; + int32 upnp_port = 10; + bytes socket_addr_v6 = 11; +} + +message ConfigUpdate { + int32 serial = 1; + repeated string rendezvous_servers = 2; +} + +message RequestRelay { + string id = 1; + string uuid = 2; + bytes socket_addr = 3; + string relay_server = 4; + bool secure = 5; + string licence_key = 6; + ConnType conn_type = 7; + string token = 8; + ControlPermissions control_permissions = 9; + ControlledContext controlled_context = 10; +} + +message RelayResponse { + bytes socket_addr = 1; + string uuid = 2; + string relay_server = 3; + oneof union { + string id = 4; + bytes pk = 5; + } + string refuse_reason = 6; + string version = 7; + int32 feedback = 9; + bytes socket_addr_v6 = 10; + int32 upnp_port = 11; +} + +message SoftwareUpdate { string url = 1; } + +// if in same intranet, punch hole won't work both for udp and tcp, +// even some router has below connection error if we connect itself, +// { kind: Other, error: "could not resolve to any address" }, +// so we request local address to connect. +message FetchLocalAddr { + bytes socket_addr = 1; + string relay_server = 2; + bytes socket_addr_v6 = 3; + ControlPermissions control_permissions = 4; + ControlledContext controlled_context = 5; +} + +message LocalAddr { + bytes socket_addr = 1; + bytes local_addr = 2; + string relay_server = 3; + string id = 4; + string version = 5; + bytes socket_addr_v6 = 6; +} + +message PeerDiscovery { + string cmd = 1; + string mac = 2; + string id = 3; + string username = 4; + string hostname = 5; + string platform = 6; + string misc = 7; +} + +message OnlineRequest { + string id = 1; + repeated string peers = 2; +} + +message OnlineResponse { + bytes states = 1; +} + +message KeyExchange { + repeated bytes keys = 1; +} + +message HealthCheck { + string token = 1; +} + +message HeaderEntry { + string name = 1; + string value = 2; +} + +message HttpProxyRequest { + string method = 1; + string path = 2; + repeated HeaderEntry headers = 3; + bytes body = 4; +} + +message HttpProxyResponse { + int32 status = 1; + repeated HeaderEntry headers = 2; + bytes body = 3; + string error = 4; +} + +message RendezvousMessage { + oneof union { + RegisterPeer register_peer = 6; + RegisterPeerResponse register_peer_response = 7; + PunchHoleRequest punch_hole_request = 8; + PunchHole punch_hole = 9; + PunchHoleSent punch_hole_sent = 10; + PunchHoleResponse punch_hole_response = 11; + FetchLocalAddr fetch_local_addr = 12; + LocalAddr local_addr = 13; + ConfigUpdate configure_update = 14; + RegisterPk register_pk = 15; + RegisterPkResponse register_pk_response = 16; + SoftwareUpdate software_update = 17; + RequestRelay request_relay = 18; + RelayResponse relay_response = 19; + TestNatRequest test_nat_request = 20; + TestNatResponse test_nat_response = 21; + PeerDiscovery peer_discovery = 22; + OnlineRequest online_request = 23; + OnlineResponse online_response = 24; + KeyExchange key_exchange = 25; + HealthCheck hc = 26; + HttpProxyRequest http_proxy_request = 27; + HttpProxyResponse http_proxy_response = 28; + } +} diff --git a/libs/hbb_common/src/bytes_codec.rs b/libs/hbb_common/src/bytes_codec.rs new file mode 100644 index 00000000000..cbd53d918f7 --- /dev/null +++ b/libs/hbb_common/src/bytes_codec.rs @@ -0,0 +1,301 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use std::io; +use tokio_util::codec::{Decoder, Encoder}; + +// Bound speculative allocation from untrusted frame headers. +const MAX_PREALLOCATED_PAYLOAD_LEN: usize = 256 * 1024; + +#[derive(Debug, Clone, Copy)] +pub struct BytesCodec { + state: DecodeState, + raw: bool, + max_packet_length: usize, +} + +#[derive(Debug, Clone, Copy)] +enum DecodeState { + Head, + Data(usize), +} + +impl Default for BytesCodec { + fn default() -> Self { + Self::new() + } +} + +impl BytesCodec { + pub fn new() -> Self { + Self { + state: DecodeState::Head, + raw: false, + max_packet_length: usize::MAX, + } + } + + pub fn set_raw(&mut self) { + self.raw = true; + } + + pub fn set_max_packet_length(&mut self, n: usize) { + self.max_packet_length = n; + } + + fn decode_head(&mut self, src: &mut BytesMut) -> io::Result> { + if src.is_empty() { + return Ok(None); + } + let head_len = ((src[0] & 0x3) + 1) as usize; + if src.len() < head_len { + return Ok(None); + } + let mut n = src[0] as usize; + if head_len > 1 { + n |= (src[1] as usize) << 8; + } + if head_len > 2 { + n |= (src[2] as usize) << 16; + } + if head_len > 3 { + n |= (src[3] as usize) << 24; + } + n >>= 2; + if n > self.max_packet_length { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Too big packet")); + } + src.advance(head_len); + // Do not reserve the full header-declared length: a peer can advertise a huge + // frame and force excessive allocation before sending the payload. + src.reserve( + n.saturating_sub(src.len()) + .min(MAX_PREALLOCATED_PAYLOAD_LEN), + ); + Ok(Some(n)) + } + + fn decode_data(&self, n: usize, src: &mut BytesMut) -> io::Result> { + if src.len() < n { + return Ok(None); + } + Ok(Some(src.split_to(n))) + } +} + +impl Decoder for BytesCodec { + type Item = BytesMut; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, io::Error> { + if self.raw { + if !src.is_empty() { + let len = src.len(); + return Ok(Some(src.split_to(len))); + } else { + return Ok(None); + } + } + let n = match self.state { + DecodeState::Head => match self.decode_head(src)? { + Some(n) => { + self.state = DecodeState::Data(n); + n + } + None => return Ok(None), + }, + DecodeState::Data(n) => n, + }; + + match self.decode_data(n, src)? { + Some(data) => { + self.state = DecodeState::Head; + Ok(Some(data)) + } + None => Ok(None), + } + } +} + +impl Encoder for BytesCodec { + type Error = io::Error; + + fn encode(&mut self, data: Bytes, buf: &mut BytesMut) -> Result<(), io::Error> { + if self.raw { + buf.reserve(data.len()); + buf.put(data); + return Ok(()); + } + if data.len() <= 0x3F { + buf.put_u8((data.len() << 2) as u8); + } else if data.len() <= 0x3FFF { + buf.put_u16_le((data.len() << 2) as u16 | 0x1); + } else if data.len() <= 0x3FFFFF { + let h = (data.len() << 2) as u32 | 0x2; + buf.put_u16_le((h & 0xFFFF) as u16); + buf.put_u8((h >> 16) as u8); + } else if data.len() <= 0x3FFFFFFF { + buf.put_u32_le((data.len() << 2) as u32 | 0x3); + } else { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "Overflow")); + } + buf.extend(data); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_codec1() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + bytes.resize(0x3F, 1); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + let buf_saved = buf.clone(); + assert_eq!(buf.len(), 0x3F + 1); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3F); + assert_eq!(res[0], 1); + } else { + panic!(); + } + let mut codec2 = BytesCodec::new(); + let mut buf2 = BytesMut::new(); + if let Ok(None) = codec2.decode(&mut buf2) { + } else { + panic!(); + } + buf2.extend(&buf_saved[0..1]); + if let Ok(None) = codec2.decode(&mut buf2) { + } else { + panic!(); + } + buf2.extend(&buf_saved[1..]); + if let Ok(Some(res)) = codec2.decode(&mut buf2) { + assert_eq!(res.len(), 0x3F); + assert_eq!(res[0], 1); + } else { + panic!(); + } + } + + #[test] + fn test_codec2() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + assert!(codec.encode("".into(), &mut buf).is_ok()); + assert_eq!(buf.len(), 1); + bytes.resize(0x3F + 1, 2); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + assert_eq!(buf.len(), 0x3F + 2 + 2); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0); + } else { + panic!(); + } + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3F + 1); + assert_eq!(res[0], 2); + } else { + panic!(); + } + } + + #[test] + fn test_codec3() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + bytes.resize(0x3F - 1, 3); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + assert_eq!(buf.len(), 0x3F + 1 - 1); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3F - 1); + assert_eq!(res[0], 3); + } else { + panic!(); + } + } + #[test] + fn test_codec4() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + bytes.resize(0x3FFF, 4); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + assert_eq!(buf.len(), 0x3FFF + 2); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3FFF); + assert_eq!(res[0], 4); + } else { + panic!(); + } + } + + #[test] + fn test_codec5() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + bytes.resize(0x3FFFFF, 5); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + assert_eq!(buf.len(), 0x3FFFFF + 3); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3FFFFF); + assert_eq!(res[0], 5); + } else { + panic!(); + } + } + + #[test] + fn test_codec6() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let mut bytes: Vec = Vec::new(); + bytes.resize(0x3FFFFF + 1, 6); + assert!(codec.encode(bytes.into(), &mut buf).is_ok()); + let buf_saved = buf.clone(); + assert_eq!(buf.len(), 0x3FFFFF + 4 + 1); + if let Ok(Some(res)) = codec.decode(&mut buf) { + assert_eq!(res.len(), 0x3FFFFF + 1); + assert_eq!(res[0], 6); + } else { + panic!(); + } + let mut codec2 = BytesCodec::new(); + let mut buf2 = BytesMut::new(); + buf2.extend(&buf_saved[0..1]); + if let Ok(None) = codec2.decode(&mut buf2) { + } else { + panic!(); + } + buf2.extend(&buf_saved[1..6]); + if let Ok(None) = codec2.decode(&mut buf2) { + } else { + panic!(); + } + buf2.extend(&buf_saved[6..]); + if let Ok(Some(res)) = codec2.decode(&mut buf2) { + assert_eq!(res.len(), 0x3FFFFF + 1); + assert_eq!(res[0], 6); + } else { + panic!(); + } + } + + #[test] + fn decode_large_frame_header_caps_preallocation() { + let mut codec = BytesCodec::new(); + let mut buf = BytesMut::new(); + let n = 0x3FFFFFFFusize; + const MAX_REASONABLE_CAPACITY: usize = MAX_PREALLOCATED_PAYLOAD_LEN * 4; + + buf.put_u32_le((n << 2) as u32 | 0x3); + + assert!(matches!(codec.decode(&mut buf), Ok(None))); + assert!(buf.capacity() <= MAX_REASONABLE_CAPACITY); + } +} diff --git a/libs/hbb_common/src/compress.rs b/libs/hbb_common/src/compress.rs new file mode 100644 index 00000000000..761d916e4f8 --- /dev/null +++ b/libs/hbb_common/src/compress.rs @@ -0,0 +1,34 @@ +use std::{cell::RefCell, io}; +use zstd::bulk::Compressor; + +// The library supports regular compression levels from 1 up to ZSTD_maxCLevel(), +// which is currently 22. Levels >= 20 +// Default level is ZSTD_CLEVEL_DEFAULT==3. +// value 0 means default, which is controlled by ZSTD_CLEVEL_DEFAULT +thread_local! { + static COMPRESSOR: RefCell>> = RefCell::new(Compressor::new(crate::config::COMPRESS_LEVEL)); +} + +pub fn compress(data: &[u8]) -> Vec { + let mut out = Vec::new(); + COMPRESSOR.with(|c| { + if let Ok(mut c) = c.try_borrow_mut() { + match &mut *c { + Ok(c) => match c.compress(data) { + Ok(res) => out = res, + Err(err) => { + crate::log::debug!("Failed to compress: {}", err); + } + }, + Err(err) => { + crate::log::debug!("Failed to get compressor: {}", err); + } + } + } + }); + out +} + +pub fn decompress(data: &[u8]) -> Vec { + zstd::decode_all(data).unwrap_or_default() +} diff --git a/libs/hbb_common/src/config.rs b/libs/hbb_common/src/config.rs new file mode 100644 index 00000000000..8b8023b61fc --- /dev/null +++ b/libs/hbb_common/src/config.rs @@ -0,0 +1,4016 @@ +use std::{ + collections::{HashMap, HashSet}, + fs, + io::{Read, Write}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, + sync::{Mutex, RwLock}, + time::{Duration, Instant, SystemTime}, +}; + +use anyhow::{anyhow, Result}; +use bytes::Bytes; +use rand::Rng; +use regex::Regex; +use serde as de; +use serde_derive::{Deserialize, Serialize}; +use serde_json; +use sodiumoxide::base64; +use sodiumoxide::crypto::sign; + +mod permanent_password; + +pub use permanent_password::{ + compute_permanent_password_h1, decode_permanent_password_h1_from_storage, + decode_preset_password_h1_from_storage, local_permanent_password_storage_is_usable_for_auth, + preset_permanent_password_storage_is_usable_for_auth, ENCRYPT_MAX_LEN, +}; +use permanent_password::{ + decode_permanent_password_h1_from_hashed_storage, decrypt_permanent_password_str_or_original, + encode_permanent_password_encrypted_storage_from_h1, password_is_empty_or_not_hashed, + preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION, +}; + +use crate::{ + compress::{compress, decompress}, + log, + password_security::{ + decrypt_str_or_original, decrypt_vec_or_original, encrypt_str_or_original, + encrypt_vec_or_original, symmetric_crypt, + }, +}; + +pub const RENDEZVOUS_TIMEOUT: u64 = 12_000; +pub const CONNECT_TIMEOUT: u64 = 18_000; +pub const READ_TIMEOUT: u64 = 18_000; +// https://github.com/quic-go/quic-go/issues/525#issuecomment-294531351 +// https://datatracker.ietf.org/doc/html/draft-hamilton-early-deployment-quic-00#section-6.10 +// 15 seconds is recommended by quic, though oneSIP recommend 25 seconds, +// https://www.onsip.com/voip-resources/voip-fundamentals/what-is-nat-keepalive +pub const REG_INTERVAL: i64 = 15_000; +pub const COMPRESS_LEVEL: i32 = 3; +const SERIAL: i32 = 3; + +#[cfg(target_os = "macos")] +lazy_static::lazy_static! { + pub static ref ORG: RwLock = RwLock::new("com.carriez".to_owned()); +} + +type Size = (i32, i32, i32, i32); +type KeyPair = (Vec, Vec); + +lazy_static::lazy_static! { + static ref CONFIG: RwLock = RwLock::new(Config::load()); + static ref CONFIG2: RwLock = RwLock::new(Config2::load()); + static ref LOCAL_CONFIG: RwLock = RwLock::new(LocalConfig::load()); + static ref STATUS: RwLock = RwLock::new(Status::load()); + static ref TRUSTED_DEVICES: RwLock<(Vec, bool)> = Default::default(); + static ref ONLINE: Mutex> = Default::default(); + pub static ref PROD_RENDEZVOUS_SERVER: RwLock = RwLock::new("".to_owned()); + pub static ref EXE_RENDEZVOUS_SERVER: RwLock = Default::default(); + pub static ref APP_NAME: RwLock = RwLock::new("RustDesk".to_owned()); + static ref KEY_PAIR: Mutex> = Default::default(); + static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now())); + pub static ref NEW_STORED_PEER_CONFIG: Mutex> = Default::default(); + pub static ref DEFAULT_SETTINGS: RwLock> = Default::default(); + pub static ref OVERWRITE_SETTINGS: RwLock> = Default::default(); + pub static ref DEFAULT_DISPLAY_SETTINGS: RwLock> = Default::default(); + pub static ref OVERWRITE_DISPLAY_SETTINGS: RwLock> = Default::default(); + pub static ref DEFAULT_LOCAL_SETTINGS: RwLock> = Default::default(); + pub static ref OVERWRITE_LOCAL_SETTINGS: RwLock> = Default::default(); + pub static ref HARD_SETTINGS: RwLock> = Default::default(); + pub static ref BUILTIN_SETTINGS: RwLock> = Default::default(); +} + +#[cfg(target_os = "android")] +lazy_static::lazy_static! { + pub static ref ANDROID_RUSTLS_PLATFORM_VERIFIER_INITIALIZED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +} + +lazy_static::lazy_static! { + pub static ref APP_DIR: RwLock = Default::default(); +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +lazy_static::lazy_static! { + pub static ref APP_HOME_DIR: RwLock = Default::default(); +} + +pub const LINK_DOCS_HOME: &str = "https://rustdesk.com/docs/en/"; +pub const LINK_DOCS_X11_REQUIRED: &str = "https://rustdesk.com/docs/en/manual/linux/#x11-required"; +pub const LINK_HEADLESS_LINUX_SUPPORT: &str = + "https://github.com/rustdesk/rustdesk/wiki/Headless-Linux-Support"; + +lazy_static::lazy_static! { + pub static ref HELPER_URL: HashMap<&'static str, &'static str> = HashMap::from([ + ("rustdesk docs home", LINK_DOCS_HOME), + ("rustdesk docs x11-required", LINK_DOCS_X11_REQUIRED), + ("rustdesk x11 headless", LINK_HEADLESS_LINUX_SUPPORT), + ]); +} + +const NUM_CHARS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + +const CHARS: &[char] = &[ + '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', + 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', +]; + +pub const RENDEZVOUS_SERVERS: &[&str] = &["rs-ny.rustdesk.com"]; +pub const RS_PUB_KEY: &str = "OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw="; + +pub const RENDEZVOUS_PORT: i32 = 21116; +pub const RELAY_PORT: i32 = 21117; +pub const WS_RENDEZVOUS_PORT: i32 = 21118; +pub const WS_RELAY_PORT: i32 = 21119; + +#[inline] +pub fn is_service_ipc_postfix(postfix: &str) -> bool { + // `_service` is a protected cross-user IPC channel used by the root service. + // + // On Linux Wayland, input injection is implemented via uinput in the root service process. + // The user `--server` process must be able to connect to these uinput IPC channels, so they + // must share the same IPC parent directory as `_service`. + postfix == "_service" || postfix.starts_with("_uinput_") +} + +// Keep Linux/macOS IPC parent directory rules in one place to avoid drift between +// `ipc_path()` and Unix `ipc_path_for_uid()`. +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn ipc_parent_dir_for_uid(uid: u32, postfix: &str) -> String { + let app_name = APP_NAME.read().unwrap().clone(); + if is_service_ipc_postfix(postfix) { + format!("/tmp/{app_name}-service") + } else { + format!("/tmp/{app_name}-{uid}") + } +} + +macro_rules! serde_field_string { + ($default_func:ident, $de_func:ident, $default_expr:expr) => { + fn $default_func() -> String { + $default_expr + } + + fn $de_func<'de, D>(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + let s: String = + de::Deserialize::deserialize(deserializer).unwrap_or(Self::$default_func()); + if s.is_empty() { + return Ok(Self::$default_func()); + } + Ok(s) + } + }; +} + +macro_rules! serde_field_bool { + ($struct_name: ident, $field_name: literal, $func: ident, $default: literal) => { + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct $struct_name { + #[serde(default = $default, rename = $field_name, deserialize_with = "deserialize_bool")] + pub v: bool, + } + impl Default for $struct_name { + fn default() -> Self { + Self { v: Self::$func() } + } + } + impl $struct_name { + pub fn $func() -> bool { + UserDefaultConfig::read($field_name) == "Y" + } + } + impl Deref for $struct_name { + type Target = bool; + + fn deref(&self) -> &Self::Target { + &self.v + } + } + impl DerefMut for $struct_name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.v + } + } + }; +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum NetworkType { + Direct, + ProxySocks, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +pub struct Config { + #[serde( + default, + skip_serializing_if = "String::is_empty", + deserialize_with = "deserialize_string" + )] + pub id: String, // use + #[serde(default, deserialize_with = "deserialize_string")] + enc_id: String, // store + #[serde(default, deserialize_with = "deserialize_string")] + password: String, + #[serde(default, deserialize_with = "deserialize_string")] + salt: String, + #[serde(default, deserialize_with = "deserialize_keypair")] + key_pair: KeyPair, // sk, pk + #[serde(default, deserialize_with = "deserialize_bool")] + key_confirmed: bool, + #[serde(default, deserialize_with = "deserialize_hashmap_string_bool")] + keys_confirmed: HashMap, +} + +#[derive(Debug, Default, PartialEq, Serialize, Deserialize, Clone)] +pub struct Socks5Server { + #[serde(default, deserialize_with = "deserialize_string")] + pub proxy: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub username: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub password: String, +} + +// more variable configs +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +pub struct Config2 { + #[serde(default, deserialize_with = "deserialize_string")] + rendezvous_server: String, + #[serde(default, deserialize_with = "deserialize_i32")] + nat_type: i32, + #[serde(default, deserialize_with = "deserialize_i32")] + serial: i32, + #[serde(default, deserialize_with = "deserialize_string")] + unlock_pin: String, + #[serde(default, deserialize_with = "deserialize_string")] + trusted_devices: String, + + #[serde(default)] + socks: Option, + + // the other scalar value must before this + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + pub options: HashMap, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +pub struct Resolution { + pub w: i32, + pub h: i32, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct PeerConfig { + #[serde(default, deserialize_with = "deserialize_vec_u8")] + pub password: Vec, + #[serde(default, deserialize_with = "deserialize_size")] + pub size: Size, + #[serde(default, deserialize_with = "deserialize_size")] + pub size_ft: Size, + #[serde(default, deserialize_with = "deserialize_size")] + pub size_pf: Size, + #[serde( + default = "PeerConfig::default_view_style", + deserialize_with = "PeerConfig::deserialize_view_style", + skip_serializing_if = "String::is_empty" + )] + pub view_style: String, + // Image scroll style, scrolledge, scrollbar or scroll auto + #[serde( + default = "PeerConfig::default_scroll_style", + deserialize_with = "PeerConfig::deserialize_scroll_style", + skip_serializing_if = "String::is_empty" + )] + pub scroll_style: String, + #[serde( + default = "PeerConfig::default_edge_scroll_edge_thickness", + deserialize_with = "PeerConfig::deserialize_edge_scroll_edge_thickness" + )] + pub edge_scroll_edge_thickness: i32, + #[serde( + default = "PeerConfig::default_image_quality", + deserialize_with = "PeerConfig::deserialize_image_quality", + skip_serializing_if = "String::is_empty" + )] + pub image_quality: String, + #[serde( + default = "PeerConfig::default_custom_image_quality", + deserialize_with = "PeerConfig::deserialize_custom_image_quality", + skip_serializing_if = "Vec::is_empty" + )] + pub custom_image_quality: Vec, + #[serde(flatten)] + pub show_remote_cursor: ShowRemoteCursor, + #[serde(flatten)] + pub lock_after_session_end: LockAfterSessionEnd, + #[serde(flatten)] + pub terminal_persistent: TerminalPersistent, + #[serde(flatten)] + pub privacy_mode: PrivacyMode, + #[serde(flatten)] + pub allow_swap_key: AllowSwapKey, + #[serde(default, deserialize_with = "deserialize_vec_i32_string_i32")] + pub port_forwards: Vec<(i32, String, i32)>, + #[serde(default, deserialize_with = "deserialize_i32")] + pub direct_failures: i32, + #[serde(flatten)] + pub disable_audio: DisableAudio, + #[serde(flatten)] + pub disable_clipboard: DisableClipboard, + #[serde(flatten)] + pub enable_file_copy_paste: EnableFileCopyPaste, + #[serde(flatten)] + pub show_quality_monitor: ShowQualityMonitor, + #[serde(flatten)] + pub follow_remote_cursor: FollowRemoteCursor, + #[serde(flatten)] + pub follow_remote_window: FollowRemoteWindow, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub keyboard_mode: String, + #[serde(flatten)] + pub view_only: ViewOnly, + #[serde(flatten)] + pub show_my_cursor: ShowMyCursor, + #[serde(flatten)] + pub sync_init_clipboard: SyncInitClipboard, + // Mouse wheel or touchpad scroll mode + #[serde( + default = "PeerConfig::default_reverse_mouse_wheel", + deserialize_with = "PeerConfig::deserialize_reverse_mouse_wheel", + skip_serializing_if = "String::is_empty" + )] + pub reverse_mouse_wheel: String, + #[serde( + default = "PeerConfig::default_displays_as_individual_windows", + deserialize_with = "PeerConfig::deserialize_displays_as_individual_windows", + skip_serializing_if = "String::is_empty" + )] + pub displays_as_individual_windows: String, + #[serde( + default = "PeerConfig::default_use_all_my_displays_for_the_remote_session", + deserialize_with = "PeerConfig::deserialize_use_all_my_displays_for_the_remote_session", + skip_serializing_if = "String::is_empty" + )] + pub use_all_my_displays_for_the_remote_session: String, + #[serde( + rename = "trackpad-speed", + default = "PeerConfig::default_trackpad_speed", + deserialize_with = "PeerConfig::deserialize_trackpad_speed" + )] + pub trackpad_speed: i32, + + #[serde( + default, + deserialize_with = "deserialize_hashmap_resolutions", + skip_serializing_if = "HashMap::is_empty" + )] + pub custom_resolutions: HashMap, + + // The other scalar value must before this + #[serde( + default, + deserialize_with = "deserialize_hashmap_string_string", + skip_serializing_if = "HashMap::is_empty" + )] + pub options: HashMap, // not use delete to represent default values + // Various data for flutter ui + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + pub ui_flutter: HashMap, + #[serde(default)] + pub info: PeerInfoSerde, + #[serde(default)] + pub transfer: TransferSerde, +} + +impl Default for PeerConfig { + fn default() -> Self { + Self { + password: Default::default(), + size: Default::default(), + size_ft: Default::default(), + size_pf: Default::default(), + view_style: Self::default_view_style(), + scroll_style: Self::default_scroll_style(), + edge_scroll_edge_thickness: Self::default_edge_scroll_edge_thickness(), + image_quality: Self::default_image_quality(), + custom_image_quality: Self::default_custom_image_quality(), + show_remote_cursor: Default::default(), + lock_after_session_end: Default::default(), + terminal_persistent: Default::default(), + privacy_mode: Default::default(), + allow_swap_key: Default::default(), + port_forwards: Default::default(), + direct_failures: Default::default(), + disable_audio: Default::default(), + disable_clipboard: Default::default(), + enable_file_copy_paste: Default::default(), + show_quality_monitor: Default::default(), + follow_remote_cursor: Default::default(), + follow_remote_window: Default::default(), + keyboard_mode: Default::default(), + view_only: Default::default(), + show_my_cursor: Default::default(), + reverse_mouse_wheel: Self::default_reverse_mouse_wheel(), + displays_as_individual_windows: Self::default_displays_as_individual_windows(), + use_all_my_displays_for_the_remote_session: + Self::default_use_all_my_displays_for_the_remote_session(), + trackpad_speed: Self::default_trackpad_speed(), + custom_resolutions: Default::default(), + options: Self::default_options(), + ui_flutter: Default::default(), + info: Default::default(), + transfer: Default::default(), + sync_init_clipboard: Default::default(), + } + } +} + +#[derive(Debug, PartialEq, Default, Serialize, Deserialize, Clone)] +pub struct PeerInfoSerde { + #[serde(default, deserialize_with = "deserialize_string")] + pub username: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub hostname: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub platform: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +pub struct TransferSerde { + #[serde(default, deserialize_with = "deserialize_vec_string")] + pub write_jobs: Vec, + #[serde(default, deserialize_with = "deserialize_vec_string")] + pub read_jobs: Vec, +} + +#[inline] +pub fn get_online_state() -> i64 { + *ONLINE.lock().unwrap().values().max().unwrap_or(&0) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn patch(path: PathBuf) -> PathBuf { + if let Some(_tmp) = path.to_str() { + #[cfg(windows)] + return _tmp + .replace( + "system32\\config\\systemprofile", + "ServiceProfiles\\LocalService", + ) + .into(); + #[cfg(target_os = "macos")] + return _tmp.replace("Application Support", "Preferences").into(); + #[cfg(target_os = "linux")] + { + if _tmp == "/root" { + if let Ok(user) = crate::platform::linux::run_cmds_trim_newline("whoami") { + if user != "root" { + let cmd = format!("getent passwd '{}' | awk -F':' '{{print $6}}'", user); + if let Ok(output) = crate::platform::linux::run_cmds_trim_newline(&cmd) { + return output.into(); + } + return format!("/home/{user}").into(); + } + } + } + } + } + path +} + +impl Config2 { + fn load() -> Config2 { + let mut config = Config::load_::("2"); + let mut store = false; + if let Some(mut socks) = config.socks { + let (password, _, store2) = + decrypt_str_or_original(&socks.password, PASSWORD_ENC_VERSION); + socks.password = password; + config.socks = Some(socks); + store |= store2; + } + let (unlock_pin, _, store2) = + decrypt_str_or_original(&config.unlock_pin, PASSWORD_ENC_VERSION); + config.unlock_pin = unlock_pin; + store |= store2; + if store { + config.store(); + } + config + } + + pub fn file() -> PathBuf { + Config::file_("2") + } + + fn store(&self) { + let mut config = self.clone(); + let stored = Config::load_::("2"); + if let Some(mut socks) = config.socks { + let stored_password = stored + .socks + .as_ref() + .map(|socks| socks.password.as_str()) + .unwrap_or_default(); + socks.password = + keep_encrypted_storage_if_plaintext_unchanged(&socks.password, stored_password); + config.socks = Some(socks); + } + config.unlock_pin = + keep_encrypted_storage_if_plaintext_unchanged(&config.unlock_pin, &stored.unlock_pin); + Config::store_(&config, "2"); + } + + pub fn get() -> Config2 { + return CONFIG2.read().unwrap().clone(); + } + + pub fn set(cfg: Config2) -> bool { + let mut lock = CONFIG2.write().unwrap(); + if *lock == cfg { + return false; + } + *lock = cfg; + lock.store(); + true + } +} + +fn keep_encrypted_storage_if_plaintext_unchanged(plain: &str, stored: &str) -> String { + let (stored_plain, encrypted, _) = decrypt_str_or_original(stored, PASSWORD_ENC_VERSION); + if encrypted && stored_plain == plain { + return stored.to_owned(); + } + encrypt_str_or_original(plain, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN) +} + +pub fn load_path( + file: PathBuf, +) -> T { + let cfg = match confy::load_path(&file) { + Ok(config) => config, + Err(err) => { + if let confy::ConfyError::GeneralLoadError(err) = &err { + if err.kind() == std::io::ErrorKind::NotFound { + return T::default(); + } + } + log::error!("Failed to load config '{}': {}", file.display(), err); + T::default() + } + }; + cfg +} + +#[inline] +pub fn store_path(path: PathBuf, cfg: T) -> crate::ResultType<()> { + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + Ok(confy::store_path_perms( + path, + cfg, + fs::Permissions::from_mode(0o600), + )?) + } + #[cfg(windows)] + { + Ok(confy::store_path(path, cfg)?) + } +} + +impl Config { + fn load_( + suffix: &str, + ) -> T { + let file = Self::file_(suffix); + let cfg = load_path(file); + if suffix.is_empty() { + log::trace!("{:?}", cfg); + } + cfg + } + + fn store_(config: &T, suffix: &str) { + let file = Self::file_(suffix); + if let Err(err) = store_path(file, config) { + log::error!("Failed to store {suffix} config: {err}"); + } + } + + fn load() -> Config { + let mut config = Config::load_::(""); + let mut store = false; + if let Err(err) = Self::validate_or_decrypt_permanent_password_storage(&mut config) { + log::error!("Failed to validate or decrypt permanent password storage: {err}"); + } + let mut id_valid = false; + let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); + if encrypted { + config.id = id; + id_valid = true; + store |= store2; + } else if + // Comment out for forward compatible + // crate::get_modified_time(&Self::file_("")) + // .checked_sub(std::time::Duration::from_secs(30)) // allow modification during installation + // .unwrap_or_else(crate::get_exe_time) + // < crate::get_exe_time() + // && + !config.id.is_empty() + && config.enc_id.is_empty() + && !decrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION).1 + { + id_valid = true; + store = true; + } + if !id_valid { + log::warn!("ID is invalid, generating new one"); + for _ in 0..3 { + if let Some(id) = Config::gen_id() { + config.id = id; + store = true; + break; + } else { + log::error!("Failed to generate new id"); + } + } + } + if store { + config.store(); + } + config + } + + fn validate_or_decrypt_permanent_password_storage(config: &mut Config) -> Result<()> { + if config.password.is_empty() { + return Ok(()); + } + + if config.password.starts_with(PASSWORD_ENC_VERSION) { + let (plain, decrypted, should_store) = + decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); + if decrypted { + config.password = plain; + return Ok(()); + } + if !should_store { + return Err(anyhow!("Invalid permanent password encrypted hash storage")); + } + return Ok(()); + } + + let (decrypted_storage, decrypted, _) = + decrypt_permanent_password_str_or_original(&config.password); + if decrypted { + Self::ensure_permanent_password_hash_salt(config)?; + if decode_permanent_password_h1_from_hashed_storage(&decrypted_storage).is_some() { + return Ok(()); + } + return Err(anyhow!("Invalid permanent password encrypted hash storage")); + } + + Ok(()) + } + + fn ensure_permanent_password_hash_salt(config: &Config) -> Result<()> { + if config.salt.is_empty() { + return Err(anyhow!( + "Permanent password hash storage requires a non-empty salt" + )); + } + Ok(()) + } + + fn ensure_permanent_password_salt(config: &mut Config) { + if config.salt.is_empty() { + config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); + } + } + + fn prepare_config_for_store(config: &mut Config) { + match Self::validate_or_decrypt_permanent_password_storage(config) { + Ok(_) => {} + Err(err) => { + // This path is for unrecoverable permanent-password storage, such as + // hashed storage without its salt. Keep unrelated config writes working, + // but handle future transient migration errors separately. + log::error!( + "Clearing invalid permanent password storage before storing config: {err}" + ); + config.password.clear(); + config.salt.clear(); + } + } + } + + fn store(&self) { + let mut config = self.clone(); + Self::prepare_config_for_store(&mut config); + if !config.password.is_empty() + && decode_permanent_password_h1_from_storage(&config.password).is_none() + { + let stored = Config::load_::(""); + config.password = + keep_encrypted_storage_if_plaintext_unchanged(&config.password, &stored.password); + } + let (stored_id, encrypted, _) = + decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); + if !encrypted || stored_id != config.id { + config.enc_id = + encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + } + config.id = "".to_owned(); + Config::store_(&config, ""); + } + + pub fn file() -> PathBuf { + Self::file_("") + } + + fn file_(suffix: &str) -> PathBuf { + let name = format!("{}{}", *APP_NAME.read().unwrap(), suffix); + Config::with_extension(Self::path(name)) + } + + pub fn is_empty(&self) -> bool { + (self.id.is_empty() && self.enc_id.is_empty()) || self.key_pair.0.is_empty() + } + + /// Get the user's home directory for configuration purposes. + /// + /// # Security Note + /// This function uses `dirs_next::home_dir()` which reads the `$HOME` environment + /// variable on Unix systems. This is acceptable for user-space operations (config + /// file storage, logging) where the user may intentionally redirect their home + /// directory. + /// + /// **DO NOT use this function in privileged contexts** (e.g., code executed via + /// `gtk_sudo` or system services running as root). For privileged operations on + /// Linux, use `crate::platform::linux::get_home_dir_trusted()` which bypasses + /// the `$HOME` environment variable and queries the system password database + /// directly via `getpwuid`. + /// + /// Using `$HOME` in privileged contexts creates a confused-deputy vulnerability + /// where an attacker can manipulate the environment variable to inject malicious + /// paths into privileged operations. + pub fn get_home() -> PathBuf { + #[cfg(any(target_os = "android", target_os = "ios"))] + return PathBuf::from(APP_HOME_DIR.read().unwrap().as_str()); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if let Some(path) = dirs_next::home_dir() { + patch(path) + } else if let Ok(path) = std::env::current_dir() { + path + } else { + std::env::temp_dir() + } + } + } + + pub fn path>(p: P) -> PathBuf { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let mut path: PathBuf = APP_DIR.read().unwrap().clone().into(); + path.push(p); + return path; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + #[cfg(not(target_os = "macos"))] + let org = "".to_owned(); + #[cfg(target_os = "macos")] + let org = ORG.read().unwrap().clone(); + // /var/root for root + if let Some(project) = + directories_next::ProjectDirs::from("", &org, &APP_NAME.read().unwrap()) + { + let mut path = patch(project.config_dir().to_path_buf()); + path.push(p); + return path; + } + "".into() + } + } + + /// Get the log directory path. + /// + /// # Security Note + /// On macOS, this function uses `dirs_next::home_dir()` which reads the `$HOME` + /// environment variable. On Linux/Android, it uses `Self::get_home()`. + /// See [`Self::get_home()`] for security considerations regarding `$HOME` usage. + #[allow(unreachable_code)] + pub fn log_path() -> PathBuf { + #[cfg(target_os = "macos")] + { + if let Some(path) = dirs_next::home_dir().as_mut() { + path.push(format!("Library/Logs/{}", *APP_NAME.read().unwrap())); + return path.clone(); + } + } + #[cfg(target_os = "linux")] + { + let mut path = Self::get_home(); + path.push(format!(".local/share/logs/{}", *APP_NAME.read().unwrap())); + std::fs::create_dir_all(&path).ok(); + return path; + } + #[cfg(target_os = "android")] + { + let mut path = Self::get_home(); + path.push(format!("{}/Logs", *APP_NAME.read().unwrap())); + std::fs::create_dir_all(&path).ok(); + return path; + } + if let Some(path) = Self::path("").parent() { + let mut path: PathBuf = path.into(); + path.push("log"); + return path; + } + "".into() + } + + pub fn ipc_path(postfix: &str) -> String { + #[cfg(windows)] + { + // \\ServerName\pipe\PipeName + // where ServerName is either the name of a remote computer or a period, to specify the local computer. + // https://docs.microsoft.com/en-us/windows/win32/ipc/pipe-names + format!( + "\\\\.\\pipe\\{}\\query{}", + *APP_NAME.read().unwrap(), + postfix + ) + } + #[cfg(not(windows))] + { + #[cfg(target_os = "android")] + use std::os::unix::fs::PermissionsExt; + #[cfg(target_os = "android")] + let mut path: PathBuf = + format!("{}/{}", *APP_DIR.read().unwrap(), *APP_NAME.read().unwrap()).into(); + #[cfg(any(target_os = "linux", target_os = "macos"))] + let mut path: PathBuf = { + let uid = unsafe { libc::geteuid() as u32 }; + ipc_parent_dir_for_uid(uid, postfix).into() + }; + #[cfg(not(any(target_os = "android", target_os = "linux", target_os = "macos")))] + let mut path: PathBuf = format!("/tmp/{}", *APP_NAME.read().unwrap()).into(); + // Android stores IPC sockets under app-controlled directories. Create the IPC parent + // dir and enforce the expected mode here. On other Unix platforms, `ipc_path()` is + // intentionally side-effect free (no mkdir/chmod); callers should enforce directory and + // socket permissions at the IPC server boundary. + #[cfg(target_os = "android")] + { + fs::create_dir_all(&path).ok(); + let path_mode = if is_service_ipc_postfix(postfix) { + 0o0711 + } else { + 0o0700 + }; + fs::set_permissions(&path, fs::Permissions::from_mode(path_mode)).ok(); + } + path.push(format!("ipc{postfix}")); + path.to_str().unwrap_or("").to_owned() + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn ipc_path_for_uid(uid: u32, postfix: &str) -> String { + let parent = ipc_parent_dir_for_uid(uid, postfix); + format!("{parent}/ipc{postfix}") + } + + pub fn icon_path() -> PathBuf { + let mut path = Self::path("icons"); + if fs::create_dir_all(&path).is_err() { + path = std::env::temp_dir(); + } + path + } + + #[inline] + pub fn get_any_listen_addr(is_ipv4: bool) -> SocketAddr { + if is_ipv4 { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) + } else { + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) + } + } + + pub fn get_rendezvous_server() -> String { + let mut rendezvous_server = EXE_RENDEZVOUS_SERVER.read().unwrap().clone(); + if rendezvous_server.is_empty() { + rendezvous_server = Self::get_option("custom-rendezvous-server"); + } + if rendezvous_server.is_empty() { + rendezvous_server = PROD_RENDEZVOUS_SERVER.read().unwrap().clone(); + } + if rendezvous_server.is_empty() { + rendezvous_server = CONFIG2.read().unwrap().rendezvous_server.clone(); + } + if rendezvous_server.is_empty() { + rendezvous_server = Self::get_rendezvous_servers() + .drain(..) + .next() + .unwrap_or_default(); + } + if !rendezvous_server.contains(':') { + rendezvous_server = format!("{rendezvous_server}:{RENDEZVOUS_PORT}"); + } + rendezvous_server + } + + pub fn get_rendezvous_servers() -> Vec { + let s = EXE_RENDEZVOUS_SERVER.read().unwrap().clone(); + if !s.is_empty() { + return vec![s]; + } + let s = Self::get_option("custom-rendezvous-server"); + if !s.is_empty() { + return vec![s]; + } + let s = PROD_RENDEZVOUS_SERVER.read().unwrap().clone(); + if !s.is_empty() { + return vec![s]; + } + let serial_obsolute = CONFIG2.read().unwrap().serial > SERIAL; + if serial_obsolute { + let ss: Vec = Self::get_option("rendezvous-servers") + .split(',') + .filter(|x| x.contains('.')) + .map(|x| x.to_owned()) + .collect(); + if !ss.is_empty() { + return ss; + } + } + return RENDEZVOUS_SERVERS.iter().map(|x| x.to_string()).collect(); + } + + pub fn reset_online() { + *ONLINE.lock().unwrap() = Default::default(); + } + + pub fn update_latency(host: &str, latency: i64) { + ONLINE.lock().unwrap().insert(host.to_owned(), latency); + let mut host = "".to_owned(); + let mut delay = i64::MAX; + for (tmp_host, tmp_delay) in ONLINE.lock().unwrap().iter() { + if tmp_delay > &0 && tmp_delay < &delay { + delay = *tmp_delay; + host = tmp_host.to_string(); + } + } + if !host.is_empty() { + let mut config = CONFIG2.write().unwrap(); + if host != config.rendezvous_server { + log::debug!("Update rendezvous_server in config to {}", host); + log::debug!("{:?}", *ONLINE.lock().unwrap()); + config.rendezvous_server = host; + config.store(); + } + } + } + + pub fn set_id(id: &str) { + let mut config = CONFIG.write().unwrap(); + if id == config.id { + return; + } + config.id = id.into(); + config.store(); + } + + pub fn set_nat_type(nat_type: i32) { + let mut config = CONFIG2.write().unwrap(); + if nat_type == config.nat_type { + return; + } + config.nat_type = nat_type; + config.store(); + } + + pub fn get_nat_type() -> i32 { + CONFIG2.read().unwrap().nat_type + } + + pub fn set_serial(serial: i32) { + let mut config = CONFIG2.write().unwrap(); + if serial == config.serial { + return; + } + config.serial = serial; + config.store(); + } + + pub fn get_serial() -> i32 { + std::cmp::max(CONFIG2.read().unwrap().serial, SERIAL) + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + fn gen_id() -> Option { + Self::get_auto_id() + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + fn gen_id() -> Option { + let hostname_as_id = BUILTIN_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_ALLOW_HOSTNAME_AS_ID) + .map(|v| option2bool(keys::OPTION_ALLOW_HOSTNAME_AS_ID, v)) + .unwrap_or(false); + if hostname_as_id { + match whoami::fallible::hostname() { + Ok(h) => Some(h.replace(" ", "-")), + Err(e) => { + log::warn!("Failed to get hostname, \"{}\", fallback to auto id", e); + Self::get_auto_id() + } + } + } else { + Self::get_auto_id() + } + } + + fn get_auto_id() -> Option { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Some( + rand::thread_rng() + .gen_range(1_000_000_000..2_000_000_000) + .to_string(), + ); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let mut id = 0u32; + if let Ok(Some(ma)) = mac_address::get_mac_address() { + for x in &ma.bytes()[2..] { + id = (id << 8) | (*x as u32); + } + id &= 0x1FFFFFFF; + log::info!("Generated id {}", id); + Some(id.to_string()) + } else { + None + } + } + } + + pub fn get_auto_password(length: usize) -> String { + Self::get_auto_password_with_chars(length, CHARS) + } + + pub fn get_auto_numeric_password(length: usize) -> String { + Self::get_auto_password_with_chars(length, NUM_CHARS) + } + + fn get_auto_password_with_chars(length: usize, chars: &[char]) -> String { + let mut rng = rand::thread_rng(); + (0..length) + .map(|_| chars[rng.gen::() % chars.len()]) + .collect() + } + + pub fn get_key_confirmed() -> bool { + CONFIG.read().unwrap().key_confirmed + } + + pub fn set_key_confirmed(v: bool) { + let mut config = CONFIG.write().unwrap(); + if config.key_confirmed == v { + return; + } + config.key_confirmed = v; + if !v { + config.keys_confirmed = Default::default(); + } + config.store(); + } + + pub fn get_host_key_confirmed(host: &str) -> bool { + matches!(CONFIG.read().unwrap().keys_confirmed.get(host), Some(true)) + } + + pub fn set_host_key_confirmed(host: &str, v: bool) { + if Self::get_host_key_confirmed(host) == v { + return; + } + let mut config = CONFIG.write().unwrap(); + config.keys_confirmed.insert(host.to_owned(), v); + config.store(); + } + + pub fn get_key_pair() -> KeyPair { + // lock here to make sure no gen_keypair more than once + // no use of CONFIG directly here to ensure no recursive calling in Config::load because of password dec which calling this function + let mut lock = KEY_PAIR.lock().unwrap(); + if let Some(p) = lock.as_ref() { + return p.clone(); + } + let mut config = Config::load_::(""); + if config.key_pair.0.is_empty() { + log::info!("Generated new keypair for id: {}", config.id); + let (pk, sk) = sign::gen_keypair(); + let key_pair = (sk.0.to_vec(), pk.0.into()); + config.key_pair = key_pair.clone(); + std::thread::spawn(|| { + let mut config = CONFIG.write().unwrap(); + config.key_pair = key_pair; + config.store(); + }); + } + *lock = Some(config.key_pair.clone()); + config.key_pair + } + + pub fn get_cached_pk() -> Option> { + KEY_PAIR.lock().unwrap().clone().map(|k| k.1) + } + + /// Get existing key pair without generating a new one. + /// Returns None if no key pair exists in cache or config file. + pub fn get_existing_key_pair() -> Option { + let mut lock = KEY_PAIR.lock().unwrap(); + if let Some(p) = lock.as_ref() { + return Some(p.clone()); + } + + // IMPORTANT: this path is called while holding KEY_PAIR lock. + // Config::load_ must remain a raw conf load/deserialize path and must never + // call decrypt_* / symmetric_crypt (directly or indirectly), otherwise this + // can re-enter key loading and deadlock. + let config = Config::load_::(""); + if !config.key_pair.0.is_empty() { + *lock = Some(config.key_pair.clone()); + Some(config.key_pair) + } else { + None + } + } + + pub fn no_register_device() -> bool { + BUILTIN_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_REGISTER_DEVICE) + .map(|v| v == "N") + .unwrap_or(false) + } + + pub fn is_disable_change_permanent_password() -> bool { + BUILTIN_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_DISABLE_CHANGE_PERMANENT_PASSWORD) + .map(|v| v == "Y") + .unwrap_or(false) + } + + pub fn is_disable_change_id() -> bool { + BUILTIN_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_DISABLE_CHANGE_ID) + .map(|v| v == "Y") + .unwrap_or(false) + } + + pub fn is_disable_unlock_pin() -> bool { + BUILTIN_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_DISABLE_UNLOCK_PIN) + .map(|v| v == "Y") + .unwrap_or(false) + } + + pub fn get_id() -> String { + let mut id = CONFIG.read().unwrap().id.clone(); + if id.is_empty() { + if let Some(tmp) = Config::gen_id() { + id = tmp; + Config::set_id(&id); + } + } + id + } + + pub fn get_id_or(b: String) -> String { + let a = CONFIG.read().unwrap().id.clone(); + if a.is_empty() { + b + } else { + a + } + } + + pub fn get_options() -> HashMap { + let mut res = DEFAULT_SETTINGS.read().unwrap().clone(); + res.extend(CONFIG2.read().unwrap().options.clone()); + res.extend(OVERWRITE_SETTINGS.read().unwrap().clone()); + res + } + + #[inline] + fn purify_options(v: &mut HashMap) { + v.retain(|k, v| is_option_can_save(&OVERWRITE_SETTINGS, k, &DEFAULT_SETTINGS, v)); + } + + pub fn set_options(mut v: HashMap) { + Self::purify_options(&mut v); + let mut config = CONFIG2.write().unwrap(); + if config.options == v { + return; + } + config.options = v; + config.store(); + } + + pub fn get_option(k: &str) -> String { + get_or( + &OVERWRITE_SETTINGS, + &CONFIG2.read().unwrap().options, + &DEFAULT_SETTINGS, + k, + ) + .unwrap_or_default() + } + + pub fn get_bool_option(k: &str) -> bool { + option2bool(k, &Self::get_option(k)) + } + + pub fn set_option(k: String, v: String) { + if !is_option_can_save(&OVERWRITE_SETTINGS, &k, &DEFAULT_SETTINGS, &v) { + let mut config = CONFIG2.write().unwrap(); + if config.options.remove(&k).is_some() { + config.store(); + } + return; + } + let mut config = CONFIG2.write().unwrap(); + let v2 = if v.is_empty() { None } else { Some(&v) }; + if v2 != config.options.get(&k) { + if v2.is_none() { + config.options.remove(&k); + } else { + config.options.insert(k, v); + } + config.store(); + } + } + + pub fn update_id() { + // to-do: how about if one ip register a lot of ids? + let id = Self::get_id(); + let mut rng = rand::thread_rng(); + let new_id = rng.gen_range(1_000_000_000..2_000_000_000).to_string(); + Config::set_id(&new_id); + log::info!("id updated from {} to {}", id, new_id); + } + + /// Sets the local permanent password. + /// + /// Returns `true` when the password is accepted or already matches the effective + /// preset password. Returns `false` when changing the password is disabled or + /// the new password cannot be prepared for storage. + pub fn set_permanent_password(password: &str) -> bool { + if Self::is_disable_change_permanent_password() { + return false; + } + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + if preset_permanent_password_storage_matches_plain(&preset_storage, &preset_salt, password) + { + if CONFIG.read().unwrap().password.is_empty() { + return true; + } + } + + let mut config = CONFIG.write().unwrap(); + + let stored = if password.is_empty() { + Some(String::new()) + } else { + Self::compute_permanent_password_storage_for_update(&mut config, password) + }; + let Some(stored) = stored else { + log::error!("Failed to compute permanent password storage; refusing update"); + return false; + }; + if stored == config.password { + return true; + } + config.password = stored; + config.store(); + Self::clear_trusted_devices(); + true + } + + fn compute_permanent_password_storage_for_update( + config: &mut Config, + password: &str, + ) -> Option { + // Keep salt stable for user-initiated permanent password updates. + // Salt should only change when service->user sync updates storage and salt as a pair. + Self::ensure_permanent_password_salt(config); + let h1 = compute_permanent_password_h1(password, &config.salt); + encode_permanent_password_encrypted_storage_from_h1(&h1) + } + + /// Returns the locally persisted permanent password storage and salt (NOT the hard/preset one). + /// + /// This function is side-effect free: + /// - It does NOT call `get_salt()` (which may auto-generate salt). + /// - It returns a consistent snapshot under a single lock. + pub fn get_local_permanent_password_storage_and_salt() -> (String, String) { + let config = CONFIG.read().unwrap(); + (config.password.clone(), config.salt.clone()) + } + + /// Persist permanent password storage and salt from service->user config sync. + pub fn set_permanent_password_storage_for_sync( + storage: &str, + salt: &str, + ) -> crate::ResultType { + let mut config = CONFIG.write().unwrap(); + if !Self::apply_permanent_password_storage_for_sync(&mut config, storage, salt)? { + return Ok(false); + } + + config.store(); + Self::clear_trusted_devices(); + Ok(true) + } + + fn apply_permanent_password_storage_for_sync( + config: &mut Config, + storage: &str, + salt: &str, + ) -> Result { + if storage.is_empty() { + if config.password.is_empty() && (salt.is_empty() || config.salt == salt) { + return Ok(false); + } + config.password.clear(); + if !salt.is_empty() { + config.salt = salt.to_owned(); + } + return Ok(true); + } + if salt.is_empty() { + return Err(anyhow!( + "Refusing to persist permanent password storage without salt" + )); + } + if decode_permanent_password_h1_from_storage(storage).is_none() { + log::error!("Rejecting non-current permanent password storage sync payload"); + return Err(anyhow!("Invalid permanent password storage sync payload")); + } + if config.password == storage && config.salt == salt { + return Ok(false); + } + + config.password = storage.to_owned(); + config.salt = salt.to_owned(); + Ok(true) + } + + pub fn has_permanent_password() -> bool { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + if !local_storage.is_empty() { + return local_permanent_password_storage_is_usable_for_auth( + &local_storage, + &local_salt, + ); + } + Self::has_usable_preset_password() + } + + fn has_usable_preset_password() -> bool { + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + preset_permanent_password_storage_is_usable_for_auth(&preset_storage, &preset_salt) + } + + pub fn is_using_preset_password() -> bool { + let (local_storage, _) = Self::get_local_permanent_password_storage_and_salt(); + local_storage.is_empty() && Self::has_usable_preset_password() + } + + pub fn get_preset_password_storage_and_salt() -> (String, String) { + let hard_settings = HARD_SETTINGS.read().unwrap(); + let storage = hard_settings.get("password").cloned().unwrap_or_default(); + let salt = hard_settings.get("salt").cloned().unwrap_or_default(); + (storage, salt) + } + + pub fn get_effective_permanent_password_salt() -> String { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + if !local_storage.is_empty() { + if local_permanent_password_storage_is_usable_for_auth(&local_storage, &local_salt) { + return Self::get_salt(); + } + return String::new(); + } + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + if !preset_salt.is_empty() { + if preset_permanent_password_storage_is_usable_for_auth(&preset_storage, &preset_salt) { + return preset_salt; + } + return String::new(); + } + Self::get_salt() + } + + pub fn has_local_permanent_password() -> bool { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + local_permanent_password_storage_is_usable_for_auth(&local_storage, &local_salt) + } + + // This shouldn't happen under normal circumstances because the salt + // should be automatically generated when migrating to hash storage. + // Actually, it is better to avoid calling set_salt at all. + pub fn set_salt(salt: &str) { + let mut config = CONFIG.write().unwrap(); + if salt == config.salt { + return; + } + if !password_is_empty_or_not_hashed(&config.password) { + if config.salt.is_empty() { + log::warn!("Salt is empty but permanent password is hashed and salt is empty"); + } else { + log::error!("Refusing to set salt because permanent password is hashed"); + return; + } + } + config.salt = salt.into(); + config.store(); + } + + pub fn get_salt() -> String { + let config = CONFIG.read().unwrap(); + let mut salt = config.salt.clone(); + if salt.is_empty() { + drop(config); + salt = Config::get_auto_password(DEFAULT_SALT_LEN); + Config::set_salt(&salt); + } + salt + } + + pub fn set_socks(socks: Option) { + if OVERWRITE_SETTINGS + .read() + .unwrap() + .contains_key(keys::OPTION_PROXY_URL) + { + return; + } + + let mut config = CONFIG2.write().unwrap(); + if config.socks == socks { + return; + } + if config.socks.is_none() { + let equal_to_default = |key: &str, value: &str| { + DEFAULT_SETTINGS + .read() + .unwrap() + .get(key) + .map_or(false, |x| *x == value) + }; + let contains_url = DEFAULT_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_PROXY_URL) + .is_some(); + let url = equal_to_default( + keys::OPTION_PROXY_URL, + &socks.clone().unwrap_or_default().proxy, + ); + let username = equal_to_default( + keys::OPTION_PROXY_USERNAME, + &socks.clone().unwrap_or_default().username, + ); + let password = equal_to_default( + keys::OPTION_PROXY_PASSWORD, + &socks.clone().unwrap_or_default().password, + ); + if contains_url && url && username && password { + return; + } + } + config.socks = socks; + config.store(); + } + + #[inline] + fn get_socks_from_custom_client_advanced_settings( + settings: &HashMap, + ) -> Option { + let url = settings.get(keys::OPTION_PROXY_URL)?; + Some(Socks5Server { + proxy: url.to_owned(), + username: settings + .get(keys::OPTION_PROXY_USERNAME) + .map(|x| x.to_string()) + .unwrap_or_default(), + password: settings + .get(keys::OPTION_PROXY_PASSWORD) + .map(|x| x.to_string()) + .unwrap_or_default(), + }) + } + + pub fn get_socks() -> Option { + Self::get_socks_from_custom_client_advanced_settings(&OVERWRITE_SETTINGS.read().unwrap()) + .or(CONFIG2.read().unwrap().socks.clone()) + .or(Self::get_socks_from_custom_client_advanced_settings( + &DEFAULT_SETTINGS.read().unwrap(), + )) + } + + #[inline] + pub fn is_proxy() -> bool { + Self::get_network_type() != NetworkType::Direct + } + + pub fn get_network_type() -> NetworkType { + if OVERWRITE_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_PROXY_URL) + .is_some() + { + return NetworkType::ProxySocks; + } + if CONFIG2.read().unwrap().socks.is_some() { + return NetworkType::ProxySocks; + } + if DEFAULT_SETTINGS + .read() + .unwrap() + .get(keys::OPTION_PROXY_URL) + .is_some() + { + return NetworkType::ProxySocks; + } + NetworkType::Direct + } + + pub fn get_unlock_pin() -> String { + if Self::is_disable_unlock_pin() { + return String::new(); + } + CONFIG2.read().unwrap().unlock_pin.clone() + } + + pub fn set_unlock_pin(pin: &str) { + if Self::is_disable_unlock_pin() { + return; + } + let mut config = CONFIG2.write().unwrap(); + if pin == config.unlock_pin { + return; + } + config.unlock_pin = pin.to_string(); + config.store(); + } + + pub fn get_trusted_devices_json() -> String { + serde_json::to_string(&Self::get_trusted_devices()).unwrap_or_default() + } + + pub fn get_trusted_devices() -> Vec { + let (devices, synced) = TRUSTED_DEVICES.read().unwrap().clone(); + if synced { + return devices; + } + let devices = CONFIG2.read().unwrap().trusted_devices.clone(); + let (devices, succ, store) = decrypt_str_or_original(&devices, PASSWORD_ENC_VERSION); + if succ { + let mut devices: Vec = + serde_json::from_str(&devices).unwrap_or_default(); + let len = devices.len(); + devices.retain(|d| !d.outdate()); + if store || devices.len() != len { + Self::set_trusted_devices(devices.clone()); + } + *TRUSTED_DEVICES.write().unwrap() = (devices.clone(), true); + devices + } else { + Default::default() + } + } + + fn set_trusted_devices(mut trusted_devices: Vec) { + trusted_devices.retain(|d| !d.outdate()); + let devices = serde_json::to_string(&trusted_devices).unwrap_or_default(); + let max_len = 1024 * 1024; + if devices.bytes().len() > max_len { + log::error!("Trusted devices too large: {}", devices.bytes().len()); + return; + } + let devices = encrypt_str_or_original(&devices, PASSWORD_ENC_VERSION, max_len); + let mut config = CONFIG2.write().unwrap(); + config.trusted_devices = devices; + config.store(); + *TRUSTED_DEVICES.write().unwrap() = (trusted_devices, true); + } + + pub fn add_trusted_device(device: TrustedDevice) { + let mut devices = Self::get_trusted_devices(); + devices.retain(|d| d.hwid != device.hwid); + devices.push(device); + Self::set_trusted_devices(devices); + } + + pub fn remove_trusted_devices(hwids: &Vec) { + let mut devices = Self::get_trusted_devices(); + devices.retain(|d| !hwids.contains(&d.hwid)); + Self::set_trusted_devices(devices); + } + + pub fn clear_trusted_devices() { + Self::set_trusted_devices(Default::default()); + } + + pub fn get() -> Config { + return CONFIG.read().unwrap().clone(); + } + + // TODO: `Config::set()` does not invalidate trusted devices when permanent password/salt changes. + // This matches historical behavior, but may need revisiting in a separate PR. + pub fn set(cfg: Config) -> bool { + let mut lock = CONFIG.write().unwrap(); + if *lock == cfg { + return false; + } + *lock = cfg; + lock.store(); + // Drop CONFIG lock before acquiring KEY_PAIR lock to avoid potential deadlock. + #[cfg(target_os = "macos")] + let new_key_pair = lock.key_pair.clone(); + drop(lock); + #[cfg(target_os = "macos")] + Self::invalidate_key_pair_cache_if_changed(&new_key_pair); + true + } + + /// Invalidate KEY_PAIR cache if it differs from the new key_pair. + /// Use None to invalidate the cache instead of Some(key_pair). + /// If we use Some with an empty key_pair, get_key_pair() would always return + /// the empty key_pair from cache without regenerating. + /// By clearing the cache, get_key_pair() will reload and regenerate if needed. + #[cfg(target_os = "macos")] + fn invalidate_key_pair_cache_if_changed(new_key_pair: &KeyPair) { + let mut key_pair_cache = KEY_PAIR.lock().unwrap(); + if let Some(cached) = key_pair_cache.as_ref() { + if cached != new_key_pair { + *key_pair_cache = None; + log::info!("key pair cache invalidated"); + } + } + } + + fn with_extension(path: PathBuf) -> PathBuf { + let ext = path.extension(); + if let Some(ext) = ext { + let ext = format!("{}.toml", ext.to_string_lossy()); + path.with_extension(ext) + } else { + path.with_extension("toml") + } + } +} + +const PEERS: &str = "peers"; + +impl PeerConfig { + pub fn load(id: &str) -> PeerConfig { + let _lock = CONFIG.read().unwrap(); + match confy::load_path(Self::path(id)) { + Ok(config) => { + let mut config: PeerConfig = config; + let mut store = false; + let (password, _, store2) = + decrypt_vec_or_original(&config.password, PASSWORD_ENC_VERSION); + config.password = password; + store = store || store2; + for opt in ["rdp_password", "os-username", "os-password"] { + if let Some(v) = config.options.get_mut(opt) { + let (encrypted, _, store2) = + decrypt_str_or_original(v, PASSWORD_ENC_VERSION); + *v = encrypted; + store = store || store2; + } + } + if store { + config.store_(id); + } + config + } + Err(err) => { + if let confy::ConfyError::GeneralLoadError(err) = &err { + if err.kind() == std::io::ErrorKind::NotFound { + return Default::default(); + } + } + log::error!("Failed to load peer config '{}': {}", id, err); + Default::default() + } + } + } + + pub fn store(&self, id: &str) { + let _lock = CONFIG.read().unwrap(); + self.store_(id); + } + + fn store_(&self, id: &str) { + let mut config = self.clone(); + config.password = + encrypt_vec_or_original(&config.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + for opt in ["rdp_password", "os-username", "os-password"] { + if let Some(v) = config.options.get_mut(opt) { + *v = encrypt_str_or_original(v, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN) + } + } + if let Err(err) = store_path(Self::path(id), config) { + log::error!("Failed to store config: {}", err); + } + NEW_STORED_PEER_CONFIG.lock().unwrap().insert(id.to_owned()); + } + + pub fn remove(id: &str) { + fs::remove_file(Self::path(id)).ok(); + } + + fn path(id: &str) -> PathBuf { + //If the id contains invalid chars, encode it + let forbidden_paths = Regex::new(r".*[<>:/\\|\?\*].*"); + let path: PathBuf; + if let Ok(forbidden_paths) = forbidden_paths { + let id_encoded = if forbidden_paths.is_match(id) { + "base64_".to_string() + base64::encode(id, base64::Variant::Original).as_str() + } else { + id.to_string() + }; + path = [PEERS, id_encoded.as_str()].iter().collect(); + } else { + log::warn!("Regex create failed: {:?}", forbidden_paths.err()); + // fallback for failing to create this regex. + path = [PEERS, id.replace(":", "_").as_str()].iter().collect(); + } + Config::with_extension(Config::path(path)) + } + + // The number of peers to load in the first round when showing the peers card list in the main window. + // When there're too many peers, loading all of them at once will take a long time. + // We can load them in two rouds, the first round loads the first 100 peers, and the second round loads the rest. + // Then the UI will show the first 100 peers first, and the rest will be loaded and shown later. + pub const BATCH_LOADING_COUNT: usize = 100; + + pub fn get_vec_id_modified_time_path( + id_filters: &Option>, + ) -> Vec<(String, SystemTime, PathBuf)> { + if let Ok(peers) = Config::path(PEERS).read_dir() { + let mut vec_id_modified_time_path = peers + .into_iter() + .filter_map(|res| match res { + Ok(res) => { + let p = res.path(); + if p.is_file() + && p.extension().map(|p| p.to_str().unwrap_or("")) == Some("toml") + { + Some(p) + } else { + None + } + } + _ => None, + }) + .map(|p| { + let id = p + .file_stem() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("") + .to_owned(); + + let id_decoded_string = if id.starts_with("base64_") && id.len() != 7 { + let id_decoded = + base64::decode(&id[7..], base64::Variant::Original).unwrap_or_default(); + String::from_utf8_lossy(&id_decoded).as_ref().to_owned() + } else { + id + }; + (id_decoded_string, p) + }) + .filter(|(id, _)| { + let Some(filters) = id_filters else { + return true; + }; + filters.contains(id) + }) + .map(|(id, p)| { + let t = crate::get_modified_time(&p); + (id, t, p) + }) + .collect::>(); + vec_id_modified_time_path.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + vec_id_modified_time_path + } else { + vec![] + } + } + + #[inline] + async fn preload_file_async(path: PathBuf) { + let _ = tokio::fs::File::open(path).await; + } + + #[tokio::main(flavor = "current_thread")] + async fn preload_peers_async() { + let now = std::time::Instant::now(); + let vec_id_modified_time_path = Self::get_vec_id_modified_time_path(&None); + let total_count = vec_id_modified_time_path.len(); + let mut futs = vec![]; + for (_, _, path) in vec_id_modified_time_path.into_iter() { + futs.push(Self::preload_file_async(path)); + if futs.len() >= Self::BATCH_LOADING_COUNT { + let first_load_start = std::time::Instant::now(); + futures::future::join_all(futs).await; + if first_load_start.elapsed().as_millis() < 10 { + // No need to preload the rest if the first load is fast. + return; + } + futs = vec![]; + } + } + if !futs.is_empty() { + futures::future::join_all(futs).await; + } + log::info!( + "Preload peers done in {:?}, batch_count: {}, total: {}", + now.elapsed(), + Self::BATCH_LOADING_COUNT, + total_count + ); + } + + // We have to preload all peers in a background thread. + // Because we find that opening files the first time after the system (Windows) booting will be very slow, up to 200~400ms. + // The reason is that the Windows has "Microsoft Defender Antivirus Service" running in the background, which will scan the file when it's opened the first time. + // So we have to preload all peers in a background thread to avoid the delay when opening the file the first time. + // We can temporarily stop "Microsoft Defender Antivirus Service" or add the fold to the white list, to verify this. But don't do this in the release version. + pub fn preload_peers() { + std::thread::spawn(|| { + Self::preload_peers_async(); + }); + } + + pub fn peers(id_filters: Option>) -> Vec<(String, SystemTime, PeerConfig)> { + let vec_id_modified_time_path = Self::get_vec_id_modified_time_path(&id_filters); + Self::batch_peers( + &vec_id_modified_time_path, + 0, + Some(vec_id_modified_time_path.len()), + ) + .0 + } + + pub fn batch_peers( + all: &Vec<(String, SystemTime, PathBuf)>, + from: usize, + to: Option, + ) -> (Vec<(String, SystemTime, PeerConfig)>, usize) { + if from >= all.len() { + return (vec![], 0); + } + + let to = match to { + Some(to) => to.min(all.len()), + None => (from + Self::BATCH_LOADING_COUNT).min(all.len()), + }; + + // to <= from is unexpected, but we can just return an empty vec in this case. + if to <= from { + return (vec![], from); + } + + let peers: Vec<_> = all[from..to] + .iter() + .map(|(id, t, p)| { + let c = PeerConfig::load(&id); + if c.info.platform.is_empty() { + fs::remove_file(p).ok(); + } + (id.clone(), t.clone(), c) + }) + .filter(|p| !p.2.info.platform.is_empty()) + .collect(); + (peers, to) + } + + pub fn exists(id: &str) -> bool { + Self::path(id).exists() + } + + serde_field_string!( + default_view_style, + deserialize_view_style, + UserDefaultConfig::read(keys::OPTION_VIEW_STYLE) + ); + serde_field_string!( + default_scroll_style, + deserialize_scroll_style, + UserDefaultConfig::read(keys::OPTION_SCROLL_STYLE) + ); + serde_field_string!( + default_image_quality, + deserialize_image_quality, + UserDefaultConfig::read(keys::OPTION_IMAGE_QUALITY) + ); + serde_field_string!( + default_reverse_mouse_wheel, + deserialize_reverse_mouse_wheel, + UserDefaultConfig::read(keys::OPTION_REVERSE_MOUSE_WHEEL) + ); + serde_field_string!( + default_displays_as_individual_windows, + deserialize_displays_as_individual_windows, + UserDefaultConfig::read(keys::OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS) + ); + serde_field_string!( + default_use_all_my_displays_for_the_remote_session, + deserialize_use_all_my_displays_for_the_remote_session, + UserDefaultConfig::read(keys::OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION) + ); + + fn default_custom_image_quality() -> Vec { + let f: f64 = UserDefaultConfig::read(keys::OPTION_CUSTOM_IMAGE_QUALITY) + .parse() + .unwrap_or(50.0); + vec![f as _] + } + + fn deserialize_custom_image_quality<'de, D>(deserializer: D) -> Result, D::Error> + where + D: de::Deserializer<'de>, + { + let v: Vec = de::Deserialize::deserialize(deserializer)?; + if v.len() == 1 && v[0] >= 10 && v[0] <= 0xFFF { + Ok(v) + } else { + Ok(Self::default_custom_image_quality()) + } + } + + fn default_options() -> HashMap { + let mut mp: HashMap = Default::default(); + let _ = [ + keys::OPTION_CODEC_PREFERENCE, + keys::OPTION_CUSTOM_FPS, + keys::OPTION_ZOOM_CURSOR, + keys::OPTION_I444, + keys::OPTION_SWAP_LEFT_RIGHT_MOUSE, + keys::OPTION_COLLAPSE_TOOLBAR, + ] + .map(|key| { + mp.insert(key.to_owned(), UserDefaultConfig::read(key)); + }); + mp + } + + fn default_trackpad_speed() -> i32 { + UserDefaultConfig::read(keys::OPTION_TRACKPAD_SPEED) + .parse() + .unwrap_or(100) + } + + fn deserialize_trackpad_speed<'de, D>(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + let v: i32 = de::Deserialize::deserialize(deserializer)?; + if v >= 10 && v <= 1000 { + Ok(v) + } else { + Ok(Self::default_trackpad_speed()) + } + } + + fn default_edge_scroll_edge_thickness() -> i32 { + UserDefaultConfig::read(keys::OPTION_EDGE_SCROLL_EDGE_THICKNESS) + .parse() + .unwrap_or(100) + } + + fn deserialize_edge_scroll_edge_thickness<'de, D>(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + let v: i32 = de::Deserialize::deserialize(deserializer)?; + if v >= 20 && v <= 150 { + Ok(v) + } else { + Ok(Self::default_edge_scroll_edge_thickness()) + } + } +} + +serde_field_bool!( + ShowRemoteCursor, + "show_remote_cursor", + default_show_remote_cursor, + "ShowRemoteCursor::default_show_remote_cursor" +); +serde_field_bool!( + FollowRemoteCursor, + "follow_remote_cursor", + default_follow_remote_cursor, + "FollowRemoteCursor::default_follow_remote_cursor" +); + +serde_field_bool!( + FollowRemoteWindow, + "follow_remote_window", + default_follow_remote_window, + "FollowRemoteWindow::default_follow_remote_window" +); +serde_field_bool!( + ShowQualityMonitor, + "show_quality_monitor", + default_show_quality_monitor, + "ShowQualityMonitor::default_show_quality_monitor" +); +serde_field_bool!( + DisableAudio, + "disable_audio", + default_disable_audio, + "DisableAudio::default_disable_audio" +); +serde_field_bool!( + EnableFileCopyPaste, + "enable-file-copy-paste", + default_enable_file_copy_paste, + "EnableFileCopyPaste::default_enable_file_copy_paste" +); +serde_field_bool!( + DisableClipboard, + "disable_clipboard", + default_disable_clipboard, + "DisableClipboard::default_disable_clipboard" +); +serde_field_bool!( + LockAfterSessionEnd, + "lock_after_session_end", + default_lock_after_session_end, + "LockAfterSessionEnd::default_lock_after_session_end" +); +serde_field_bool!( + TerminalPersistent, + "terminal-persistent", + default_terminal_persistent, + "TerminalPersistent::default_terminal_persistent" +); +serde_field_bool!( + PrivacyMode, + "privacy_mode", + default_privacy_mode, + "PrivacyMode::default_privacy_mode" +); + +serde_field_bool!( + AllowSwapKey, + "allow_swap_key", + default_allow_swap_key, + "AllowSwapKey::default_allow_swap_key" +); + +serde_field_bool!( + ViewOnly, + "view_only", + default_view_only, + "ViewOnly::default_view_only" +); + +serde_field_bool!( + ShowMyCursor, + "show_my_cursor", + default_show_my_cursor, + "ShowMyCursor::default_show_my_cursor" +); + +serde_field_bool!( + SyncInitClipboard, + "sync-init-clipboard", + default_sync_init_clipboard, + "SyncInitClipboard::default_sync_init_clipboard" +); + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct LocalConfig { + #[serde(default, deserialize_with = "deserialize_string")] + remote_id: String, // latest used one + #[serde(default, deserialize_with = "deserialize_string")] + kb_layout_type: String, + #[serde(default, deserialize_with = "deserialize_size")] + size: Size, + #[serde(default, deserialize_with = "deserialize_vec_string")] + pub fav: Vec, + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + options: HashMap, + // Various data for flutter ui + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + ui_flutter: HashMap, +} + +impl LocalConfig { + fn load() -> LocalConfig { + Config::load_::("_local") + } + + fn store(&self) { + Config::store_(self, "_local"); + } + + pub fn get_kb_layout_type() -> String { + LOCAL_CONFIG.read().unwrap().kb_layout_type.clone() + } + + pub fn set_kb_layout_type(kb_layout_type: String) { + let mut config = LOCAL_CONFIG.write().unwrap(); + config.kb_layout_type = kb_layout_type; + config.store(); + } + + pub fn get_size() -> Size { + LOCAL_CONFIG.read().unwrap().size + } + + pub fn set_size(x: i32, y: i32, w: i32, h: i32) { + let mut config = LOCAL_CONFIG.write().unwrap(); + let size = (x, y, w, h); + if size == config.size || size.2 < 300 || size.3 < 300 { + return; + } + config.size = size; + config.store(); + } + + pub fn set_remote_id(remote_id: &str) { + let mut config = LOCAL_CONFIG.write().unwrap(); + if remote_id == config.remote_id { + return; + } + config.remote_id = remote_id.into(); + config.store(); + } + + pub fn get_remote_id() -> String { + LOCAL_CONFIG.read().unwrap().remote_id.clone() + } + + pub fn set_fav(fav: Vec) { + let mut lock = LOCAL_CONFIG.write().unwrap(); + if lock.fav == fav { + return; + } + lock.fav = fav; + lock.store(); + } + + pub fn get_fav() -> Vec { + LOCAL_CONFIG.read().unwrap().fav.clone() + } + + pub fn get_option(k: &str) -> String { + get_or( + &OVERWRITE_LOCAL_SETTINGS, + &LOCAL_CONFIG.read().unwrap().options, + &DEFAULT_LOCAL_SETTINGS, + k, + ) + .unwrap_or_default() + } + + // Usually get_option should be used. + pub fn get_option_from_file(k: &str) -> String { + get_or( + &OVERWRITE_LOCAL_SETTINGS, + &Self::load().options, + &DEFAULT_LOCAL_SETTINGS, + k, + ) + .unwrap_or_default() + } + + pub fn get_bool_option(k: &str) -> bool { + option2bool(k, &Self::get_option(k)) + } + + pub fn set_option(k: String, v: String) { + if !is_option_can_save(&OVERWRITE_LOCAL_SETTINGS, &k, &DEFAULT_LOCAL_SETTINGS, &v) { + let mut config = LOCAL_CONFIG.write().unwrap(); + if config.options.remove(&k).is_some() { + config.store(); + } + return; + } + let mut config = LOCAL_CONFIG.write().unwrap(); + // The custom client will explictly set "default" as the default language. + let is_custom_client_default_lang = k == keys::OPTION_LANGUAGE && v == "default"; + if is_custom_client_default_lang { + config.options.insert(k, "".to_owned()); + config.store(); + return; + } + let v2 = if v.is_empty() { None } else { Some(&v) }; + if v2 != config.options.get(&k) { + if v2.is_none() { + config.options.remove(&k); + } else { + config.options.insert(k, v); + } + config.store(); + } + } + + pub fn get_flutter_option(k: &str) -> String { + get_or( + &OVERWRITE_LOCAL_SETTINGS, + &LOCAL_CONFIG.read().unwrap().ui_flutter, + &DEFAULT_LOCAL_SETTINGS, + k, + ) + .unwrap_or_default() + } + + pub fn set_flutter_option(k: String, v: String) { + let mut config = LOCAL_CONFIG.write().unwrap(); + let v2 = if v.is_empty() { None } else { Some(&v) }; + if v2 != config.ui_flutter.get(&k) { + if v2.is_none() { + config.ui_flutter.remove(&k); + } else { + config.ui_flutter.insert(k, v); + } + config.store(); + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct DiscoveryPeer { + #[serde(default, deserialize_with = "deserialize_string")] + pub id: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub username: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub hostname: String, + #[serde(default, deserialize_with = "deserialize_string")] + pub platform: String, + #[serde(default, deserialize_with = "deserialize_bool")] + pub online: bool, + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + pub ip_mac: HashMap, +} + +impl DiscoveryPeer { + pub fn is_same_peer(&self, other: &DiscoveryPeer) -> bool { + self.id == other.id && self.username == other.username + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct LanPeers { + #[serde(default, deserialize_with = "deserialize_vec_discoverypeer")] + pub peers: Vec, +} + +impl LanPeers { + pub fn load() -> LanPeers { + let _lock = CONFIG.read().unwrap(); + match confy::load_path(Config::file_("_lan_peers")) { + Ok(peers) => peers, + Err(err) => { + log::error!("Failed to load lan peers: {}", err); + Default::default() + } + } + } + + pub fn store(peers: &[DiscoveryPeer]) { + let f = LanPeers { + peers: peers.to_owned(), + }; + if let Err(err) = store_path(Config::file_("_lan_peers"), f) { + log::error!("Failed to store lan peers: {}", err); + } + } + + pub fn modify_time() -> crate::ResultType { + let p = Config::file_("_lan_peers"); + Ok(fs::metadata(p)? + .modified()? + .duration_since(SystemTime::UNIX_EPOCH)? + .as_millis() as _) + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct UserDefaultConfig { + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + options: HashMap, +} + +impl UserDefaultConfig { + fn read(key: &str) -> String { + let mut cfg = USER_DEFAULT_CONFIG.write().unwrap(); + // we do so, because default config may changed in another process, but we don't sync it + // but no need to read every time, give a small interval to avoid too many redundant read waste + if cfg.1.elapsed() > Duration::from_secs(1) { + *cfg = (Self::load(), Instant::now()); + } + cfg.0.get(key) + } + + pub fn load() -> UserDefaultConfig { + Config::load_::("_default") + } + + #[inline] + fn store(&self) { + Config::store_(self, "_default"); + } + + pub fn get(&self, key: &str) -> String { + match key { + #[cfg(any(target_os = "android", target_os = "ios"))] + keys::OPTION_VIEW_STYLE => self.get_string(key, "adaptive", vec!["original"]), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + keys::OPTION_VIEW_STYLE => self.get_string(key, "original", vec!["adaptive"]), + keys::OPTION_SCROLL_STYLE => { + self.get_string(key, "scrollauto", vec!["scrolledge", "scrollbar"]) + } + keys::OPTION_IMAGE_QUALITY => { + self.get_string(key, "balanced", vec!["best", "low", "custom"]) + } + keys::OPTION_CODEC_PREFERENCE => { + self.get_string(key, "auto", vec!["vp8", "vp9", "av1", "h264", "h265"]) + } + keys::OPTION_CUSTOM_IMAGE_QUALITY => self.get_num_string(key, 50.0, 10.0, 0xFFF as f64), + keys::OPTION_CUSTOM_FPS => self.get_num_string(key, 30.0, 5.0, 120.0), + keys::OPTION_ENABLE_FILE_COPY_PASTE => self.get_string(key, "Y", vec!["", "N"]), + keys::OPTION_EDGE_SCROLL_EDGE_THICKNESS => self.get_num_string(key, 100, 20, 150), + keys::OPTION_TRACKPAD_SPEED => self.get_num_string(key, 100, 10, 1000), + _ => self + .get_after(key) + .map(|v| v.to_string()) + .unwrap_or_default(), + } + } + + pub fn set(&mut self, key: String, value: String) { + if !is_option_can_save( + &OVERWRITE_DISPLAY_SETTINGS, + &key, + &DEFAULT_DISPLAY_SETTINGS, + &value, + ) { + if self.options.remove(&key).is_some() { + self.store(); + } + return; + } + if value.is_empty() { + self.options.remove(&key); + } else { + self.options.insert(key, value); + } + self.store(); + } + + #[inline] + fn get_string(&self, key: &str, default: &str, others: Vec<&str>) -> String { + match self.get_after(key) { + Some(option) => { + if others.contains(&option.as_str()) { + option.to_owned() + } else { + default.to_owned() + } + } + None => default.to_owned(), + } + } + + #[inline] + fn get_num_string(&self, key: &str, default: T, min: T, max: T) -> String + where + T: ToString + std::str::FromStr + std::cmp::PartialOrd + std::marker::Copy, + { + match self.get_after(key) { + Some(option) => { + let v: T = option.parse().unwrap_or(default); + if v >= min && v <= max { + v.to_string() + } else { + default.to_string() + } + } + None => default.to_string(), + } + } + + fn get_after(&self, k: &str) -> Option { + get_or( + &OVERWRITE_DISPLAY_SETTINGS, + &self.options, + &DEFAULT_DISPLAY_SETTINGS, + k, + ) + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AbPeer { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub id: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub hash: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub username: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub hostname: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub platform: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub alias: String, + #[serde(default, deserialize_with = "deserialize_vec_string")] + pub tags: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct AbEntry { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub guid: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub name: String, + #[serde(default, deserialize_with = "deserialize_vec_abpeer")] + pub peers: Vec, + #[serde(default, deserialize_with = "deserialize_vec_string")] + pub tags: Vec, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub tag_colors: String, +} + +impl AbEntry { + pub fn personal(&self) -> bool { + self.name == "My address book" || self.name == "Legacy address book" + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct Ab { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub access_token: String, + #[serde(default, deserialize_with = "deserialize_vec_abentry")] + pub ab_entries: Vec, +} + +impl Ab { + fn path() -> PathBuf { + let filename = format!("{}_ab", APP_NAME.read().unwrap().clone()); + Config::path(filename) + } + + pub fn store(json: String) { + if let Ok(mut file) = std::fs::File::create(Self::path()) { + let data = compress(json.as_bytes()); + let max_len = 64 * 1024 * 1024; + if data.len() > max_len { + // maxlen of function decompress + log::error!("ab data too large, {} > {}", data.len(), max_len); + return; + } + if let Ok(data) = symmetric_crypt(&data, true) { + file.write_all(&data).ok(); + } + }; + } + + pub fn load() -> Ab { + if let Ok(mut file) = std::fs::File::open(Self::path()) { + let mut data = vec![]; + if file.read_to_end(&mut data).is_ok() { + if let Ok(data) = symmetric_crypt(&data, false) { + let data = decompress(&data); + if let Ok(ab) = serde_json::from_str::(&String::from_utf8_lossy(&data)) { + return ab; + } + } + } + }; + Self::remove(); + Ab::default() + } + + pub fn remove() { + std::fs::remove_file(Self::path()).ok(); + } +} + +// use default value when field type is wrong +macro_rules! deserialize_default { + ($func_name:ident, $return_type:ty) => { + fn $func_name<'de, D>(deserializer: D) -> Result<$return_type, D::Error> + where + D: de::Deserializer<'de>, + { + Ok(de::Deserialize::deserialize(deserializer).unwrap_or_default()) + } + }; +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct GroupPeer { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub id: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub username: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub hostname: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub platform: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub login_name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct GroupUser { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub name: String, + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub display_name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct DeviceGroup { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct Group { + #[serde( + default, + deserialize_with = "deserialize_string", + skip_serializing_if = "String::is_empty" + )] + pub access_token: String, + #[serde(default, deserialize_with = "deserialize_vec_groupuser")] + pub users: Vec, + #[serde(default, deserialize_with = "deserialize_vec_grouppeer")] + pub peers: Vec, + #[serde(default, deserialize_with = "deserialize_vec_devicegroup")] + pub device_groups: Vec, +} + +impl Group { + fn path() -> PathBuf { + let filename = format!("{}_group", APP_NAME.read().unwrap().clone()); + Config::path(filename) + } + + pub fn store(json: String) { + if let Ok(mut file) = std::fs::File::create(Self::path()) { + let data = compress(json.as_bytes()); + let max_len = 64 * 1024 * 1024; + if data.len() > max_len { + // maxlen of function decompress + return; + } + if let Ok(data) = symmetric_crypt(&data, true) { + file.write_all(&data).ok(); + } + }; + } + + pub fn load() -> Self { + if let Ok(mut file) = std::fs::File::open(Self::path()) { + let mut data = vec![]; + if file.read_to_end(&mut data).is_ok() { + if let Ok(data) = symmetric_crypt(&data, false) { + let data = decompress(&data); + if let Ok(group) = serde_json::from_str::(&String::from_utf8_lossy(&data)) + { + return group; + } + } + } + }; + Self::remove(); + Self::default() + } + + pub fn remove() { + std::fs::remove_file(Self::path()).ok(); + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct TrustedDevice { + pub hwid: Bytes, + pub time: i64, + pub id: String, + pub name: String, + pub platform: String, +} + +impl TrustedDevice { + pub fn outdate(&self) -> bool { + const DAYS_90: i64 = 90 * 24 * 60 * 60 * 1000; + self.time + DAYS_90 < crate::get_time() + } +} + +deserialize_default!(deserialize_string, String); +deserialize_default!(deserialize_bool, bool); +deserialize_default!(deserialize_i32, i32); +deserialize_default!(deserialize_vec_u8, Vec); +deserialize_default!(deserialize_vec_string, Vec); +deserialize_default!(deserialize_vec_i32_string_i32, Vec<(i32, String, i32)>); +deserialize_default!(deserialize_vec_discoverypeer, Vec); +deserialize_default!(deserialize_vec_abpeer, Vec); +deserialize_default!(deserialize_vec_abentry, Vec); +deserialize_default!(deserialize_vec_groupuser, Vec); +deserialize_default!(deserialize_vec_grouppeer, Vec); +deserialize_default!(deserialize_vec_devicegroup, Vec); +deserialize_default!(deserialize_keypair, KeyPair); +deserialize_default!(deserialize_size, Size); +deserialize_default!(deserialize_hashmap_string_string, HashMap); +deserialize_default!(deserialize_hashmap_string_bool, HashMap); +deserialize_default!(deserialize_hashmap_resolutions, HashMap); + +#[inline] +fn get_or( + a: &RwLock>, + b: &HashMap, + c: &RwLock>, + k: &str, +) -> Option { + a.read() + .unwrap() + .get(k) + .or(b.get(k)) + .or(c.read().unwrap().get(k)) + .cloned() +} + +#[inline] +fn is_option_can_save( + overwrite: &RwLock>, + k: &str, + defaults: &RwLock>, + v: &str, +) -> bool { + if overwrite.read().unwrap().contains_key(k) + || defaults.read().unwrap().get(k).map_or(false, |x| x == v) + { + return false; + } + true +} + +#[inline] +pub fn is_incoming_only() -> bool { + HARD_SETTINGS + .read() + .unwrap() + .get("conn-type") + .map_or(false, |x| x == ("incoming")) +} + +#[inline] +pub fn is_outgoing_only() -> bool { + HARD_SETTINGS + .read() + .unwrap() + .get("conn-type") + .map_or(false, |x| x == ("outgoing")) +} + +#[inline] +fn is_some_hard_opton(name: &str) -> bool { + HARD_SETTINGS + .read() + .unwrap() + .get(name) + .map_or(false, |x| x == ("Y")) +} + +#[inline] +pub fn is_disable_tcp_listen() -> bool { + is_some_hard_opton("disable-tcp-listen") +} + +#[inline] +pub fn is_disable_settings() -> bool { + is_some_hard_opton("disable-settings") +} + +#[inline] +pub fn is_disable_ab() -> bool { + is_some_hard_opton("disable-ab") +} + +#[inline] +pub fn is_disable_account() -> bool { + is_some_hard_opton("disable-account") +} + +#[inline] +pub fn is_disable_installation() -> bool { + is_some_hard_opton("disable-installation") +} + +// This function must be kept the same as the one in flutter and sciter code. +// flutter: flutter/lib/common.dart -> option2bool() +// sciter: Does not have the function, but it should be kept the same. +pub fn option2bool(option: &str, value: &str) -> bool { + if option.starts_with("enable-") { + value != "N" + } else if option.starts_with("allow-") + || option == "stop-service" + || option == keys::OPTION_DIRECT_SERVER + || option == "force-always-relay" + { + value == "Y" + } else { + value != "N" + } +} + +pub fn use_ws() -> bool { + let option = keys::OPTION_ALLOW_WEBSOCKET; + option2bool(option, &Config::get_option(option)) +} + +pub fn allow_insecure_tls_fallback() -> bool { + let option = keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK; + option2bool(option, &Config::get_option(option)) +} + +pub mod keys { + pub const OPTION_VIEW_ONLY: &str = "view_only"; + pub const OPTION_SHOW_MONITORS_TOOLBAR: &str = "show_monitors_toolbar"; + pub const OPTION_COLLAPSE_TOOLBAR: &str = "collapse_toolbar"; + pub const OPTION_SHOW_REMOTE_CURSOR: &str = "show_remote_cursor"; + pub const OPTION_FOLLOW_REMOTE_CURSOR: &str = "follow_remote_cursor"; + pub const OPTION_FOLLOW_REMOTE_WINDOW: &str = "follow_remote_window"; + pub const OPTION_ZOOM_CURSOR: &str = "zoom-cursor"; + pub const OPTION_SHOW_QUALITY_MONITOR: &str = "show_quality_monitor"; + pub const OPTION_DISABLE_AUDIO: &str = "disable_audio"; + pub const OPTION_ENABLE_REMOTE_PRINTER: &str = "enable-remote-printer"; + pub const OPTION_ENABLE_FILE_COPY_PASTE: &str = "enable-file-copy-paste"; + pub const OPTION_DISABLE_CLIPBOARD: &str = "disable_clipboard"; + pub const OPTION_LOCK_AFTER_SESSION_END: &str = "lock_after_session_end"; + pub const OPTION_PRIVACY_MODE: &str = "privacy_mode"; + pub const OPTION_TOUCH_MODE: &str = "touch-mode"; + pub const OPTION_I444: &str = "i444"; + pub const OPTION_REVERSE_MOUSE_WHEEL: &str = "reverse_mouse_wheel"; + pub const OPTION_SWAP_LEFT_RIGHT_MOUSE: &str = "swap-left-right-mouse"; + pub const OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS: &str = "displays_as_individual_windows"; + pub const OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION: &str = + "use_all_my_displays_for_the_remote_session"; + pub const OPTION_VIEW_STYLE: &str = "view_style"; + pub const OPTION_SCROLL_STYLE: &str = "scroll_style"; + pub const OPTION_EDGE_SCROLL_EDGE_THICKNESS: &str = "edge-scroll-edge-thickness"; + pub const OPTION_IMAGE_QUALITY: &str = "image_quality"; + pub const OPTION_CUSTOM_IMAGE_QUALITY: &str = "custom_image_quality"; + pub const OPTION_CUSTOM_FPS: &str = "custom-fps"; + pub const OPTION_CODEC_PREFERENCE: &str = "codec-preference"; + pub const OPTION_SYNC_INIT_CLIPBOARD: &str = "sync-init-clipboard"; + pub const OPTION_THEME: &str = "theme"; + pub const OPTION_LANGUAGE: &str = "lang"; + pub const OPTION_REMOTE_MENUBAR_DRAG_LEFT: &str = "remote-menubar-drag-left"; + pub const OPTION_REMOTE_MENUBAR_DRAG_RIGHT: &str = "remote-menubar-drag-right"; + pub const OPTION_HIDE_AB_TAGS_PANEL: &str = "hideAbTagsPanel"; + pub const OPTION_ENABLE_CONFIRM_CLOSING_TABS: &str = "enable-confirm-closing-tabs"; + pub const OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS: &str = + "enable-open-new-connections-in-tabs"; + pub const OPTION_TEXTURE_RENDER: &str = "use-texture-render"; + pub const OPTION_ALLOW_D3D_RENDER: &str = "allow-d3d-render"; + pub const OPTION_ENABLE_CHECK_UPDATE: &str = "enable-check-update"; + pub const OPTION_ALLOW_AUTO_UPDATE: &str = "allow-auto-update"; + pub const OPTION_SYNC_AB_WITH_RECENT_SESSIONS: &str = "sync-ab-with-recent-sessions"; + pub const OPTION_SYNC_AB_TAGS: &str = "sync-ab-tags"; + pub const OPTION_FILTER_AB_BY_INTERSECTION: &str = "filter-ab-by-intersection"; + pub const OPTION_ACCESS_MODE: &str = "access-mode"; + pub const OPTION_ENABLE_KEYBOARD: &str = "enable-keyboard"; + pub const OPTION_ENABLE_CLIPBOARD: &str = "enable-clipboard"; + pub const OPTION_ENABLE_FILE_TRANSFER: &str = "enable-file-transfer"; + pub const OPTION_ENABLE_CAMERA: &str = "enable-camera"; + pub const OPTION_ENABLE_TERMINAL: &str = "enable-terminal"; + pub const OPTION_TERMINAL_PERSISTENT: &str = "terminal-persistent"; + pub const OPTION_ENABLE_AUDIO: &str = "enable-audio"; + pub const OPTION_ENABLE_TUNNEL: &str = "enable-tunnel"; + pub const OPTION_ENABLE_REMOTE_RESTART: &str = "enable-remote-restart"; + pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session"; + pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input"; + pub const OPTION_ENABLE_PRIVACY_MODE: &str = "enable-privacy-mode"; + pub const OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW: &str = + "enable-perm-change-in-accept-window"; + pub const OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION: &str = "allow-remote-config-modification"; + pub const OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD: &str = "allow-numeric-one-time-password"; + pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery"; + pub const OPTION_DIRECT_SERVER: &str = "direct-server"; + pub const OPTION_DIRECT_ACCESS_PORT: &str = "direct-access-port"; + pub const OPTION_WHITELIST: &str = "whitelist"; + pub const OPTION_ALLOW_AUTO_DISCONNECT: &str = "allow-auto-disconnect"; + pub const OPTION_AUTO_DISCONNECT_TIMEOUT: &str = "auto-disconnect-timeout"; + pub const OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN: &str = "allow-only-conn-window-open"; + pub const OPTION_ALLOW_AUTO_RECORD_INCOMING: &str = "allow-auto-record-incoming"; + pub const OPTION_ALLOW_AUTO_RECORD_OUTGOING: &str = "allow-auto-record-outgoing"; + pub const OPTION_VIDEO_SAVE_DIRECTORY: &str = "video-save-directory"; + pub const OPTION_ENABLE_ABR: &str = "enable-abr"; + pub const OPTION_ALLOW_REMOVE_WALLPAPER: &str = "allow-remove-wallpaper"; + pub const OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER: &str = "allow-always-software-render"; + pub const OPTION_ALLOW_LINUX_HEADLESS: &str = "allow-linux-headless"; + pub const OPTION_ENABLE_HWCODEC: &str = "enable-hwcodec"; + pub const OPTION_APPROVE_MODE: &str = "approve-mode"; + pub const OPTION_VERIFICATION_METHOD: &str = "verification-method"; + pub const OPTION_TEMPORARY_PASSWORD_LENGTH: &str = "temporary-password-length"; + pub const OPTION_CUSTOM_RENDEZVOUS_SERVER: &str = "custom-rendezvous-server"; + pub const OPTION_API_SERVER: &str = "api-server"; + pub const OPTION_KEY: &str = "key"; + pub const OPTION_ALLOW_WEBSOCKET: &str = "allow-websocket"; + pub const OPTION_PRESET_ADDRESS_BOOK_NAME: &str = "preset-address-book-name"; + pub const OPTION_PRESET_ADDRESS_BOOK_TAG: &str = "preset-address-book-tag"; + pub const OPTION_PRESET_ADDRESS_BOOK_ALIAS: &str = "preset-address-book-alias"; + pub const OPTION_PRESET_ADDRESS_BOOK_PASSWORD: &str = "preset-address-book-password"; + pub const OPTION_PRESET_ADDRESS_BOOK_NOTE: &str = "preset-address-book-note"; + pub const OPTION_PRESET_DEVICE_USERNAME: &str = "preset-device-username"; + pub const OPTION_PRESET_DEVICE_NAME: &str = "preset-device-name"; + pub const OPTION_PRESET_NOTE: &str = "preset-note"; + pub const OPTION_ENABLE_DIRECTX_CAPTURE: &str = "enable-directx-capture"; + pub const OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE: &str = + "enable-android-software-encoding-half-scale"; + pub const OPTION_ENABLE_TRUSTED_DEVICES: &str = "enable-trusted-devices"; + pub const OPTION_AV1_TEST: &str = "av1-test"; + pub const OPTION_TRACKPAD_SPEED: &str = "trackpad-speed"; + pub const OPTION_REGISTER_DEVICE: &str = "register-device"; + pub const OPTION_RELAY_SERVER: &str = "relay-server"; + pub const OPTION_ICE_SERVERS: &str = "ice-servers"; + /// Maximum number of files allowed during a single file transfer request. + /// + /// Key: `file-transfer-max-files`. + /// Unit: number of files (not bytes). + /// + /// Behaviour: + /// - If set to a positive integer N, at most N files are allowed. + /// - If set to 0, a safe built-in default is used (see DEFAULT_MAX_VALIDATED_FILES). + /// - If unset, negative, or non-integer, no explicit limit is enforced for backward compatibility. + pub const OPTION_FILE_TRANSFER_MAX_FILES: &str = "file-transfer-max-files"; + pub const OPTION_DISABLE_UDP: &str = "disable-udp"; + pub const OPTION_ALLOW_INSECURE_TLS_FALLBACK: &str = "allow-insecure-tls-fallback"; + pub const OPTION_SHOW_VIRTUAL_MOUSE: &str = "show-virtual-mouse"; + // joystick is the virtual mouse. + // So `OPTION_SHOW_VIRTUAL_MOUSE` should also be set if `OPTION_SHOW_VIRTUAL_JOYSTICK` is set. + pub const OPTION_SHOW_VIRTUAL_JOYSTICK: &str = "show-virtual-joystick"; + pub const OPTION_ENABLE_FLUTTER_HTTP_ON_RUST: &str = "enable-flutter-http-on-rust"; + pub const OPTION_ALLOW_ASK_FOR_NOTE: &str = "allow-ask-for-note"; + + // built-in options + pub const OPTION_DISPLAY_NAME: &str = "display-name"; + pub const OPTION_AVATAR: &str = "avatar"; + pub const OPTION_PRESET_DEVICE_GROUP_NAME: &str = "preset-device-group-name"; + pub const OPTION_PRESET_USERNAME: &str = "preset-user-name"; + pub const OPTION_PRESET_STRATEGY_NAME: &str = "preset-strategy-name"; + pub const OPTION_REMOVE_PRESET_PASSWORD_WARNING: &str = "remove-preset-password-warning"; + pub const OPTION_HIDE_SECURITY_SETTINGS: &str = "hide-security-settings"; + pub const OPTION_HIDE_NETWORK_SETTINGS: &str = "hide-network-settings"; + pub const OPTION_HIDE_SERVER_SETTINGS: &str = "hide-server-settings"; + pub const OPTION_HIDE_PROXY_SETTINGS: &str = "hide-proxy-settings"; + pub const OPTION_HIDE_REMOTE_PRINTER_SETTINGS: &str = "hide-remote-printer-settings"; + pub const OPTION_HIDE_WEBSOCKET_SETTINGS: &str = "hide-websocket-settings"; + pub const OPTION_HIDE_STOP_SERVICE: &str = "hide-stop-service"; + pub const OPTION_ALLOW_COMMAND_LINE_SETTINGS_WHEN_SETTINGS_DISABLED: &str = + "allow-command-line-settings-when-settings-disabled"; + + // Connection punch-through options + pub const OPTION_ENABLE_UDP_PUNCH: &str = "enable-udp-punch"; + pub const OPTION_ENABLE_IPV6_PUNCH: &str = "enable-ipv6-punch"; + pub const OPTION_HIDE_USERNAME_ON_CARD: &str = "hide-username-on-card"; + pub const OPTION_HIDE_HELP_CARDS: &str = "hide-help-cards"; + pub const OPTION_DEFAULT_CONNECT_PASSWORD: &str = "default-connect-password"; + pub const OPTION_HIDE_TRAY: &str = "hide-tray"; + pub const OPTION_ONE_WAY_CLIPBOARD_REDIRECTION: &str = "one-way-clipboard-redirection"; + pub const OPTION_ALLOW_LOGON_SCREEN_PASSWORD: &str = "allow-logon-screen-password"; + pub const OPTION_ALLOW_DEEP_LINK_PASSWORD: &str = "allow-deep-link-password"; + pub const OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS: &str = "allow-deep-link-server-settings"; + pub const OPTION_ONE_WAY_FILE_TRANSFER: &str = "one-way-file-transfer"; + pub const OPTION_ALLOW_HTTPS_21114: &str = "allow-https-21114"; + pub const OPTION_USE_RAW_TCP_FOR_API: &str = "use-raw-tcp-for-api"; + pub const OPTION_ALLOW_HOSTNAME_AS_ID: &str = "allow-hostname-as-id"; + pub const OPTION_HIDE_POWERED_BY_ME: &str = "hide-powered-by-me"; + pub const OPTION_MAIN_WINDOW_ALWAYS_ON_TOP: &str = "main-window-always-on-top"; + pub const OPTION_DISABLE_CHANGE_PERMANENT_PASSWORD: &str = "disable-change-permanent-password"; + pub const OPTION_DISABLE_CHANGE_ID: &str = "disable-change-id"; + pub const OPTION_DISABLE_UNLOCK_PIN: &str = "disable-unlock-pin"; + + // flutter local options + pub const OPTION_FLUTTER_REMOTE_MENUBAR_STATE: &str = "remoteMenubarState"; + pub const OPTION_FLUTTER_PEER_SORTING: &str = "peer-sorting"; + pub const OPTION_FLUTTER_PEER_TAB_INDEX: &str = "peer-tab-index"; + pub const OPTION_FLUTTER_PEER_TAB_ORDER: &str = "peer-tab-order"; + pub const OPTION_FLUTTER_PEER_TAB_VISIBLE: &str = "peer-tab-visible"; + pub const OPTION_FLUTTER_PEER_CARD_UI_TYLE: &str = "peer-card-ui-type"; + pub const OPTION_FLUTTER_CURRENT_AB_NAME: &str = "current-ab-name"; + pub const OPTION_ALLOW_REMOTE_CM_MODIFICATION: &str = "allow-remote-cm-modification"; + + pub const OPTION_PRINTER_INCOMING_JOB_ACTION: &str = "printer-incomming-job-action"; + pub const OPTION_PRINTER_ALLOW_AUTO_PRINT: &str = "allow-printer-auto-print"; + pub const OPTION_PRINTER_SELECTED_NAME: &str = "printer-selected-name"; + + // android floating window options + pub const OPTION_DISABLE_FLOATING_WINDOW: &str = "disable-floating-window"; + pub const OPTION_FLOATING_WINDOW_SIZE: &str = "floating-window-size"; + pub const OPTION_FLOATING_WINDOW_UNTOUCHABLE: &str = "floating-window-untouchable"; + pub const OPTION_FLOATING_WINDOW_TRANSPARENCY: &str = "floating-window-transparency"; + pub const OPTION_FLOATING_WINDOW_SVG: &str = "floating-window-svg"; + + // android keep screen on + pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on"; + + // Server-side: keep host system awake during incoming sessions (Security setting) + pub const OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS: &str = + "keep-awake-during-incoming-sessions"; + + // Client-side: keep client system awake during outgoing sessions (General setting) + pub const OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS: &str = + "keep-awake-during-outgoing-sessions"; + + pub const OPTION_DISABLE_GROUP_PANEL: &str = "disable-group-panel"; + pub const OPTION_DISABLE_DISCOVERY_PANEL: &str = "disable-discovery-panel"; + pub const OPTION_PRE_ELEVATE_SERVICE: &str = "pre-elevate-service"; + + // proxy settings + // The following options are not real keys, they are just used for custom client advanced settings. + // The real keys are in Config2::socks. + pub const OPTION_PROXY_URL: &str = "proxy-url"; + pub const OPTION_PROXY_USERNAME: &str = "proxy-username"; + pub const OPTION_PROXY_PASSWORD: &str = "proxy-password"; + + // DEFAULT_DISPLAY_SETTINGS, OVERWRITE_DISPLAY_SETTINGS + pub const KEYS_DISPLAY_SETTINGS: &[&str] = &[ + OPTION_VIEW_ONLY, + OPTION_SHOW_MONITORS_TOOLBAR, + OPTION_COLLAPSE_TOOLBAR, + OPTION_SHOW_REMOTE_CURSOR, + OPTION_FOLLOW_REMOTE_CURSOR, + OPTION_FOLLOW_REMOTE_WINDOW, + OPTION_ZOOM_CURSOR, + OPTION_SHOW_QUALITY_MONITOR, + OPTION_DISABLE_AUDIO, + OPTION_ENABLE_FILE_COPY_PASTE, + OPTION_DISABLE_CLIPBOARD, + OPTION_LOCK_AFTER_SESSION_END, + OPTION_PRIVACY_MODE, + OPTION_TOUCH_MODE, + OPTION_I444, + OPTION_REVERSE_MOUSE_WHEEL, + OPTION_SWAP_LEFT_RIGHT_MOUSE, + OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS, + OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION, + OPTION_VIEW_STYLE, + OPTION_TERMINAL_PERSISTENT, + OPTION_SCROLL_STYLE, + OPTION_EDGE_SCROLL_EDGE_THICKNESS, + OPTION_IMAGE_QUALITY, + OPTION_CUSTOM_IMAGE_QUALITY, + OPTION_CUSTOM_FPS, + OPTION_CODEC_PREFERENCE, + OPTION_SYNC_INIT_CLIPBOARD, + OPTION_TRACKPAD_SPEED, + ]; + // DEFAULT_LOCAL_SETTINGS, OVERWRITE_LOCAL_SETTINGS + pub const KEYS_LOCAL_SETTINGS: &[&str] = &[ + OPTION_THEME, + OPTION_LANGUAGE, + OPTION_ENABLE_CONFIRM_CLOSING_TABS, + OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS, + OPTION_TEXTURE_RENDER, + OPTION_ALLOW_D3D_RENDER, + OPTION_SYNC_AB_WITH_RECENT_SESSIONS, + OPTION_SYNC_AB_TAGS, + OPTION_FILTER_AB_BY_INTERSECTION, + OPTION_REMOTE_MENUBAR_DRAG_LEFT, + OPTION_REMOTE_MENUBAR_DRAG_RIGHT, + OPTION_HIDE_AB_TAGS_PANEL, + OPTION_FLUTTER_REMOTE_MENUBAR_STATE, + OPTION_FLUTTER_PEER_SORTING, + OPTION_FLUTTER_PEER_TAB_INDEX, + OPTION_FLUTTER_PEER_TAB_ORDER, + OPTION_FLUTTER_PEER_TAB_VISIBLE, + OPTION_FLUTTER_PEER_CARD_UI_TYLE, + OPTION_FLUTTER_CURRENT_AB_NAME, + OPTION_DISABLE_FLOATING_WINDOW, + OPTION_FLOATING_WINDOW_SIZE, + OPTION_FLOATING_WINDOW_UNTOUCHABLE, + OPTION_FLOATING_WINDOW_TRANSPARENCY, + OPTION_FLOATING_WINDOW_SVG, + OPTION_KEEP_SCREEN_ON, + // Client-side: keep client system awake during outgoing sessions (General setting) + OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS, + OPTION_DISABLE_GROUP_PANEL, + OPTION_DISABLE_DISCOVERY_PANEL, + OPTION_PRE_ELEVATE_SERVICE, + OPTION_ALLOW_REMOTE_CM_MODIFICATION, + OPTION_ALLOW_AUTO_RECORD_OUTGOING, + OPTION_VIDEO_SAVE_DIRECTORY, + OPTION_ENABLE_UDP_PUNCH, + OPTION_ENABLE_IPV6_PUNCH, + OPTION_TOUCH_MODE, + OPTION_SHOW_VIRTUAL_MOUSE, + OPTION_SHOW_VIRTUAL_JOYSTICK, + OPTION_ENABLE_FLUTTER_HTTP_ON_RUST, + OPTION_ALLOW_ASK_FOR_NOTE, + ]; + // DEFAULT_SETTINGS, OVERWRITE_SETTINGS + pub const KEYS_SETTINGS: &[&str] = &[ + OPTION_ACCESS_MODE, + OPTION_ENABLE_KEYBOARD, + OPTION_ENABLE_CLIPBOARD, + OPTION_ENABLE_FILE_TRANSFER, + OPTION_ENABLE_CAMERA, + OPTION_ENABLE_TERMINAL, + OPTION_ENABLE_REMOTE_PRINTER, + OPTION_ENABLE_AUDIO, + OPTION_ENABLE_TUNNEL, + OPTION_ENABLE_REMOTE_RESTART, + OPTION_ENABLE_RECORD_SESSION, + OPTION_ENABLE_BLOCK_INPUT, + OPTION_ENABLE_PRIVACY_MODE, + OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION, + OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD, + OPTION_ENABLE_LAN_DISCOVERY, + OPTION_DIRECT_SERVER, + OPTION_DIRECT_ACCESS_PORT, + OPTION_WHITELIST, + OPTION_ALLOW_AUTO_DISCONNECT, + OPTION_AUTO_DISCONNECT_TIMEOUT, + OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN, + OPTION_ALLOW_AUTO_RECORD_INCOMING, + OPTION_ENABLE_ABR, + OPTION_ALLOW_REMOVE_WALLPAPER, + OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER, + OPTION_ALLOW_LINUX_HEADLESS, + OPTION_ENABLE_HWCODEC, + OPTION_APPROVE_MODE, + OPTION_VERIFICATION_METHOD, + OPTION_TEMPORARY_PASSWORD_LENGTH, + OPTION_PROXY_URL, + OPTION_PROXY_USERNAME, + OPTION_PROXY_PASSWORD, + OPTION_CUSTOM_RENDEZVOUS_SERVER, + OPTION_API_SERVER, + OPTION_KEY, + OPTION_ALLOW_WEBSOCKET, + OPTION_PRESET_ADDRESS_BOOK_NAME, + OPTION_PRESET_ADDRESS_BOOK_TAG, + OPTION_PRESET_ADDRESS_BOOK_ALIAS, + OPTION_PRESET_ADDRESS_BOOK_PASSWORD, + OPTION_PRESET_ADDRESS_BOOK_NOTE, + OPTION_PRESET_DEVICE_USERNAME, + OPTION_PRESET_DEVICE_NAME, + OPTION_PRESET_NOTE, + OPTION_ENABLE_DIRECTX_CAPTURE, + OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE, + OPTION_ENABLE_TRUSTED_DEVICES, + OPTION_RELAY_SERVER, + OPTION_ICE_SERVERS, + OPTION_DISABLE_UDP, + OPTION_ALLOW_INSECURE_TLS_FALLBACK, + OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS, + OPTION_ALLOW_AUTO_UPDATE, + ]; + + // BUILDIN_SETTINGS + pub const KEYS_BUILDIN_SETTINGS: &[&str] = &[ + OPTION_DISPLAY_NAME, + OPTION_AVATAR, + OPTION_PRESET_DEVICE_GROUP_NAME, + OPTION_PRESET_USERNAME, + OPTION_PRESET_STRATEGY_NAME, + OPTION_REMOVE_PRESET_PASSWORD_WARNING, + OPTION_HIDE_SECURITY_SETTINGS, + OPTION_HIDE_NETWORK_SETTINGS, + OPTION_HIDE_SERVER_SETTINGS, + OPTION_HIDE_PROXY_SETTINGS, + OPTION_HIDE_REMOTE_PRINTER_SETTINGS, + OPTION_HIDE_WEBSOCKET_SETTINGS, + OPTION_HIDE_STOP_SERVICE, + OPTION_HIDE_USERNAME_ON_CARD, + OPTION_HIDE_HELP_CARDS, + OPTION_DEFAULT_CONNECT_PASSWORD, + OPTION_HIDE_TRAY, + OPTION_ONE_WAY_CLIPBOARD_REDIRECTION, + OPTION_ALLOW_LOGON_SCREEN_PASSWORD, + OPTION_ALLOW_DEEP_LINK_PASSWORD, + OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS, + OPTION_ONE_WAY_FILE_TRANSFER, + OPTION_ALLOW_HTTPS_21114, + OPTION_ALLOW_HOSTNAME_AS_ID, + OPTION_REGISTER_DEVICE, + OPTION_HIDE_POWERED_BY_ME, + OPTION_MAIN_WINDOW_ALWAYS_ON_TOP, + OPTION_FILE_TRANSFER_MAX_FILES, + OPTION_DISABLE_CHANGE_PERMANENT_PASSWORD, + OPTION_DISABLE_CHANGE_ID, + OPTION_DISABLE_UNLOCK_PIN, + OPTION_USE_RAW_TCP_FOR_API, + OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + OPTION_ALLOW_COMMAND_LINE_SETTINGS_WHEN_SETTINGS_DISABLED, + ]; +} + +pub fn common_load< + T: serde::Serialize + serde::de::DeserializeOwned + Default + std::fmt::Debug, +>( + suffix: &str, +) -> T { + Config::load_::(suffix) +} + +pub fn common_store(config: &T, suffix: &str) { + Config::store_(config, suffix); +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct Status { + #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] + values: HashMap, +} + +impl Status { + fn load() -> Status { + Config::load_::("_status") + } + + fn store(&self) { + Config::store_(self, "_status"); + } + + pub fn get(k: &str) -> String { + STATUS + .read() + .unwrap() + .values + .get(k) + .cloned() + .unwrap_or_default() + } + + pub fn set(k: &str, v: String) { + if Self::get(k) == v { + return; + } + + let mut st = STATUS.write().unwrap(); + st.values.insert(k.to_owned(), v); + st.store(); + } +} + +#[cfg(test)] +mod tests { + use super::{permanent_password::PERMANENT_PASSWORD_ENC_VERSION, *}; + + static CONFIG_STATE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + struct ConfigStateTestGuard { + original_config: Config, + original_hard_settings: HashMap, + } + + struct ConfigFileRestoreGuard { + path: PathBuf, + original_content: Option>, + } + + impl ConfigStateTestGuard { + fn new(config: Config, hard_settings: HashMap) -> Self { + let original_config = Config::get(); + let original_hard_settings = HARD_SETTINGS.read().unwrap().clone(); + *CONFIG.write().unwrap() = config; + *HARD_SETTINGS.write().unwrap() = hard_settings; + Self { + original_config, + original_hard_settings, + } + } + } + + impl Drop for ConfigStateTestGuard { + fn drop(&mut self) { + *CONFIG.write().unwrap() = self.original_config.clone(); + *HARD_SETTINGS.write().unwrap() = self.original_hard_settings.clone(); + } + } + + impl ConfigFileRestoreGuard { + fn new(path: PathBuf) -> Self { + let original_content = fs::read(&path).ok(); + Self { + path, + original_content, + } + } + } + + impl Drop for ConfigFileRestoreGuard { + fn drop(&mut self) { + if let Some(content) = &self.original_content { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&self.path, content).ok(); + } else { + fs::remove_file(&self.path).ok(); + } + } + } + + fn with_config_and_hard_settings( + config: Config, + hard_settings: HashMap, + test: impl FnOnce() -> R, + ) -> R { + let _guard = CONFIG_STATE_TEST_LOCK.lock().unwrap(); + let _state_guard = ConfigStateTestGuard::new(config, hard_settings); + test() + } + + #[test] + fn test_serialize() { + let cfg: Config = Default::default(); + let res = toml::to_string_pretty(&cfg); + assert!(res.is_ok()); + let cfg: PeerConfig = Default::default(); + let res = toml::to_string_pretty(&cfg); + assert!(res.is_ok()); + } + + #[test] + fn test_hbbs_00_hashed_preset_password_storage_matches_plain_with_salt() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = "00".to_owned() + &base64::encode(h1, base64::Variant::Original); + let hard_settings = HashMap::from([ + ("password".to_owned(), storage), + ("salt".to_owned(), salt.to_owned()), + ]); + + with_config_and_hard_settings(Config::default(), hard_settings, || { + assert!(Config::has_permanent_password()); + assert!(Config::has_usable_preset_password()); + assert!(Config::is_using_preset_password()); + assert_eq!(Config::get_effective_permanent_password_salt(), salt); + }); + } + + #[test] + fn test_legacy_plain_preset_password_with_00_hash_shape_without_salt_keeps_old_behavior() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = "00".to_owned() + &base64::encode(h1, base64::Variant::Original); + let hard_settings = HashMap::from([("password".to_owned(), storage.clone())]); + + let mut config = Config::default(); + config.salt = "local1".to_owned(); + + with_config_and_hard_settings(config, hard_settings, || { + assert!(Config::has_permanent_password()); + assert!(Config::has_usable_preset_password()); + assert!(Config::is_using_preset_password()); + assert_eq!(Config::get_effective_permanent_password_salt(), "local1"); + }); + } + + #[test] + fn test_local_hashed_permanent_password_without_salt_is_not_reported_as_set() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let mut config = Config::default(); + config.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + with_config_and_hard_settings(config, HashMap::new(), || { + assert!(!Config::has_permanent_password()); + assert!(!Config::has_local_permanent_password()); + assert!(!Config::is_using_preset_password()); + }); + } + + #[test] + fn test_invalid_local_hashed_password_does_not_generate_effective_salt() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let mut config = Config::default(); + config.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + with_config_and_hard_settings(config, HashMap::new(), || { + assert_eq!(Config::get_effective_permanent_password_salt(), ""); + assert_eq!( + Config::get_local_permanent_password_storage_and_salt().1, + "" + ); + }); + } + + #[test] + fn test_legacy_plain_preset_password_uses_local_salt_for_challenge() { + let mut config = Config::default(); + config.salt = "local1".to_owned(); + let hard_settings = HashMap::from([("password".to_owned(), "legacy-password".to_owned())]); + + with_config_and_hard_settings(config, hard_settings, || { + assert_eq!(Config::get_effective_permanent_password_salt(), "local1"); + assert!(Config::has_permanent_password()); + assert!(Config::is_using_preset_password()); + }); + } + + #[test] + fn test_malformed_preset_password_with_salt_is_not_usable() { + for storage in ["01secret", "00not-a-valid-hash"] { + let hard_settings = HashMap::from([ + ("password".to_owned(), storage.to_owned()), + ("salt".to_owned(), "preset-salt".to_owned()), + ]); + + with_config_and_hard_settings(Config::default(), hard_settings, || { + assert_eq!(Config::get_effective_permanent_password_salt(), ""); + assert_eq!( + Config::get_local_permanent_password_storage_and_salt().1, + "" + ); + assert!(!Config::has_permanent_password()); + assert!(!Config::is_using_preset_password()); + }); + } + } + + #[test] + fn test_validate_or_decrypt_keeps_plaintext_permanent_password_unchanged() { + let mut cfg = Config::default(); + cfg.password = "p@ssw0rd".to_owned(); + cfg.salt = "".to_owned(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, "p@ssw0rd"); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_validate_or_decrypt_decrypts_00_permanent_password_without_forcing_store() { + let mut cfg = Config::default(); + let legacy_storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + cfg.password = legacy_storage; + cfg.salt = "".to_owned(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, "legacy-secret"); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_validate_or_decrypt_rejects_corrupted_00_permanent_password_storage() { + let legacy_storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + let mut invalid_payload = base64::decode( + &legacy_storage.as_bytes()[PASSWORD_ENC_VERSION.len()..], + base64::Variant::Original, + ) + .unwrap(); + *invalid_payload.last_mut().unwrap() ^= 1; + + let mut cfg = Config::default(); + cfg.password = PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + cfg.salt = "salt123".to_owned(); + + assert!(Config::validate_or_decrypt_permanent_password_storage(&mut cfg).is_err()); + } + + #[test] + fn test_validate_or_decrypt_rejects_encrypted_hashed_permanent_password_without_salt() { + let mut cfg = Config::default(); + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + let original_password = cfg.password.clone(); + + assert!(Config::validate_or_decrypt_permanent_password_storage(&mut cfg).is_err()); + assert_eq!(cfg.password, original_password); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_set_does_not_validate_or_decrypt_permanent_password_storage_in_memory() { + let mut cfg = Config::default(); + let invalid_payload = + crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); + let invalid_storage = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + cfg.password = invalid_storage.clone(); + cfg.id = "123456789".to_owned(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert_eq!(updated.password, invalid_storage); + assert!(updated.salt.is_empty()); + assert_eq!(updated.id, "123456789"); + }); + } + + #[test] + fn test_store_keeps_existing_enc_id_when_id_is_unchanged() { + let mut cfg = Config::default(); + cfg.id = "123456789".to_owned(); + cfg.enc_id = encrypt_str_or_original(&cfg.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + let original_enc_id = cfg.enc_id.clone(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + assert_eq!(Config::load().enc_id, original_enc_id); + assert_eq!(Config::get().id, "123456789"); + }); + } + + #[test] + fn test_store_rewrites_enc_id_when_id_changes() { + let original_id = "123456789"; + let updated_id = "987654321"; + let mut cfg = Config::default(); + cfg.id = updated_id.to_owned(); + let original_enc_id = + encrypt_str_or_original(original_id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + cfg.enc_id = original_enc_id.clone(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let stored = Config::load().enc_id; + let (stored_id, encrypted, _) = decrypt_str_or_original(&stored, PASSWORD_ENC_VERSION); + assert_ne!(stored, original_enc_id); + assert!(encrypted); + assert_eq!(stored_id, updated_id); + assert_eq!(Config::get().id, updated_id); + }); + } + + #[test] + fn test_config2_store_keeps_existing_unlock_pin_when_pin_is_unchanged() { + let _guard = CONFIG_STATE_TEST_LOCK.lock().unwrap(); + let _file_guard = ConfigFileRestoreGuard::new(Config::file_("2")); + let pin = "123456"; + let original_unlock_pin = + encrypt_str_or_original(pin, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + let mut cfg = Config2 { + unlock_pin: original_unlock_pin.clone(), + ..Default::default() + }; + Config::store_(&cfg, "2"); + let (unlock_pin, decrypted, _) = + decrypt_str_or_original(&cfg.unlock_pin, PASSWORD_ENC_VERSION); + assert!(decrypted); + cfg.unlock_pin = unlock_pin; + cfg.nat_type = 1; + + cfg.store(); + + let stored = Config::load_::("2"); + assert_eq!(stored.unlock_pin, original_unlock_pin); + } + + #[test] + fn test_set_does_not_convert_plaintext_permanent_password_to_storage_format_in_memory() { + let mut cfg = Config::default(); + cfg.password = "legacy-secret".to_owned(); + cfg.salt = "".to_owned(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert!(!updated.password.starts_with(PASSWORD_ENC_VERSION)); + assert_eq!(updated.password, "legacy-secret"); + assert!(updated.salt.is_empty()); + }); + } + + #[test] + fn test_set_keeps_plaintext_permanent_password_with_current_prefix_in_memory() { + let mut cfg = Config::default(); + cfg.password = "01legacy-secret".to_owned(); + cfg.salt = "".to_owned(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert_eq!(updated.password, "01legacy-secret"); + assert!(updated.salt.is_empty()); + }); + } + + #[test] + fn test_validate_or_decrypt_keeps_plaintext_permanent_password_with_current_prefix_and_long_base64( + ) { + let mut cfg = Config::default(); + let plain = "01".to_owned() + &base64::encode([42u8; 24], base64::Variant::Original); + cfg.password = plain.clone(); + cfg.salt = "".to_owned(); + + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, plain); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_permanent_password_sync_treats_same_encrypted_hash_as_unchanged() { + let mut cfg = Config::default(); + cfg.salt = "salt123".to_owned(); + let h1 = compute_permanent_password_h1("p@ssw0rd", &cfg.salt); + let encrypted_hash_storage = + encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.password = encrypted_hash_storage.clone(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); + + assert!(!Config::apply_permanent_password_storage_for_sync( + &mut cfg, + &encrypted_hash_storage, + "salt123" + ) + .unwrap()); + } + + #[test] + fn test_permanent_password_sync_stores_incoming_encrypted_hash_when_local_empty() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let incoming = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + let mut cfg = Config::default(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, &incoming, salt).unwrap() + ); + assert_eq!(cfg.password, incoming); + assert_eq!(cfg.salt, salt); + } + + #[test] + fn test_permanent_password_sync_rejects_non_current_storage_payloads() { + let invalid_payload = vec![42u8; sodiumoxide::crypto::secretbox::MACBYTES + 1]; + let invalid_storage = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + let encrypted_legacy_plaintext = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + + let encrypted = crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); + let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original); + for storage in [ + "00secret", + &encrypted_legacy_plaintext, + &invalid_storage, + "01invalid", + &encrypted_non_hash, + ] { + let mut cfg = Config::default(); + assert!(Config::apply_permanent_password_storage_for_sync( + &mut cfg, storage, "salt123" + ) + .is_err()); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + } + + let mut cfg = Config::default(); + cfg.password = invalid_storage.clone(); + cfg.salt = "salt123".to_owned(); + assert!(Config::apply_permanent_password_storage_for_sync( + &mut cfg, + &invalid_storage, + "salt123" + ) + .is_err()); + assert_eq!(cfg.password, invalid_storage); + assert_eq!(cfg.salt, "salt123"); + } + + #[test] + fn test_permanent_password_sync_rejects_non_empty_storage_without_salt() { + let mut cfg = Config::default(); + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let incoming = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, &incoming, "").is_err() + ); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_permanent_password_sync_empty_storage_clears_existing_password() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let mut cfg = Config::default(); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.salt = salt.to_owned(); + + assert!(Config::apply_permanent_password_storage_for_sync(&mut cfg, "", "").unwrap()); + assert!(cfg.password.is_empty()); + assert_eq!(cfg.salt, salt); + } + + #[test] + fn test_permanent_password_sync_empty_storage_uses_incoming_salt() { + let old_salt = "old-salt"; + let h1 = compute_permanent_password_h1("p@ssw0rd", old_salt); + let mut cfg = Config::default(); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.salt = old_salt.to_owned(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, "", "new-salt").unwrap() + ); + assert!(cfg.password.is_empty()); + assert_eq!(cfg.salt, "new-salt"); + } + + #[test] + fn test_overwrite_settings() { + DEFAULT_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "a".to_string()); + DEFAULT_SETTINGS + .write() + .unwrap() + .insert("c".to_string(), "a".to_string()); + CONFIG2 + .write() + .unwrap() + .options + .insert("a".to_string(), "b".to_string()); + CONFIG2 + .write() + .unwrap() + .options + .insert("b".to_string(), "b".to_string()); + OVERWRITE_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "c".to_string()); + OVERWRITE_SETTINGS + .write() + .unwrap() + .insert("c".to_string(), "f".to_string()); + OVERWRITE_SETTINGS + .write() + .unwrap() + .insert("d".to_string(), "c".to_string()); + let mut res: HashMap = Default::default(); + res.insert("b".to_owned(), "c".to_string()); + res.insert("d".to_owned(), "c".to_string()); + res.insert("c".to_owned(), "a".to_string()); + Config::purify_options(&mut res); + assert!(res.len() == 0); + res.insert("b".to_owned(), "c".to_string()); + res.insert("d".to_owned(), "c".to_string()); + res.insert("c".to_owned(), "a".to_string()); + res.insert("f".to_owned(), "a".to_string()); + Config::purify_options(&mut res); + assert!(res.len() == 1); + res.insert("b".to_owned(), "c".to_string()); + res.insert("d".to_owned(), "c".to_string()); + res.insert("c".to_owned(), "a".to_string()); + res.insert("f".to_owned(), "a".to_string()); + res.insert("e".to_owned(), "d".to_string()); + Config::purify_options(&mut res); + assert!(res.len() == 2); + res.insert("b".to_owned(), "c".to_string()); + res.insert("d".to_owned(), "c".to_string()); + res.insert("c".to_owned(), "a".to_string()); + res.insert("f".to_owned(), "a".to_string()); + res.insert("c".to_owned(), "d".to_string()); + res.insert("d".to_owned(), "cc".to_string()); + Config::purify_options(&mut res); + DEFAULT_SETTINGS + .write() + .unwrap() + .insert("f".to_string(), "c".to_string()); + Config::purify_options(&mut res); + assert!(res.len() == 2); + DEFAULT_SETTINGS + .write() + .unwrap() + .insert("f".to_string(), "a".to_string()); + Config::purify_options(&mut res); + assert!(res.len() == 1); + let res = Config::get_options(); + assert!(res["a"] == "b"); + assert!(res["c"] == "f"); + assert!(res["b"] == "c"); + assert!(res["d"] == "c"); + assert!(Config::get_option("a") == "b"); + assert!(Config::get_option("c") == "f"); + assert!(Config::get_option("b") == "c"); + assert!(Config::get_option("d") == "c"); + DEFAULT_SETTINGS.write().unwrap().clear(); + OVERWRITE_SETTINGS.write().unwrap().clear(); + CONFIG2.write().unwrap().options.clear(); + + DEFAULT_LOCAL_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "a".to_string()); + DEFAULT_LOCAL_SETTINGS + .write() + .unwrap() + .insert("c".to_string(), "a".to_string()); + LOCAL_CONFIG + .write() + .unwrap() + .options + .insert("a".to_string(), "b".to_string()); + LOCAL_CONFIG + .write() + .unwrap() + .options + .insert("b".to_string(), "b".to_string()); + OVERWRITE_LOCAL_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "c".to_string()); + OVERWRITE_LOCAL_SETTINGS + .write() + .unwrap() + .insert("d".to_string(), "c".to_string()); + assert!(LocalConfig::get_option("a") == "b"); + assert!(LocalConfig::get_option("c") == "a"); + assert!(LocalConfig::get_option("b") == "c"); + assert!(LocalConfig::get_option("d") == "c"); + DEFAULT_LOCAL_SETTINGS.write().unwrap().clear(); + OVERWRITE_LOCAL_SETTINGS.write().unwrap().clear(); + LOCAL_CONFIG.write().unwrap().options.clear(); + + DEFAULT_DISPLAY_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "a".to_string()); + DEFAULT_DISPLAY_SETTINGS + .write() + .unwrap() + .insert("c".to_string(), "a".to_string()); + USER_DEFAULT_CONFIG + .write() + .unwrap() + .0 + .options + .insert("a".to_string(), "b".to_string()); + USER_DEFAULT_CONFIG + .write() + .unwrap() + .0 + .options + .insert("b".to_string(), "b".to_string()); + OVERWRITE_DISPLAY_SETTINGS + .write() + .unwrap() + .insert("b".to_string(), "c".to_string()); + OVERWRITE_DISPLAY_SETTINGS + .write() + .unwrap() + .insert("d".to_string(), "c".to_string()); + assert!(UserDefaultConfig::read("a") == "b"); + assert!(UserDefaultConfig::read("c") == "a"); + assert!(UserDefaultConfig::read("b") == "c"); + assert!(UserDefaultConfig::read("d") == "c"); + DEFAULT_DISPLAY_SETTINGS.write().unwrap().clear(); + OVERWRITE_DISPLAY_SETTINGS.write().unwrap().clear(); + LOCAL_CONFIG.write().unwrap().options.clear(); + } + + #[test] + fn test_config_deserialize() { + let wrong_type_str = r#" + id = true + enc_id = [] + password = 1 + salt = "123456" + key_pair = {} + key_confirmed = "1" + keys_confirmed = 1 + "#; + let cfg = toml::from_str::(wrong_type_str); + assert_eq!( + cfg, + Ok(Config { + salt: "123456".to_string(), + ..Default::default() + }) + ); + + let wrong_field_str = r#" + hello = "world" + key_confirmed = true + "#; + let cfg = toml::from_str::(wrong_field_str); + assert_eq!( + cfg, + Ok(Config { + key_confirmed: true, + ..Default::default() + }) + ); + } + + #[test] + fn test_peer_config_deserialize() { + let default_peer_config = toml::from_str::("").unwrap(); + // test custom_resolution + { + let wrong_type_str = r#" + view_style = "adaptive" + scroll_style = "scrollbar" + custom_resolutions = true + "#; + let mut cfg_to_compare = default_peer_config.clone(); + cfg_to_compare.view_style = "adaptive".to_string(); + cfg_to_compare.scroll_style = "scrollbar".to_string(); + let cfg = toml::from_str::(wrong_type_str); + assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_type_str"); + + let wrong_type_str = r#" + view_style = "adaptive" + scroll_style = "scrollbar" + [custom_resolutions.0] + w = "1920" + h = 1080 + "#; + let mut cfg_to_compare = default_peer_config.clone(); + cfg_to_compare.view_style = "adaptive".to_string(); + cfg_to_compare.scroll_style = "scrollbar".to_string(); + let cfg = toml::from_str::(wrong_type_str); + assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_type_str"); + + let wrong_field_str = r#" + [custom_resolutions.0] + w = 1920 + h = 1080 + hello = "world" + [ui_flutter] + "#; + let mut cfg_to_compare = default_peer_config.clone(); + cfg_to_compare.custom_resolutions = + HashMap::from([("0".to_string(), Resolution { w: 1920, h: 1080 })]); + let cfg = toml::from_str::(wrong_field_str); + assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_field_str"); + } + } + + #[test] + fn test_store_load() { + let peerconfig_id = "123456789"; + let cfg: PeerConfig = Default::default(); + cfg.store(&peerconfig_id); + assert_eq!(PeerConfig::load(&peerconfig_id), cfg); + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + // ignore file type information by masking with 0o777 (see https://stackoverflow.com/a/50045872) + fs::metadata(PeerConfig::path(&peerconfig_id)) + .expect("reading metadata failed") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn test_uinput_ipc_path_is_shared_across_uids() { + const ROOT_UID: u32 = 0; + const USER_UID: u32 = 1000; + + let path_root = Config::ipc_path_for_uid(ROOT_UID, "_uinput_keyboard"); + let path_user = Config::ipc_path_for_uid(USER_UID, "_uinput_keyboard"); + assert_eq!(path_root, path_user); + + let app_name = APP_NAME.read().unwrap().clone(); + assert!( + path_root.starts_with(&format!("/tmp/{app_name}-service/")), + "unexpected uinput ipc path: {}", + path_root + ); + + let non_service_root = Config::ipc_path_for_uid(ROOT_UID, ""); + let non_service_user = Config::ipc_path_for_uid(USER_UID, ""); + assert_ne!(non_service_root, non_service_user); + } +} diff --git a/libs/hbb_common/src/config/permanent_password.rs b/libs/hbb_common/src/config/permanent_password.rs new file mode 100644 index 00000000000..5fbca254269 --- /dev/null +++ b/libs/hbb_common/src/config/permanent_password.rs @@ -0,0 +1,412 @@ +use sha2::{Digest, Sha256}; +use sodiumoxide::base64; + +use crate::{ + log, + password_security::{decrypt_str_or_original, symmetric_crypt}, +}; + +pub(super) const PASSWORD_ENC_VERSION: &str = "00"; +pub(super) const PERMANENT_PASSWORD_ENC_VERSION: &str = "01"; +pub(super) const PERMANENT_PASSWORD_HASH_PREFIX: &str = "00"; +const HBBS_PRESET_PASSWORD_HASH_PREFIX: &str = "00"; +pub(super) const PERMANENT_PASSWORD_H1_LEN: usize = 32; +pub(super) const DEFAULT_SALT_LEN: usize = 32; +pub const ENCRYPT_MAX_LEN: usize = 128; // used for password, pin, etc, not for all +const VERSION_LEN: usize = 2; + +#[cfg(test)] +pub(super) fn is_permanent_password_hashed_storage(v: &str) -> bool { + decode_permanent_password_h1_from_hashed_storage(v).is_some() +} + +pub fn compute_permanent_password_h1( + password: &str, + salt: &str, +) -> [u8; PERMANENT_PASSWORD_H1_LEN] { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + hasher.update(salt.as_bytes()); + let out = hasher.finalize(); + let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; + h1.copy_from_slice(&out[..PERMANENT_PASSWORD_H1_LEN]); + h1 +} + +pub(super) fn constant_time_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool { + sodiumoxide::utils::memcmp(a, b) +} + +pub(super) fn encode_permanent_password_storage_from_h1( + h1: &[u8; PERMANENT_PASSWORD_H1_LEN], +) -> String { + PERMANENT_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original) +} + +pub(super) fn encode_permanent_password_encrypted_storage_from_h1( + h1: &[u8; PERMANENT_PASSWORD_H1_LEN], +) -> Option { + let hashed_storage = encode_permanent_password_storage_from_h1(h1); + encrypt_permanent_password_storage(&hashed_storage) +} + +pub(super) fn decode_permanent_password_h1_from_hashed_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + decode_password_h1_after_prefix(storage, PERMANENT_PASSWORD_HASH_PREFIX) +} + +fn decode_password_h1_after_prefix( + storage: &str, + prefix: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + let encoded = storage.strip_prefix(prefix)?; + + let v = base64::decode(encoded.as_bytes(), base64::Variant::Original).ok()?; + if v.len() != PERMANENT_PASSWORD_H1_LEN { + return None; + } + let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; + h1.copy_from_slice(&v[..PERMANENT_PASSWORD_H1_LEN]); + Some(h1) +} + +fn encrypt_permanent_password_storage(storage: &str) -> Option { + if storage.chars().count() > ENCRYPT_MAX_LEN { + return None; + } + let encrypted = symmetric_crypt(storage.as_bytes(), true).ok()?; + Some( + PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original), + ) +} + +pub(super) fn decrypt_permanent_password_str_or_original(storage: &str) -> (String, bool, bool) { + if storage.len() > VERSION_LEN && storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + if let Ok(decoded) = base64::decode( + &storage.as_bytes()[VERSION_LEN..], + base64::Variant::Original, + ) { + if let Ok(v) = symmetric_crypt(&decoded, false) { + return (String::from_utf8_lossy(&v).to_string(), true, false); + } + } + } + (storage.to_owned(), false, !storage.is_empty()) +} + +pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { + if storage.is_empty() { + return false; + } + + if decode_permanent_password_h1_from_storage(storage).is_some() { + return !salt.is_empty(); + } + if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + let (_, decrypted, _) = decrypt_permanent_password_str_or_original(storage); + if decrypted { + log::error!("Permanent password storage looks current but cannot be decoded as a hash"); + return false; + } + } + + let (_, decrypted, looks_like_plaintext) = + decrypt_str_or_original(storage, PASSWORD_ENC_VERSION); + if storage.starts_with(PASSWORD_ENC_VERSION) && !decrypted && !looks_like_plaintext { + log::error!("Permanent password storage looks encrypted but cannot be decrypted"); + return false; + } + true +} + +pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { + if storage.is_empty() { + return false; + } + if salt.is_empty() { + return true; + } + decode_preset_password_h1_from_storage(storage).is_some() +} + +pub fn decode_preset_password_h1_from_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + decode_password_h1_after_prefix(storage, HBBS_PRESET_PASSWORD_HASH_PREFIX) +} + +#[cfg(test)] +fn local_permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool { + if storage.is_empty() || input.is_empty() { + return false; + } + if !local_permanent_password_storage_is_usable_for_auth(storage, salt) { + return false; + } + if let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) { + if salt.is_empty() { + log::error!("Salt is empty but permanent password storage is hashed"); + return false; + } + let h1 = compute_permanent_password_h1(input, salt); + return constant_time_eq_32(&h1, &stored_h1); + } + storage == input +} + +pub(super) fn preset_permanent_password_storage_matches_plain( + storage: &str, + salt: &str, + input: &str, +) -> bool { + if storage.is_empty() || input.is_empty() { + return false; + } + if salt.is_empty() { + return storage == input; + } + let Some(stored_h1) = decode_preset_password_h1_from_storage(storage) else { + return false; + }; + let h1 = compute_permanent_password_h1(input, salt); + constant_time_eq_32(&h1, &stored_h1) +} + +pub fn decode_permanent_password_h1_from_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + let (hashed_storage, decrypted, _) = decrypt_permanent_password_str_or_original(storage); + if !decrypted { + return None; + } + return decode_permanent_password_h1_from_hashed_storage(&hashed_storage); + } + None +} + +// Salt can be updated only when the password is empty, plaintext, or decryptable +// legacy storage. Current-prefixed storage is treated as salt-bound. +pub(super) fn password_is_empty_or_not_hashed(permanent_password_storage: &str) -> bool { + if permanent_password_storage.is_empty() { + return true; + } + if decode_permanent_password_h1_from_storage(permanent_password_storage).is_some() { + return false; + } + if permanent_password_storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + return false; + } + let (_, decrypted, looks_like_plaintext) = + decrypt_str_or_original(permanent_password_storage, PASSWORD_ENC_VERSION); + decrypted || looks_like_plaintext +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::password_security::encrypt_str_or_original; + + fn encode_hbbs_preset_password_storage_from_h1(h1: &[u8; PERMANENT_PASSWORD_H1_LEN]) -> String { + HBBS_PRESET_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original) + } + + #[test] + fn test_permanent_password_h1_storage_roundtrip() { + let salt = "salt123"; + let password = "p@ssw0rd"; + let h1 = compute_permanent_password_h1(password, salt); + let stored = encode_permanent_password_storage_from_h1(&h1); + assert!(stored.starts_with(PERMANENT_PASSWORD_HASH_PREFIX)); + assert!(is_permanent_password_hashed_storage(&stored)); + let decoded = decode_permanent_password_h1_from_hashed_storage(&stored).unwrap(); + assert_eq!(&decoded[..], &h1[..]); + } + + #[test] + fn test_permanent_password_encrypted_storage_uses_01_outer_and_00_inner() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); + assert!(!is_permanent_password_hashed_storage(&storage)); + + let (inner, decrypted, should_store) = decrypt_permanent_password_str_or_original(&storage); + assert!(decrypted); + assert!(!should_store); + assert!(inner.starts_with(PERMANENT_PASSWORD_HASH_PREFIX)); + assert_eq!( + decode_permanent_password_h1_from_storage(&storage), + Some(h1) + ); + } + + #[test] + fn test_encrypted_hashed_password_storage_matches_plain_with_salt() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(local_permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(local_permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + assert!(!local_permanent_password_storage_matches_plain( + &storage, salt, "wrong" + )); + } + + #[test] + fn test_hbbs_00_hashed_preset_password_storage_is_decoded_for_preset_auth() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert_eq!(decode_preset_password_h1_from_storage(&storage), Some(h1)); + } + + #[test] + fn test_hbbs_00_hashed_preset_password_storage_matches_plain_with_salt() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert!(preset_permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(preset_permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, salt, "wrong" + )); + } + + #[test] + fn test_encrypted_hash_storage_is_not_accepted_as_preset_storage() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(!preset_permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + } + + #[test] + fn test_hbbs_00_shaped_preset_password_without_salt_stays_plaintext() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert!(preset_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(preset_permanent_password_storage_matches_plain( + &storage, "", &storage + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, "", "p@ssw0rd" + )); + } + + #[test] + fn test_hashed_preset_password_storage_without_salt_is_not_usable() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_permanent_password_storage_from_h1(&h1); + + assert!(!local_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(!local_permanent_password_storage_matches_plain( + &storage, "", "p@ssw0rd" + )); + } + + #[test] + fn test_legacy_plain_preset_password_without_salt_keeps_old_behavior() { + let storage = "01not-a-valid-hash"; + + assert!(preset_permanent_password_storage_is_usable_for_auth( + storage, "" + )); + assert!(preset_permanent_password_storage_matches_plain( + storage, + "", + "01not-a-valid-hash" + )); + } + + #[test] + fn test_malformed_preset_password_with_salt_is_not_usable_for_auth() { + for storage in ["01not-a-valid-hash", "00not-a-valid-hash"] { + assert!(!preset_permanent_password_storage_is_usable_for_auth( + storage, + "preset-salt" + )); + assert!(!preset_permanent_password_storage_matches_plain( + storage, + "preset-salt", + storage + )); + } + } + + #[test] + fn test_invalid_current_version_storage_is_not_usable_for_auth() { + let encrypted = symmetric_crypt(b"not-a-hash", true).unwrap(); + let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original); + + assert!(!local_permanent_password_storage_is_usable_for_auth( + &encrypted_non_hash, + "salt123" + )); + assert!(!local_permanent_password_storage_matches_plain( + &encrypted_non_hash, + "salt123", + &encrypted_non_hash + )); + } + + #[test] + fn test_legacy_plain_preset_password_that_decodes_as_hash_requires_salt() { + let h1 = compute_permanent_password_h1("plain-looking-hash", "salt123"); + let storage = encode_permanent_password_storage_from_h1(&h1); + + assert!(!local_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(!local_permanent_password_storage_matches_plain( + &storage, "", &storage + )); + } + + #[test] + fn test_password_is_empty_or_not_hashed_accepts_plaintext_and_decryptable_legacy_plaintext() { + let storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + + assert!(password_is_empty_or_not_hashed("00secret")); + assert!(password_is_empty_or_not_hashed(&storage)); + } + + #[test] + fn test_password_is_empty_or_not_hashed_treats_locked_00_storage_as_hashed() { + let invalid_payload = vec![42u8; sodiumoxide::crypto::secretbox::MACBYTES + 1]; + let locked_storage = PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + + assert!(!password_is_empty_or_not_hashed(&locked_storage)); + } + + #[test] + fn test_password_is_empty_or_not_hashed_treats_invalid_01_storage_as_hashed() { + assert!(!password_is_empty_or_not_hashed("01not-a-valid-hash")); + } +} diff --git a/libs/hbb_common/src/fingerprint.rs b/libs/hbb_common/src/fingerprint.rs new file mode 100644 index 00000000000..2d8985e38a4 --- /dev/null +++ b/libs/hbb_common/src/fingerprint.rs @@ -0,0 +1,381 @@ +use serde_derive::{Deserialize, Serialize}; +use sha2::digest::Update; +use sha2::{Digest, Sha512}; +use std::collections::HashMap; +use std::sync::Once; +use sysinfo::System; + +const TABLE: [u8; 256] = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, +]; + +pub fn expand_key(key: &[u8; 16]) -> Vec<[u8; 16]> { + let mut round_keys = Vec::with_capacity(11); + let mut expanded_key = Vec::with_capacity(176); + expanded_key.extend_from_slice(key); + + for i in 4..44 { + let mut temp = [0u8; 4]; + temp.copy_from_slice(&expanded_key[(i - 1) * 4..i * 4]); + + if i % 4 == 0 { + temp.rotate_left(1); + for j in 0..4 { + temp[j] = TABLE[temp[j] as usize]; + } + temp[0] ^= match i { + 4 => 0x01, + 8 => 0x02, + 12 => 0x04, + 16 => 0x08, + 20 => 0x10, + 24 => 0x20, + 28 => 0x40, + 32 => 0x80, + 36 => 0x1b, + 40 => 0x36, + _ => 0, + }; + } + + for j in 0..4 { + let prev = expanded_key[(i - 4) * 4 + j]; + expanded_key.push(prev ^ temp[j]); + } + } + + for chunk in expanded_key.chunks(16) { + let mut round_key = [0u8; 16]; + round_key.copy_from_slice(chunk); + round_keys.push(round_key); + } + + round_keys +} + +fn finalize_block(input: &[u8; 16], key: &[u8; 16]) -> [u8; 16] { + let round_keys = expand_key(key); + let mut state = *input; + + add_round_key(&mut state, &round_keys[0]); + + for round in 1..10 { + sub_bytes(&mut state); + shift_rows(&mut state); + mix_columns(&mut state); + add_round_key(&mut state, &round_keys[round]); + } + + sub_bytes(&mut state); + shift_rows(&mut state); + add_round_key(&mut state, &round_keys[10]); + + state +} + +fn sub_bytes(state: &mut [u8; 16]) { + for byte in state.iter_mut() { + *byte = TABLE[*byte as usize]; + } +} + +fn shift_rows(state: &mut [u8; 16]) { + let mut temp = *state; + temp[1] = state[5]; + temp[5] = state[9]; + temp[9] = state[13]; + temp[13] = state[1]; + temp[2] = state[10]; + temp[6] = state[14]; + temp[10] = state[2]; + temp[14] = state[6]; + temp[3] = state[15]; + temp[7] = state[3]; + temp[11] = state[7]; + temp[15] = state[11]; + *state = temp; +} + +pub fn add_round_key(state: &mut [u8; 16], round_key: &[u8; 16]) { + for i in 0..16 { + state[i] ^= round_key[i]; + } +} + +pub fn gf_mul(a: u8, b: u8) -> u8 { + let mut p = 0u8; + let mut temp = b; + let mut a = a; + + while a != 0 { + if (a & 1) != 0 { + p ^= temp; + } + let high_bit = temp & 0x80; + temp <<= 1; + if high_bit != 0 { + temp ^= 0x1b; + } + a >>= 1; + } + p +} + +fn mix_columns(state: &mut [u8; 16]) { + for i in 0..4 { + let s0 = state[i * 4]; + let s1 = state[i * 4 + 1]; + let s2 = state[i * 4 + 2]; + let s3 = state[i * 4 + 3]; + + state[i * 4] = gf_mul(0x02, s0) ^ gf_mul(0x03, s1) ^ s2 ^ s3; + state[i * 4 + 1] = s0 ^ gf_mul(0x02, s1) ^ gf_mul(0x03, s2) ^ s3; + state[i * 4 + 2] = s0 ^ s1 ^ gf_mul(0x02, s2) ^ gf_mul(0x03, s3); + state[i * 4 + 3] = gf_mul(0x03, s0) ^ s1 ^ s2 ^ gf_mul(0x02, s3); + } +} + +fn get_system_entropy() -> [u8; 16] { + let mut entropy = [0u8; 16]; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for i in 0..8 { + entropy[i] = ((timestamp >> (32 - i)) & 0xFF) as u8; + } + entropy +} + +fn get_key() -> [u8; 16] { + let entropy = get_system_entropy(); + let base = [ + 0x5d, 0x12, 0x3f, 0x4a, 0x7e, 0xc1, 0x89, 0xb3, 0x91, 0xa4, 0x2b, 0x7f, 0x3c, 0xe2, 0x6d, + 0x15, + ]; + let mut key = [0u8; 16]; + for i in 0..16 { + key[i] = base[i] ^ entropy[i]; + } + base +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FingerprintingInfo { + eol: String, + endianness: String, + brand: String, + speed_max: String, + cores: String, + physical_cores: String, + mem_total: String, + platform: String, + arch: String, + id: String, + addr: String, +} + +static mut FINGERPRINTING_INFO: Option = None; +static INIT: Once = Once::new(); +static mut CACHED_FINGERPRINTS: Option>> = None; + +impl FingerprintingInfo { + fn new() -> Self { + let mut sys = System::new(); + sys.refresh_cpu(); + let cpu = sys.cpus().first(); + let id = { + let mut id = crate::config::Config::get_id(); + id.truncate(16); + format!("{:<16}", id) + }; + + FingerprintingInfo { + eol: if cfg!(windows) { "\r\n" } else { "\n" }.to_string(), + endianness: if cfg!(target_endian = "big") { + "BE" + } else { + "LE" + } + .to_string(), + brand: cpu.map(|cpu| cpu.brand().to_string()).unwrap_or_default(), + speed_max: cpu + .map(|cpu| cpu.frequency().to_string()) + .unwrap_or_default(), + cores: sys.cpus().len().to_string(), + physical_cores: sys.physical_core_count().unwrap_or(1).to_string(), + mem_total: sys.total_memory().to_string(), + platform: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + id, + #[cfg(any(target_os = "android", target_os = "ios"))] + addr: "0".repeat(16), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + addr: { + let mut addr = default_net::get_mac().map(|m| m.addr).unwrap_or_default(); + if addr.is_empty() { + addr = mac_address::get_mac_address() + .ok() + .and_then(|mac| mac) + .map(|mac| mac.to_string()) + .unwrap_or_else(|| "".to_string()); + } + addr = addr.replace(":", ""); + format!("{:0<16}", addr) + }, + } + } +} + +pub fn get_fingerprinting_info() -> FingerprintingInfo { + unsafe { + INIT.call_once(|| { + FINGERPRINTING_INFO = Some(FingerprintingInfo::new()); + CACHED_FINGERPRINTS = Some(HashMap::new()); + }); + #[allow(static_mut_refs)] + FINGERPRINTING_INFO.clone().unwrap_or_default() + } +} + +pub fn get_fingerprint(only: Option>, except: Option>) -> Vec { + let all_parameters = vec![ + "eol".to_string(), + "endianness".to_string(), + "brand".to_string(), + "speed_max".to_string(), + "cores".to_string(), + "physical_cores".to_string(), + "mem_total".to_string(), + "platform".to_string(), + "arch".to_string(), + "id".to_string(), + "addr".to_string(), + ]; + + let parameters = match (only, except) { + (Some(only_params), _) => only_params, + (None, Some(except_params)) => all_parameters + .into_iter() + .filter(|param| !except_params.contains(param)) + .collect(), + (None, None) => all_parameters, + }; + + let cache_key = parameters.join(""); + + unsafe { + #[allow(static_mut_refs)] + if let Some(cache) = &mut CACHED_FINGERPRINTS { + if let Some(fingerprint) = cache.get(&cache_key) { + return fingerprint.clone(); + } + + let fingerprint = calculate_fingerprint(¶meters); + cache.insert(cache_key, fingerprint.clone()); + fingerprint + } else { + calculate_fingerprint(¶meters) + } + } +} + +struct Sha512Hasher { + sha512: Sha512, + key: [u8; 16], + buffer: Vec, +} + +impl Sha512Hasher { + fn new() -> Self { + let key = get_key(); + Sha512Hasher { + sha512: Sha512::new(), + key, + buffer: Vec::new(), + } + } + + fn update(&mut self, data: &[u8]) { + if data.len() <= 32 { + self.buffer.extend_from_slice(data); + } else { + let split_point = data.len() - 32; + Update::update(&mut self.sha512, &data[..split_point]); + + self.buffer.clear(); + self.buffer.extend_from_slice(&data[split_point..]); + } + } + + fn finalize(self) -> Vec { + let mut result = Vec::new(); + + result.extend(self.sha512.finalize()); + + if !self.buffer.is_empty() { + let mut first_block = [0u8; 16]; + let mut second_block = [0u8; 16]; + if self.buffer.len() >= 32 { + let start_first = self.buffer.len() - 32; + let start_second = self.buffer.len() - 16; + first_block.copy_from_slice(&self.buffer[start_first..start_second]); + second_block.copy_from_slice(&self.buffer[start_second..]); + } else if self.buffer.len() > 16 { + let start_second = self.buffer.len() - 16; + first_block[..self.buffer.len() - 16].copy_from_slice(&self.buffer[..start_second]); + second_block.copy_from_slice(&self.buffer[start_second..]); + } else { + first_block[..self.buffer.len()].copy_from_slice(&self.buffer); + } + let encrypted_first = finalize_block(&first_block, &self.key); + let encrypted_second = finalize_block(&second_block, &self.key); + result.extend(&encrypted_first); + result.extend(&encrypted_second); + } + + result + } +} + +fn calculate_fingerprint(parameters: &[String]) -> Vec { + let info = get_fingerprinting_info(); + + let mut hasher = Sha512Hasher::new(); + + let fingerprint_string = parameters + .iter() + .filter_map(|param| match param.as_str() { + "eol" => Some(info.eol.as_str()), + "endianness" => Some(&info.endianness), + "brand" => Some(&info.brand), + "speed_max" => Some(&info.speed_max), + "cores" => Some(&info.cores), + "physical_cores" => Some(&info.physical_cores), + "mem_total" => Some(&info.mem_total), + "platform" => Some(&info.platform), + "arch" => Some(&info.arch), + "id" => Some(&info.id), + "addr" => Some(&info.addr), + _ => None, + }) + .collect::>() + .join(""); + hasher.update(fingerprint_string.as_bytes()); + hasher.finalize() +} diff --git a/libs/hbb_common/src/fs.rs b/libs/hbb_common/src/fs.rs new file mode 100644 index 00000000000..53a82990b6e --- /dev/null +++ b/libs/hbb_common/src/fs.rs @@ -0,0 +1,1806 @@ +#[cfg(windows)] +use std::os::windows::prelude::*; +use std::{ + fmt::{Debug, Display}, + io::Cursor, + path::{Path, PathBuf}, + sync::atomic::{AtomicI32, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde_derive::{Deserialize, Serialize}; +use serde_json::json; +use tokio::{ + fs::{File, OpenOptions}, + io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufStream as TokioBufStream}, +}; + +use crate::{anyhow::anyhow, bail, get_version_number, message_proto::*, ResultType, Stream}; +// https://doc.rust-lang.org/std/os/windows/fs/trait.MetadataExt.html +use crate::{ + compress::{compress, decompress}, + config::Config, +}; + +static NEXT_JOB_ID: AtomicI32 = AtomicI32::new(1); + +pub fn get_next_job_id() -> i32 { + NEXT_JOB_ID.fetch_add(1, Ordering::SeqCst) +} + +pub fn update_next_job_id(id: i32) { + NEXT_JOB_ID.store(id, Ordering::SeqCst); +} + +pub fn read_dir(path: &Path, include_hidden: bool) -> ResultType { + let mut dir = FileDirectory { + path: get_string(path), + ..Default::default() + }; + #[cfg(windows)] + if "/" == &get_string(path) { + let drives = unsafe { winapi::um::fileapi::GetLogicalDrives() }; + for i in 0..32 { + if drives & (1 << i) != 0 { + let name = format!( + "{}:", + std::char::from_u32('A' as u32 + i as u32).unwrap_or('A') + ); + dir.entries.push(FileEntry { + name, + entry_type: FileType::DirDrive.into(), + ..Default::default() + }); + } + } + return Ok(dir); + } + for entry in path.read_dir()?.flatten() { + let p = entry.path(); + let name = p + .file_name() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("") + .to_owned(); + if name.is_empty() { + continue; + } + let mut is_hidden = false; + let meta; + if let Ok(tmp) = std::fs::symlink_metadata(&p) { + meta = tmp; + } else { + continue; + } + // docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants + #[cfg(windows)] + if meta.file_attributes() & 0x2 != 0 { + is_hidden = true; + } + #[cfg(not(windows))] + if name.find('.').unwrap_or(usize::MAX) == 0 { + is_hidden = true; + } + if is_hidden && !include_hidden { + continue; + } + let (entry_type, size) = { + if p.is_dir() { + if meta.file_type().is_symlink() { + (FileType::DirLink.into(), 0) + } else { + (FileType::Dir.into(), 0) + } + } else if meta.file_type().is_symlink() { + (FileType::FileLink.into(), 0) + } else { + (FileType::File.into(), meta.len()) + } + }; + let modified_time = meta + .modified() + .map(|x| { + x.duration_since(std::time::SystemTime::UNIX_EPOCH) + .map(|x| x.as_secs()) + .unwrap_or(0) + }) + .unwrap_or(0); + dir.entries.push(FileEntry { + name: get_file_name(&p), + entry_type, + is_hidden, + size, + modified_time, + ..Default::default() + }); + } + Ok(dir) +} + +#[inline] +pub fn get_file_name(p: &Path) -> String { + p.file_name() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("") + .to_owned() +} + +#[inline] +pub fn get_string(path: &Path) -> String { + path.to_str().unwrap_or("").to_owned() +} + +#[inline] +pub fn get_path(path: &str) -> PathBuf { + Path::new(path).to_path_buf() +} + +#[inline] +pub fn get_home_as_string() -> String { + get_string(&Config::get_home()) +} + +fn read_dir_recursive( + path: &Path, + prefix: &Path, + include_hidden: bool, +) -> ResultType> { + let mut files = Vec::new(); + if path.is_dir() { + // to-do: symbol link handling, cp the link rather than the content + // to-do: file mode, for unix + let fd = read_dir(path, include_hidden)?; + for entry in fd.entries.iter() { + match entry.entry_type.enum_value() { + Ok(FileType::File) => { + let mut entry = entry.clone(); + entry.name = get_string(&prefix.join(entry.name)); + files.push(entry); + } + Ok(FileType::Dir) => { + if let Ok(mut tmp) = read_dir_recursive( + &path.join(&entry.name), + &prefix.join(&entry.name), + include_hidden, + ) { + for entry in tmp.drain(0..) { + files.push(entry); + } + } + } + _ => {} + } + } + Ok(files) + } else if path.is_file() { + let (size, modified_time) = if let Ok(meta) = std::fs::metadata(path) { + ( + meta.len(), + meta.modified() + .map(|x| { + x.duration_since(std::time::SystemTime::UNIX_EPOCH) + .map(|x| x.as_secs()) + .unwrap_or(0) + }) + .unwrap_or(0), + ) + } else { + (0, 0) + }; + files.push(FileEntry { + entry_type: FileType::File.into(), + size, + modified_time, + ..Default::default() + }); + Ok(files) + } else { + bail!("Not exists"); + } +} + +pub fn get_recursive_files(path: &str, include_hidden: bool) -> ResultType> { + read_dir_recursive(&get_path(path), &get_path(""), include_hidden) +} + +fn read_empty_dirs_recursive( + path: &Path, + prefix: &Path, + include_hidden: bool, +) -> ResultType> { + let mut dirs = Vec::new(); + if path.is_dir() { + // to-do: symbol link handling, cp the link rather than the content + // to-do: file mode, for unix + let fd = read_dir(path, include_hidden)?; + if fd.entries.is_empty() { + dirs.push(fd); + } else { + for entry in fd.entries.iter() { + match entry.entry_type.enum_value() { + Ok(FileType::Dir) => { + if let Ok(mut tmp) = read_empty_dirs_recursive( + &path.join(&entry.name), + &prefix.join(&entry.name), + include_hidden, + ) { + for entry in tmp.drain(0..) { + dirs.push(entry); + } + } + } + _ => {} + } + } + } + Ok(dirs) + } else if path.is_file() { + Ok(dirs) + } else { + bail!("Not exists"); + } +} + +pub fn get_empty_dirs_recursive( + path: &str, + include_hidden: bool, +) -> ResultType> { + read_empty_dirs_recursive(&get_path(path), &get_path(""), include_hidden) +} + +#[inline] +pub fn is_file_exists(file_path: &str) -> bool { + return Path::new(file_path).exists(); +} + +#[inline] +pub fn can_enable_overwrite_detection(version: i64) -> bool { + version >= get_version_number("1.1.10") +} + +#[repr(i32)] +#[derive(Copy, Clone, Serialize, Debug, PartialEq)] +pub enum JobType { + Generic = 0, + Printer = 1, +} + +impl Default for JobType { + fn default() -> Self { + JobType::Generic + } +} + +impl From for file_transfer_send_request::FileType { + fn from(t: JobType) -> Self { + match t { + JobType::Generic => file_transfer_send_request::FileType::Generic, + JobType::Printer => file_transfer_send_request::FileType::Printer, + } + } +} + +impl From for JobType { + fn from(value: i32) -> Self { + match value { + 0 => JobType::Generic, + 1 => JobType::Printer, + _ => JobType::Generic, + } + } +} + +impl Into for JobType { + fn into(self) -> i32 { + self as i32 + } +} + +impl JobType { + pub fn from_proto(t: ::protobuf::EnumOrUnknown) -> Self { + match t.enum_value() { + Ok(file_transfer_send_request::FileType::Generic) => JobType::Generic, + Ok(file_transfer_send_request::FileType::Printer) => JobType::Printer, + _ => JobType::Generic, + } + } +} + +#[derive(Debug)] +pub enum DataSource { + FilePath(PathBuf), + MemoryCursor(Cursor>), +} + +impl Default for DataSource { + fn default() -> Self { + DataSource::FilePath(PathBuf::new()) + } +} + +impl serde::Serialize for DataSource { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + match self { + DataSource::FilePath(p) => serializer.serialize_str(p.to_str().unwrap_or("")), + DataSource::MemoryCursor(_) => serializer.serialize_str(""), + } + } +} + +impl Display for DataSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DataSource::FilePath(p) => write!(f, "File: {}", p.to_string_lossy().to_string()), + DataSource::MemoryCursor(_) => write!(f, "Bytes"), + } + } +} + +impl DataSource { + fn to_meta(&self) -> String { + match self { + DataSource::FilePath(p) => p.to_string_lossy().to_string(), + DataSource::MemoryCursor(_) => "".to_string(), + } + } +} + +enum DataStream { + FileStream(File), + BufStream(TokioBufStream>>), +} + +impl Debug for DataStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DataStream::FileStream(fs) => write!(f, "{:?}", fs), + DataStream::BufStream(_) => write!(f, "BufStream"), + } + } +} + +impl DataStream { + async fn write_all(&mut self, buf: &[u8]) -> ResultType<()> { + match self { + DataStream::FileStream(fs) => fs.write_all(buf).await?, + DataStream::BufStream(bs) => bs.write_all(buf).await?, + } + Ok(()) + } + + async fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + DataStream::FileStream(fs) => fs.read(buf).await, + DataStream::BufStream(bs) => bs.read(buf).await, + } + } +} + +#[derive(Default, Serialize, Deserialize, Debug)] +pub struct FileDigest { + pub size: u64, + pub modified: u64, +} + +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TransferJob { + pub id: i32, + pub r#type: JobType, + pub remote: String, + pub data_source: DataSource, + pub show_hidden: bool, + pub is_remote: bool, + pub is_last_job: bool, + pub is_resume: bool, + pub file_num: i32, + #[serde(skip_serializing)] + files: Vec, + pub conn_id: i32, // server only + + #[serde(skip_serializing)] + data_stream: Option, + pub total_size: u64, + finished_size: u64, + transferred: u64, + enable_overwrite_detection: bool, + file_confirmed: bool, + // indicating the last file is skipped + file_skipped: bool, + file_is_waiting: bool, + default_overwrite_strategy: Option, + #[serde(skip_serializing)] + digest: FileDigest, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct TransferJobMeta { + #[serde(default)] + pub id: i32, + #[serde(default)] + pub remote: String, + #[serde(default)] + pub to: String, + #[serde(default)] + pub show_hidden: bool, + #[serde(default)] + pub file_num: i32, + #[serde(default)] + pub is_remote: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct RemoveJobMeta { + #[serde(default)] + pub path: String, + #[serde(default)] + pub is_remote: bool, + #[serde(default)] + pub no_confirm: bool, +} + +#[inline] +fn get_ext(name: &str) -> &str { + if let Some(i) = name.rfind('.') { + return &name[i + 1..]; + } + "" +} + +#[inline] +fn is_compressed_file(name: &str) -> bool { + let compressed_exts = ["xz", "gz", "zip", "7z", "rar", "bz2", "tgz", "png", "jpg"]; + let ext = get_ext(name); + compressed_exts.contains(&ext) +} + +pub fn validate_file_name_no_traversal(name: &str) -> ResultType<()> { + if name.bytes().any(|b| b == 0) { + bail!("file name contains null bytes"); + } + let has_traversal = name + .split(|c: char| c == '/' || (cfg!(windows) && c == '\\')) + .filter(|s| !s.is_empty()) + .any(|s| s == ".."); + if has_traversal { + bail!("path traversal detected in file name"); + } + #[cfg(windows)] + { + if name.len() >= 2 { + let bytes = name.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + bail!("absolute path detected in file name"); + } + } + if name.starts_with('/') || name.starts_with('\\') { + bail!("absolute path detected in file name"); + } + } + #[cfg(not(windows))] + if name.starts_with('/') { + bail!("absolute path detected in file name"); + } + Ok(()) +} + +fn validate_transfer_file_names(files: &[FileEntry]) -> ResultType<()> { + // Single-file transfer may use an empty relative name, because + // the destination file path is carried by transfer metadata. + if files.len() == 1 && files.first().map_or(false, |f| f.name.is_empty()) { + return Ok(()); + } + for file in files { + if file.name.is_empty() { + bail!("empty file name in multi-file transfer"); + } + validate_file_name_no_traversal(&file.name)?; + } + Ok(()) +} + +#[inline] +fn validate_fs_path_argument(path: &str, arg_name: &str) -> ResultType<()> { + if path.is_empty() { + bail!("{arg_name} cannot be empty"); + } + if path.bytes().any(|b| b == 0) { + bail!("{arg_name} contains null bytes"); + } + Ok(()) +} + +fn validate_no_symlink_components(base: &PathBuf, name: &str) -> ResultType<()> { + if name.is_empty() { + return Ok(()); + } + let mut current = base.clone(); + for component in Path::new(name).components() { + match component { + std::path::Component::Normal(seg) => { + current.push(seg); + // Best-effort guard: path-based checks are inherently TOCTOU-prone + // if local filesystem state changes between validation and write. + match std::fs::symlink_metadata(¤t) { + Ok(meta) => { + // This is inherent to filesystem-based checks and acknowledged as a limitation. + // For true protection, you'd need openat(2) / O_NOFOLLOW at write time. + if meta.file_type().is_symlink() { + bail!("symlink path component is not allowed"); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // Component does not exist yet, continue best-effort validation. + } + Err(err) => { + bail!( + "failed to validate path component '{}': {}", + current.display(), + err + ); + } + } + } + std::path::Component::CurDir => {} + _ => { + bail!("invalid file name component"); + } + } + } + Ok(()) +} + +fn join_validated_path(base: &PathBuf, name: &str) -> ResultType { + validate_file_name_no_traversal(name)?; + validate_no_symlink_components(base, name)?; + Ok(TransferJob::join(base, name)) +} + +impl TransferJob { + #[allow(clippy::too_many_arguments)] + pub fn new_write( + id: i32, + r#type: JobType, + remote: String, + data_source: DataSource, + file_num: i32, + show_hidden: bool, + is_remote: bool, + enable_overwrite_detection: bool, + ) -> Self { + log::info!("new write {}", data_source); + Self { + id, + r#type, + remote, + data_source, + file_num, + show_hidden, + is_remote, + files: Vec::new(), + total_size: 0, + enable_overwrite_detection, + ..Default::default() + } + } + + pub fn with_files(mut self, files: Vec) -> ResultType { + self.set_files(files)?; + Ok(self) + } + + pub fn new_read( + id: i32, + r#type: JobType, + remote: String, + data_source: DataSource, + file_num: i32, + show_hidden: bool, + is_remote: bool, + enable_overwrite_detection: bool, + ) -> ResultType { + log::info!("new read {}", data_source); + let (files, total_size) = match &data_source { + DataSource::FilePath(p) => { + let p = p.to_str().ok_or(anyhow!("Invalid path"))?; + let files = get_recursive_files(p, show_hidden)?; + let total_size = files.iter().map(|x| x.size).sum(); + (files, total_size) + } + DataSource::MemoryCursor(c) => (Vec::new(), c.get_ref().len() as u64), + }; + Ok(Self { + id, + r#type, + remote, + data_source, + file_num, + show_hidden, + is_remote, + files, + total_size, + enable_overwrite_detection, + ..Default::default() + }) + } + + pub async fn get_buf_data(self) -> ResultType>> { + match self.data_stream { + Some(DataStream::BufStream(mut bs)) => { + bs.flush().await?; + Ok(Some(bs.into_inner().into_inner())) + } + _ => Ok(None), + } + } + + #[inline] + pub fn files(&self) -> &Vec { + &self.files + } + + #[inline] + pub fn set_files(&mut self, files: Vec) -> ResultType<()> { + validate_transfer_file_names(&files)?; + if let DataSource::FilePath(base) = &self.data_source { + for file in &files { + validate_no_symlink_components(base, &file.name)?; + } + } + self.total_size = files.iter().map(|x| x.size).sum(); + self.files = files; + Ok(()) + } + + #[inline] + pub fn set_digest(&mut self, size: u64, modified: u64) { + self.digest.size = size; + self.digest.modified = modified; + } + + #[inline] + pub fn id(&self) -> i32 { + self.id + } + + #[inline] + pub fn total_size(&self) -> u64 { + self.total_size + } + + #[inline] + pub fn finished_size(&self) -> u64 { + self.finished_size + } + + #[inline] + pub fn transferred(&self) -> u64 { + self.transferred + } + + #[inline] + pub fn file_num(&self) -> i32 { + self.file_num + } + + fn resolve_entry_path(&self, base: &PathBuf, name: &str) -> Option { + if self.r#type == JobType::Generic { + match join_validated_path(base, name) { + Ok(path) => Some(path), + Err(err) => { + log::error!("Invalid file name in transfer job {}: {}", self.id, err); + None + } + } + } else { + Some(Self::join(base, name)) + } + } + + pub fn modify_time(&self) { + if self.r#type == JobType::Printer { + return; + } + if let DataSource::FilePath(p) = &self.data_source { + let file_num = self.file_num as usize; + if file_num < self.files.len() { + let entry = &self.files[file_num]; + let Some(path) = self.resolve_entry_path(p, &entry.name) else { + return; + }; + let download_path = format!("{}.download", get_string(&path)); + let digest_path = format!("{}.digest", get_string(&path)); + std::fs::remove_file(digest_path).ok(); + std::fs::rename(download_path, &path).ok(); + filetime::set_file_mtime( + &path, + filetime::FileTime::from_unix_time(entry.modified_time as _, 0), + ) + .ok(); + } + } + } + + pub fn remove_download_file(&self) { + if self.r#type == JobType::Printer { + return; + } + if let DataSource::FilePath(p) = &self.data_source { + let file_num = self.file_num as usize; + if file_num < self.files.len() { + let entry = &self.files[file_num]; + let Some(path) = self.resolve_entry_path(p, &entry.name) else { + return; + }; + let download_path = format!("{}.download", get_string(&path)); + let digest_path = format!("{}.digest", get_string(&path)); + std::fs::remove_file(download_path).ok(); + std::fs::remove_file(digest_path).ok(); + } + } + } + + #[inline] + pub fn set_finished_size_on_resume(&mut self) { + if self.is_resume && self.file_num > 0 { + let finished_size: u64 = self + .files + .iter() + .take(self.file_num as usize) + .map(|file| file.size) + .sum(); + self.finished_size = finished_size; + } + } + + pub async fn write(&mut self, block: FileTransferBlock) -> ResultType<()> { + if block.id != self.id { + bail!("Wrong id"); + } + match &self.data_source { + DataSource::FilePath(p) => { + let file_num = block.file_num as usize; + if file_num >= self.files.len() { + bail!("Wrong file number"); + } + if file_num != self.file_num as usize || self.data_stream.is_none() { + self.modify_time(); + if let Some(DataStream::FileStream(file)) = self.data_stream.as_mut() { + file.sync_all().await?; + } + self.file_num = block.file_num; + let entry = &self.files[file_num]; + let (path, digest_path) = if self.r#type == JobType::Printer { + (p.to_string_lossy().to_string(), None) + } else { + let path = join_validated_path(p, &entry.name)?; + // NOTE: We intentionally keep path-based validation + regular file open here. + // This still has a known TOCTOU window for symlink races, but avoids a large + // cross-platform rewrite for now. + // Revisit with descriptor/handle-based no-follow open in future hardening. + if let Some(pp) = path.parent() { + std::fs::create_dir_all(pp).ok(); + } + let file_path = get_string(&path); + ( + format!("{}.download", &file_path), + Some(format!("{}.digest", &file_path)), + ) + }; + if let Some(dp) = digest_path.as_ref() { + if Path::new(dp).exists() { + std::fs::remove_file(dp)?; + } + } + self.data_stream = Some(DataStream::FileStream(File::create(&path).await?)); + if let Some(dp) = digest_path.as_ref() { + std::fs::write(dp, json!(self.digest).to_string()).ok(); + } + } + } + DataSource::MemoryCursor(c) => { + if self.data_stream.is_none() { + self.data_stream = Some(DataStream::BufStream(TokioBufStream::new(c.clone()))); + } + } + } + if block.compressed { + let tmp = decompress(&block.data); + self.data_stream + .as_mut() + .ok_or(anyhow!("data stream is None"))? + .write_all(&tmp) + .await?; + self.finished_size += tmp.len() as u64; + } else { + self.data_stream + .as_mut() + .ok_or(anyhow!("file is None"))? + .write_all(&block.data) + .await?; + self.finished_size += block.data.len() as u64; + } + self.transferred += block.data.len() as u64; + Ok(()) + } + + #[inline] + pub fn join(p: &PathBuf, name: &str) -> PathBuf { + if name.is_empty() { + p.clone() + } else { + p.join(name) + } + } + + /// Open the data stream for the current file. + /// Returns Ok(true) if job is done, Ok(false) otherwise. + async fn open_data_stream(&mut self) -> ResultType { + let file_num = self.file_num as usize; + match &mut self.data_source { + DataSource::FilePath(p) => { + if file_num >= self.files.len() { + // job done + self.data_stream.take(); + return Ok(true); + }; + if self.data_stream.is_none() { + match File::open(Self::join(p, &self.files[file_num].name)).await { + Ok(file) => { + self.data_stream = Some(DataStream::FileStream(file)); + self.file_confirmed = false; + self.file_is_waiting = false; + } + // On open error, behave the same as validation failure: advance + // to next file and return the error. + Err(err) => { + self.file_num += 1; + self.file_confirmed = false; + self.file_is_waiting = false; + return Err(err.into()); + } + } + } + } + DataSource::MemoryCursor(c) => { + if self.data_stream.is_none() { + let mut t = std::io::Cursor::new(Vec::new()); + std::mem::swap(&mut t, c); + self.data_stream = Some(DataStream::BufStream(TokioBufStream::new(t))); + } + } + } + Ok(false) + } + + /// Get current file's digest (last_modified, file_size) for overwrite detection. + async fn get_current_digest(&self) -> ResultType<(u64, u64)> { + let meta = match self.data_stream.as_ref().ok_or(anyhow!("file is None"))? { + DataStream::FileStream(file) => file.metadata().await?, + DataStream::BufStream(_) => bail!("No digest for buf stream"), + }; + let last_modified = meta + .modified()? + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + Ok((last_modified, meta.len())) + } + + async fn init_data_stream(&mut self, stream: &mut crate::Stream) -> ResultType<()> { + if self.open_data_stream().await? { + return Ok(()); + } + if self.r#type == JobType::Generic + && self.enable_overwrite_detection + && !self.file_confirmed() + && !self.file_is_waiting() + { + self.send_current_digest(stream).await?; + self.set_file_is_waiting(true); + } + Ok(()) + } + + /// Initialize data stream for CM (Connection Manager) scenario. + /// Returns digest info (last_modified, file_size) if overwrite detection is enabled, + /// so caller can send it via IPC instead of network stream. + /// Returns Ok(None) if job is done or already initialized. + pub async fn init_data_stream_for_cm(&mut self) -> ResultType> { + if self.open_data_stream().await? { + return Ok(None); + } + // For overwrite detection, return digest info instead of sending via stream + if self.r#type == JobType::Generic + && self.enable_overwrite_detection + && !self.file_confirmed() + && !self.file_is_waiting() + { + let digest = self.get_current_digest().await?; + self.set_file_is_waiting(true); + return Ok(Some(digest)); + } + Ok(None) + } + + pub async fn read(&mut self) -> ResultType> { + if self.r#type == JobType::Generic { + if self.enable_overwrite_detection && !self.file_confirmed() { + return Ok(None); + } + } + + let file_num = self.file_num as usize; + let name = match &self.data_source { + DataSource::FilePath(p) => { + if file_num >= self.files.len() { + self.data_stream.take(); + return Ok(None); + }; + if self.files.len() == 1 && self.files[file_num].name.is_empty() { + p.file_name() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("") + } else { + &self.files[file_num].name + } + } + DataSource::MemoryCursor(..) => "", + }; + const BUF_SIZE: usize = 128 * 1024; + let mut buf: Vec = vec![0; BUF_SIZE]; + let mut compressed = false; + let mut offset: usize = 0; + loop { + match self + .data_stream + .as_mut() + .ok_or(anyhow!("data stream is None"))? + .read(&mut buf[offset..]) + .await + { + Err(err) => { + self.file_num += 1; + self.data_stream = None; + self.file_confirmed = false; + self.file_is_waiting = false; + return Err(err.into()); + } + Ok(n) => { + offset += n; + if n == 0 || offset == BUF_SIZE { + break; + } + } + } + } + unsafe { buf.set_len(offset) }; + if offset == 0 { + if matches!(self.data_source, DataSource::MemoryCursor(_)) { + self.data_stream.take(); + return Ok(None); + } + self.file_num += 1; + self.data_stream = None; + self.file_confirmed = false; + self.file_is_waiting = false; + } else { + self.finished_size += offset as u64; + if matches!(self.data_source, DataSource::FilePath(_)) && !is_compressed_file(name) { + let tmp = compress(&buf); + if tmp.len() < buf.len() { + buf = tmp; + compressed = true; + } + } + self.transferred += buf.len() as u64; + } + Ok(Some(FileTransferBlock { + id: self.id, + file_num: file_num as _, + data: buf.into(), + compressed, + ..Default::default() + })) + } + + // Only for generic job and file stream + async fn send_current_digest(&mut self, stream: &mut Stream) -> ResultType<()> { + let (last_modified, file_size) = self.get_current_digest().await?; + let mut msg = Message::new(); + let mut resp = FileResponse::new(); + resp.set_digest(FileTransferDigest { + id: self.id, + file_num: self.file_num, + last_modified, + file_size, + is_resume: self.is_resume, + ..Default::default() + }); + msg.set_file_response(resp); + stream.send(&msg).await?; + log::info!( + "id: {}, file_num: {}, digest message is sent. waiting for confirm. msg: {:?}", + self.id, + self.file_num, + msg + ); + Ok(()) + } + + pub fn set_overwrite_strategy(&mut self, overwrite_strategy: Option) { + self.default_overwrite_strategy = overwrite_strategy; + } + + pub fn default_overwrite_strategy(&self) -> Option { + self.default_overwrite_strategy + } + + pub fn set_file_confirmed(&mut self, file_confirmed: bool) { + log::info!("id: {}, file_confirmed: {}", self.id, file_confirmed); + self.file_confirmed = file_confirmed; + self.file_skipped = false; + } + + pub fn set_file_is_waiting(&mut self, file_is_waiting: bool) { + self.file_is_waiting = file_is_waiting; + } + + #[inline] + pub fn file_is_waiting(&self) -> bool { + self.file_is_waiting + } + + #[inline] + pub fn file_confirmed(&self) -> bool { + self.file_confirmed + } + + /// Indicating whether the last file is skipped + #[inline] + pub fn file_skipped(&self) -> bool { + self.file_skipped + } + + /// Indicating whether the whole task is skipped + #[inline] + pub fn job_skipped(&self) -> bool { + self.file_skipped() && self.files.len() == 1 + } + + /// Check whether the job is completed after `read` returns `None` + /// This is a helper function which gives additional lifecycle when the job reads `None`. + /// If returns `true`, it means we can delete the job automatically. `False` otherwise. + /// + /// [`Note`] + /// Conditions: + /// 1. Files are not waiting for confirmation by peers. + #[inline] + pub fn job_completed(&self) -> bool { + // has no error, Condition 2 + !self.enable_overwrite_detection || (!self.file_confirmed && !self.file_is_waiting) + } + + /// Get job error message, useful for getting status when job had finished + pub fn job_error(&self) -> Option { + if self.job_skipped() { + return Some("skipped".to_string()); + } + None + } + + pub fn set_file_skipped(&mut self) -> bool { + log::debug!("skip file {} in job {}", self.file_num, self.id); + self.data_stream.take(); + self.set_file_confirmed(false); + self.set_file_is_waiting(false); + self.file_num += 1; + self.file_skipped = true; + true + } + + async fn set_stream_offset(&mut self, file_num: usize, offset: u64) { + if let DataSource::FilePath(p) = &self.data_source { + let entry = &self.files[file_num]; + let Some(path) = self.resolve_entry_path(p, &entry.name) else { + return; + }; + let file_path = get_string(&path); + let download_path = format!("{}.download", &file_path); + let digest_path = format!("{}.digest", &file_path); + + let mut f = if Path::new(&download_path).exists() && Path::new(&digest_path).exists() { + // If both download and digest files exist, seek (writer) to the offset + // NOTE: same as write path: best-effort symlink validation happened earlier, + // but this reopen remains TOCTOU-prone by design for now. + match OpenOptions::new() + .create(true) + .write(true) + .open(&download_path) + .await + { + Ok(f) => f, + Err(e) => { + log::warn!("Failed to open file {}: {}", download_path, e); + return; + } + } + } else if Path::new(&file_path).exists() { + // If `file_path` exists, seek (reader) to the offset + match File::open(&file_path).await { + Ok(f) => f, + Err(e) => { + log::warn!("Failed to open file {}: {}", file_path, e); + return; + } + } + } else { + log::warn!( + "File {} not found, cannot seek to offset {}", + file_path, + offset + ); + return; + }; + if f.seek(std::io::SeekFrom::Start(offset)).await.is_ok() { + self.data_stream = Some(DataStream::FileStream(f)); + self.transferred += offset; + self.finished_size += offset; + } + } + } + + pub async fn confirm(&mut self, r: &FileTransferSendConfirmRequest) -> bool { + if self.file_num() != r.file_num { + // This branch will always be hit if: + // 1. `confirm()` is called in `ui_cm_interface.rs` + // 2. Not resuming + // + // It is ok. Because `confirm()` in `ui_cm_interface.rs` is only used for resuming. + log::info!("file num truncated, ignoring"); + } else { + match r.union { + Some(file_transfer_send_confirm_request::Union::Skip(s)) => { + if s { + self.set_file_skipped(); + } else { + self.set_file_confirmed(true); + } + } + Some(file_transfer_send_confirm_request::Union::OffsetBlk(offset)) => { + self.set_file_confirmed(true); + // If offset is greater than 0, we need to seek to the offset + if offset > 0 { + self.set_stream_offset(r.file_num as usize, offset as u64) + .await; + } + } + _ => {} + } + } + true + } + + #[inline] + pub fn gen_meta(&self) -> TransferJobMeta { + TransferJobMeta { + id: self.id, + remote: self.remote.to_string(), + to: self.data_source.to_meta(), + file_num: self.file_num, + show_hidden: self.show_hidden, + is_remote: self.is_remote, + } + } +} + +#[inline] +pub fn new_error(id: i32, err: T, file_num: i32) -> Message { + let mut resp = FileResponse::new(); + resp.set_error(FileTransferError { + id, + error: err.to_string(), + file_num, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_file_response(resp); + msg_out +} + +#[inline] +pub fn new_dir(id: i32, path: String, files: Vec) -> Message { + let mut resp = FileResponse::new(); + resp.set_dir(FileDirectory { + id, + path, + entries: files, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_file_response(resp); + msg_out +} + +#[inline] +pub fn new_block(block: FileTransferBlock) -> Message { + let mut resp = FileResponse::new(); + resp.set_block(block); + let mut msg_out = Message::new(); + msg_out.set_file_response(resp); + msg_out +} + +#[inline] +pub fn new_send_confirm(r: FileTransferSendConfirmRequest) -> Message { + let mut msg_out = Message::new(); + let mut action = FileAction::new(); + action.set_send_confirm(r); + msg_out.set_file_action(action); + msg_out +} + +#[inline] +pub fn new_receive( + id: i32, + path: String, + file_num: i32, + files: Vec, + total_size: u64, +) -> Message { + let mut action = FileAction::new(); + action.set_receive(FileTransferReceiveRequest { + id, + path, + files, + file_num, + total_size, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_file_action(action); + msg_out +} + +#[inline] +pub fn new_send( + id: i32, + r#type: JobType, + path: String, + file_num: i32, + include_hidden: bool, +) -> Message { + log::info!("new send: {}, id: {}", path, id); + let mut action = FileAction::new(); + let t: file_transfer_send_request::FileType = r#type.into(); + action.set_send(FileTransferSendRequest { + id, + path, + include_hidden, + file_num, + file_type: t.into(), + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_file_action(action); + msg_out +} + +#[inline] +pub fn new_done(id: i32, file_num: i32) -> Message { + let mut resp = FileResponse::new(); + resp.set_done(FileTransferDone { + id, + file_num, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_file_response(resp); + msg_out +} + +#[inline] +pub fn remove_job(id: i32, jobs: &mut Vec) -> Option { + jobs.iter() + .position(|x| x.id() == id) + .map(|index| jobs.remove(index)) +} + +#[inline] +pub fn get_job(id: i32, jobs: &mut [TransferJob]) -> Option<&mut TransferJob> { + jobs.iter_mut().find(|x| x.id() == id) +} + +#[inline] +pub fn get_job_immutable(id: i32, jobs: &[TransferJob]) -> Option<&TransferJob> { + jobs.iter().find(|x| x.id() == id) +} + +async fn init_jobs(jobs: &mut Vec, stream: &mut crate::Stream) -> ResultType<()> { + for job in jobs.iter_mut() { + if job.is_last_job { + continue; + } + if let Err(err) = job.init_data_stream(stream).await { + stream + .send(&new_error(job.id(), err, job.file_num())) + .await?; + } + } + Ok(()) +} + +pub async fn handle_read_jobs( + jobs: &mut Vec, + stream: &mut crate::Stream, +) -> ResultType { + init_jobs(jobs, stream).await?; + + let mut job_log = Default::default(); + let mut finished = Vec::new(); + for job in jobs.iter_mut() { + if job.is_last_job { + continue; + } + match job.read().await { + Err(err) => { + stream + .send(&new_error(job.id(), err, job.file_num())) + .await?; + } + Ok(Some(block)) => { + stream.send(&new_block(block)).await?; + } + Ok(None) => { + if job.job_completed() { + job_log = serialize_transfer_job(job, true, false, ""); + finished.push(job.id()); + match job.job_error() { + Some(err) => { + job_log = serialize_transfer_job(job, false, false, &err); + stream + .send(&new_error(job.id(), err, job.file_num())) + .await? + } + None => stream.send(&new_done(job.id(), job.file_num())).await?, + } + } else { + // waiting confirmation. + } + } + } + // Break to handle jobs one by one. + break; + } + for id in finished { + let _ = remove_job(id, jobs); + } + Ok(job_log) +} + +pub fn remove_all_empty_dir(path: &Path) -> ResultType<()> { + let fd = read_dir(path, true)?; + for entry in fd.entries.iter() { + match entry.entry_type.enum_value() { + Ok(FileType::Dir) => { + remove_all_empty_dir(&path.join(&entry.name)).ok(); + } + Ok(FileType::DirLink) | Ok(FileType::FileLink) => { + std::fs::remove_file(path.join(&entry.name)).ok(); + } + _ => {} + } + } + std::fs::remove_dir(path).ok(); + Ok(()) +} + +#[inline] +pub fn remove_file(file: &str) -> ResultType<()> { + validate_fs_path_argument(file, "file path")?; + std::fs::remove_file(get_path(file))?; + Ok(()) +} + +#[inline] +pub fn create_dir(dir: &str) -> ResultType<()> { + validate_fs_path_argument(dir, "directory path")?; + std::fs::create_dir_all(get_path(dir))?; + Ok(()) +} + +#[inline] +pub fn rename_file(path: &str, new_name: &str) -> ResultType<()> { + validate_fs_path_argument(path, "path")?; + if new_name.is_empty() { + bail!("new file name cannot be empty"); + } + validate_file_name_no_traversal(new_name)?; + let path = std::path::Path::new(&path); + if path.exists() { + let dir = path + .parent() + .ok_or(anyhow!("Parent directoy of {path:?} not exists"))?; + let new_path = dir.join(&new_name); + std::fs::rename(&path, &new_path)?; + Ok(()) + } else { + bail!("{path:?} not exists"); + } +} + +#[inline] +pub fn transform_windows_path(entries: &mut Vec) { + for entry in entries { + entry.name = entry.name.replace('\\', "/"); + } +} + +pub enum DigestCheckResult { + IsSame, + NeedConfirm(FileTransferDigest), + NoSuchFile, +} + +#[inline] +pub fn is_write_need_confirmation( + is_resume: bool, + file_path: &str, + digest: &FileTransferDigest, +) -> ResultType { + let path = Path::new(file_path); + let digest_file = format!("{}.digest", file_path); + let download_file = format!("{}.download", file_path); + if is_resume && Path::new(&digest_file).exists() && Path::new(&download_file).exists() { + // If the digest file exists, it means the file was transferred before. + // We can use the digest file to check whether the file is the same. + if let Ok(content) = std::fs::read_to_string(digest_file) { + if let Ok(local_digest) = serde_json::from_str::(&content) { + let is_identical = local_digest.modified == digest.last_modified + && local_digest.size == digest.file_size; + if is_identical { + if let Ok(download_metadata) = std::fs::metadata(download_file) { + // Get the file size of the local file + // Only send confirmation if the file is not empty. + let transferred_size = download_metadata.len(); + if transferred_size > 0 { + return Ok(DigestCheckResult::NeedConfirm(FileTransferDigest { + id: digest.id, + file_num: digest.file_num, + last_modified: digest.last_modified, + file_size: digest.file_size, + is_identical, + transferred_size, + ..Default::default() + })); + } + } + } + } + } + } + + if path.exists() && path.is_file() { + let metadata = std::fs::metadata(path)?; + let modified_time = metadata.modified()?; + let remote_mt = Duration::from_secs(digest.last_modified); + let local_mt = modified_time.duration_since(UNIX_EPOCH)?; + // [Note] + // We decide to give the decision whether to override the existing file to users, + // which obey the behavior of the file manager in our system. + let mut is_identical = false; + if remote_mt == local_mt && digest.file_size == metadata.len() { + is_identical = true; + } + Ok(DigestCheckResult::NeedConfirm(FileTransferDigest { + id: digest.id, + file_num: digest.file_num, + last_modified: local_mt.as_secs(), + file_size: metadata.len(), + is_identical, + ..Default::default() + })) + } else { + // If the file does not exist, or the digest file and download file do not exist, we return NoSuchFile. + Ok(DigestCheckResult::NoSuchFile) + } +} + +pub fn serialize_transfer_jobs(jobs: &[TransferJob]) -> String { + let mut v = vec![]; + for job in jobs { + let value = serde_json::to_value(job).unwrap_or_default(); + v.push(value); + } + serde_json::to_string(&v).unwrap_or_default() +} + +pub fn serialize_transfer_job(job: &TransferJob, done: bool, cancel: bool, error: &str) -> String { + let mut value = serde_json::to_value(job).unwrap_or_default(); + value["done"] = json!(done); + value["cancel"] = json!(cancel); + value["error"] = json!(error); + serde_json::to_string(&value).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + Self { + path: unique_temp_dir(prefix), + } + } + + fn join(&self, path: &str) -> PathBuf { + self.path.join(path) + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}_{}", prefix, std::process::id(), timestamp)) + } + + fn new_file_entry(name: &str) -> FileEntry { + let mut entry = FileEntry::new(); + entry.name = name.to_string(); + entry + } + + fn new_validation_job(id: i32) -> TransferJob { + TransferJob::new_write( + id, + JobType::Generic, + "/fake/remote".to_string(), + DataSource::FilePath(std::env::temp_dir().join(format!("rustdesk_validation_{id}"))), + 0, + false, + true, + false, + ) + } + + fn new_write_job(id: i32, download_dir: PathBuf, name: &str) -> ResultType { + let job = TransferJob::new_write( + id, + JobType::Generic, + "/fake/remote".to_string(), + DataSource::FilePath(download_dir), + 0, + false, + true, + false, + ) + .with_files(vec![new_file_entry(name)])?; + Ok(job) + } + + fn assert_err_contains(err: anyhow::Error, expected: &str) { + assert!( + err.to_string().contains(expected), + "expected error containing '{}', got: {}", + expected, + err + ); + } + + #[test] + fn path_traversal_e2e_write_rejects_relative_escape() { + let tmp_root = TestTempDir::new("rustdesk_e2e_relative"); + let downloads = tmp_root.join("downloads"); + std::fs::create_dir_all(&downloads).expect("create downloads dir"); + + let err = new_write_job(1, downloads, "../traversal_proof.txt") + .expect_err("relative path traversal must be rejected"); + assert_err_contains(err, "path traversal"); + assert!(!tmp_root.join("traversal_proof.txt").exists()); + } + + #[test] + fn path_traversal_e2e_write_rejects_absolute_path() { + let tmp_root = TestTempDir::new("rustdesk_e2e_absolute"); + let downloads = tmp_root.join("downloads"); + let absolute_target = tmp_root.join("fake_ssh").join("authorized_keys"); + std::fs::create_dir_all(&downloads).expect("create downloads dir"); + + let err = new_write_job(2, downloads, &absolute_target.to_string_lossy()) + .expect_err("absolute path must be rejected"); + assert_err_contains(err, "absolute path"); + assert!(!absolute_target.exists()); + } + + #[test] + #[cfg_attr(windows, ignore = "requires symlink privilege to create test symlink")] + fn path_traversal_e2e_write_rejects_symlink_escape() { + let tmp_root = TestTempDir::new("rustdesk_e2e_symlink"); + let downloads = tmp_root.join("downloads"); + let outside = tmp_root.join("outside"); + let escaped_target = outside.join("escape.txt"); + std::fs::create_dir_all(&downloads).expect("create downloads dir"); + std::fs::create_dir_all(&outside).expect("create outside dir"); + + let symlink_path = downloads.join("link"); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + symlink(&outside, &symlink_path).expect("create symlink for test"); + } + #[cfg(windows)] + { + use std::os::windows::fs::symlink_dir; + symlink_dir(&outside, &symlink_path).expect("create directory symlink for test"); + } + + let err = new_write_job(3, downloads, "link/escape.txt") + .expect_err("symlink traversal must be rejected"); + assert_err_contains(err, "symlink"); + assert!(!escaped_target.exists()); + } + + #[test] + fn set_files_allows_single_empty_name_for_single_file_transfer() { + let mut job = new_validation_job(101); + assert!(job.set_files(vec![new_file_entry("")]).is_ok()); + } + + #[test] + fn set_files_rejects_empty_name_in_multi_file_transfer() { + let mut job = new_validation_job(102); + let err = job + .set_files(vec![new_file_entry(""), new_file_entry("ok.txt")]) + .expect_err("empty name in multi-file transfer must be rejected"); + assert_err_contains(err, "empty file name"); + } + + #[test] + fn set_files_rejects_null_byte_name() { + let mut job = new_validation_job(103); + let err = job + .set_files(vec![new_file_entry("bad\0name.txt")]) + .expect_err("null byte in file name must be rejected"); + assert_err_contains(err, "null bytes"); + } + + #[test] + fn set_files_rejects_mixed_entries_when_one_is_traversal() { + let mut job = new_validation_job(104); + let err = job + .set_files(vec![ + new_file_entry("safe/file.txt"), + new_file_entry("../../escape.txt"), + ]) + .expect_err("any traversal entry must reject the full file list"); + assert_err_contains(err, "path traversal"); + } + + #[cfg(windows)] + #[test] + fn set_files_rejects_unc_absolute_path() { + let mut job = new_validation_job(105); + let err = job + .set_files(vec![new_file_entry("\\\\server\\share\\payload.txt")]) + .expect_err("UNC absolute path must be rejected"); + assert_err_contains(err, "absolute path"); + } + + #[cfg(not(windows))] + #[test] + fn set_files_allows_backslash_prefixed_name_on_unix() { + let mut job = new_validation_job(105); + assert!(job + .set_files(vec![new_file_entry("\\\\server\\share\\payload.txt")]) + .is_ok()); + } + + #[test] + fn remove_file_rejects_empty_path() { + let err = remove_file("").expect_err("empty file path must be rejected"); + assert_err_contains(err, "cannot be empty"); + } + + #[test] + fn remove_file_rejects_null_byte_path() { + let err = remove_file("bad\0path").expect_err("null byte path must be rejected"); + assert_err_contains(err, "null bytes"); + } + + #[test] + fn create_dir_rejects_empty_path() { + let err = create_dir("").expect_err("empty directory path must be rejected"); + assert_err_contains(err, "cannot be empty"); + } + + #[test] + fn create_dir_rejects_null_byte_path() { + let err = create_dir("bad\0path").expect_err("null byte path must be rejected"); + assert_err_contains(err, "null bytes"); + } + + #[test] + fn rename_file_rejects_invalid_new_name() { + let tmp_root = TestTempDir::new("rustdesk_rename_invalid"); + let src = tmp_root.join("source.txt"); + std::fs::create_dir_all(&tmp_root.path).expect("create temp dir"); + std::fs::write(&src, b"content").expect("create source file"); + + let src_str = src.to_string_lossy().to_string(); + + let err_empty = + rename_file(&src_str, "").expect_err("empty new file name must be rejected"); + assert_err_contains(err_empty, "cannot be empty"); + + let err_traversal = rename_file(&src_str, "../escape.txt") + .expect_err("traversal new file name must be rejected"); + assert_err_contains(err_traversal, "path traversal"); + + let err_null = rename_file(&src_str, "bad\0name.txt") + .expect_err("null byte in new file name must be rejected"); + assert_err_contains(err_null, "null bytes"); + + #[cfg(windows)] + { + let err_abs = rename_file(&src_str, "C:\\Windows\\Temp\\payload.txt") + .expect_err("absolute new file name must be rejected"); + assert_err_contains(err_abs, "absolute path"); + } + #[cfg(not(windows))] + { + let err_abs = rename_file(&src_str, "/tmp/payload.txt") + .expect_err("absolute new file name must be rejected"); + assert_err_contains(err_abs, "absolute path"); + } + } + + #[test] + fn rename_file_accepts_valid_new_name() { + let tmp_root = TestTempDir::new("rustdesk_rename_ok"); + let src = tmp_root.join("rename_src.txt"); + let dst = tmp_root.join("renamed.txt"); + std::fs::create_dir_all(&tmp_root.path).expect("create temp dir"); + std::fs::write(&src, b"content").expect("create source file"); + + let src_str = src.to_string_lossy().to_string(); + rename_file(&src_str, "renamed.txt").expect("rename should succeed"); + + assert!(!src.exists()); + assert!(dst.exists()); + } + + #[cfg(windows)] + #[test] + fn set_files_rejects_windows_drive_absolute_path() { + let mut job = new_validation_job(106); + let err = job + .set_files(vec![new_file_entry("C:\\Windows\\Temp\\payload.txt")]) + .expect_err("drive-letter absolute path must be rejected"); + assert_err_contains(err, "absolute path"); + } + + #[cfg(windows)] + #[test] + fn set_files_rejects_windows_verbatim_drive_absolute_path() { + let mut job = new_validation_job(1061); + let err = job + .set_files(vec![new_file_entry(r"\\?\C:\Windows\Temp\x.txt")]) + .expect_err("verbatim drive absolute path must be rejected"); + assert_err_contains(err, "absolute path"); + } +} diff --git a/libs/hbb_common/src/keyboard.rs b/libs/hbb_common/src/keyboard.rs new file mode 100644 index 00000000000..10979f520e9 --- /dev/null +++ b/libs/hbb_common/src/keyboard.rs @@ -0,0 +1,39 @@ +use std::{fmt, slice::Iter, str::FromStr}; + +use crate::protos::message::KeyboardMode; + +impl fmt::Display for KeyboardMode { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + KeyboardMode::Legacy => write!(f, "legacy"), + KeyboardMode::Map => write!(f, "map"), + KeyboardMode::Translate => write!(f, "translate"), + KeyboardMode::Auto => write!(f, "auto"), + } + } +} + +impl FromStr for KeyboardMode { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "legacy" => Ok(KeyboardMode::Legacy), + "map" => Ok(KeyboardMode::Map), + "translate" => Ok(KeyboardMode::Translate), + "auto" => Ok(KeyboardMode::Auto), + _ => Err(()), + } + } +} + +impl KeyboardMode { + pub fn iter() -> Iter<'static, KeyboardMode> { + static KEYBOARD_MODES: [KeyboardMode; 4] = [ + KeyboardMode::Legacy, + KeyboardMode::Map, + KeyboardMode::Translate, + KeyboardMode::Auto, + ]; + KEYBOARD_MODES.iter() + } +} diff --git a/libs/hbb_common/src/lib.rs b/libs/hbb_common/src/lib.rs new file mode 100644 index 00000000000..2b356421906 --- /dev/null +++ b/libs/hbb_common/src/lib.rs @@ -0,0 +1,633 @@ +pub mod compress; +pub mod platform; +pub mod protos; +pub use bytes; +use config::Config; +pub use futures; +pub use protobuf; +pub use protos::message as message_proto; +pub use protos::rendezvous as rendezvous_proto; +use serde_derive::{Deserialize, Serialize}; +use std::{ + fs::File, + io::{self, BufRead}, + net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}, + path::Path, + time::{self, SystemTime, UNIX_EPOCH}, +}; +pub use tokio; +pub use tokio_util; +pub mod proxy; +pub mod socket_client; +pub mod tcp; +pub mod udp; +pub use env_logger; +pub use log; +pub mod bytes_codec; +pub use anyhow::{self, bail}; +pub use futures_util; +pub mod config; +pub mod fs; +pub mod mem; +pub use lazy_static; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use mac_address; +pub use rand; +pub use regex; +pub use sodiumoxide; +pub use tokio_socks; +pub use tokio_socks::IntoTargetAddr; +pub use tokio_socks::TargetAddr; +pub mod password_security; +pub use chrono; +pub use directories_next; +pub use libc; +pub mod keyboard; +pub use base64; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use dlopen; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use machine_uid; +pub use serde_derive; +pub use serde_json; +pub use sha2; +pub use sysinfo; +pub use thiserror; +pub use toml; +pub use uuid; +pub mod fingerprint; +pub use flexi_logger; +pub mod stream; +pub mod websocket; +#[cfg(feature = "webrtc")] +pub mod webrtc; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub use rustls_platform_verifier; +pub use stream::Stream; +pub use whoami; +pub mod tls; +pub mod verifier; +pub use async_recursion; +#[cfg(target_os = "linux")] +pub use users; +pub use libloading; +#[cfg(target_os = "linux")] +pub use x11; + +pub type SessionID = uuid::Uuid; + +#[inline] +pub async fn sleep(sec: f32) { + tokio::time::sleep(time::Duration::from_secs_f32(sec)).await; +} + +#[macro_export] +macro_rules! allow_err { + ($e:expr) => { + if let Err(err) = $e { + log::debug!( + "{:?}, {}:{}:{}:{}", + err, + module_path!(), + file!(), + line!(), + column!() + ); + } else { + } + }; + + ($e:expr, $($arg:tt)*) => { + if let Err(err) = $e { + log::debug!( + "{:?}, {}, {}:{}:{}:{}", + err, + format_args!($($arg)*), + module_path!(), + file!(), + line!(), + column!() + ); + } else { + } + }; +} + +#[inline] +pub fn timeout(ms: u64, future: T) -> tokio::time::Timeout { + tokio::time::timeout(std::time::Duration::from_millis(ms), future) +} + +pub type ResultType = anyhow::Result; + +/// Certain router and firewalls scan the packet and if they +/// find an IP address belonging to their pool that they use to do the NAT mapping/translation, so here we mangle the ip address + +pub struct AddrMangle(); + +#[inline] +pub fn try_into_v4(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) if !addr.ip().is_loopback() => { + if let Some(v4) = v6.ip().to_ipv4() { + SocketAddr::new(IpAddr::V4(v4), addr.port()) + } else { + addr + } + } + _ => addr, + } +} + +impl AddrMangle { + pub fn encode(addr: SocketAddr) -> Vec { + // not work with [:1]: + let addr = try_into_v4(addr); + match addr { + SocketAddr::V4(addr_v4) => { + let tm = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(std::time::Duration::ZERO) + .as_micros() as u32) as u128; + let ip = u32::from_le_bytes(addr_v4.ip().octets()) as u128; + let port = addr.port() as u128; + let v = ((ip + tm) << 49) | (tm << 17) | (port + (tm & 0xFFFF)); + let bytes = v.to_le_bytes(); + let mut n_padding = 0; + for i in bytes.iter().rev() { + if i == &0u8 { + n_padding += 1; + } else { + break; + } + } + bytes[..(16 - n_padding)].to_vec() + } + SocketAddr::V6(addr_v6) => { + let mut x = addr_v6.ip().octets().to_vec(); + let port: [u8; 2] = addr_v6.port().to_le_bytes(); + x.push(port[0]); + x.push(port[1]); + x + } + } + } + + pub fn decode(bytes: &[u8]) -> SocketAddr { + use std::convert::TryInto; + + if bytes.len() > 16 { + if bytes.len() != 18 { + return Config::get_any_listen_addr(false); + } + let tmp: [u8; 2] = bytes[16..].try_into().unwrap_or_default(); + let port = u16::from_le_bytes(tmp); + let tmp: [u8; 16] = bytes[..16].try_into().unwrap_or_default(); + let ip = std::net::Ipv6Addr::from(tmp); + return SocketAddr::new(IpAddr::V6(ip), port); + } + let mut padded = [0u8; 16]; + padded[..bytes.len()].copy_from_slice(bytes); + let number = u128::from_le_bytes(padded); + let tm = (number >> 17) & (u32::max_value() as u128); + let ip = (((number >> 49) - tm) as u32).to_le_bytes(); + let port = (number & 0xFFFFFF) - (tm & 0xFFFF); + SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(ip[0], ip[1], ip[2], ip[3]), + port as u16, + )) + } +} + +pub fn get_version_from_url(url: &str) -> String { + let n = url.chars().count(); + let a = url.chars().rev().position(|x| x == '-'); + if let Some(a) = a { + let b = url.chars().rev().position(|x| x == '.'); + if let Some(b) = b { + if a > b { + if url + .chars() + .skip(n - b) + .collect::() + .parse::() + .is_ok() + { + return url.chars().skip(n - a).collect(); + } else { + return url.chars().skip(n - a).take(a - b - 1).collect(); + } + } else { + return url.chars().skip(n - a).collect(); + } + } + } + "".to_owned() +} + +pub fn gen_version() { + println!("cargo:rerun-if-changed=Cargo.toml"); + use std::io::prelude::*; + let mut file = File::create("./src/version.rs").unwrap(); + for line in read_lines("Cargo.toml").unwrap().flatten() { + let ab: Vec<&str> = line.split('=').map(|x| x.trim()).collect(); + if ab.len() == 2 && ab[0] == "version" { + file.write_all(format!("pub const VERSION: &str = {};\n", ab[1]).as_bytes()) + .ok(); + break; + } + } + // generate build date + let build_date = format!("{}", chrono::Local::now().format("%Y-%m-%d %H:%M")); + file.write_all( + format!("#[allow(dead_code)]\npub const BUILD_DATE: &str = \"{build_date}\";\n").as_bytes(), + ) + .ok(); + file.sync_all().ok(); +} + +fn read_lines

(filename: P) -> io::Result>> +where + P: AsRef, +{ + let file = File::open(filename)?; + Ok(io::BufReader::new(file).lines()) +} + +pub fn is_valid_custom_id(id: &str) -> bool { + regex::Regex::new(r"^[a-zA-Z][\w-]{5,15}$") + .unwrap() + .is_match(id) +} + +// Support 1.1.10-1, the number after - is a patch version. +pub fn get_version_number(v: &str) -> i64 { + let mut versions = v.split('-'); + + let mut n = 0; + + // The first part is the version number. + // 1.1.10 -> 1001100, 1.2.3 -> 1001030, multiple the last number by 10 + // to leave space for patch version. + if let Some(v) = versions.next() { + let mut last = 0; + for x in v.split('.') { + last = x.parse::().unwrap_or(0); + n = n * 1000 + last; + } + n -= last; + n += last * 10; + } + + if let Some(v) = versions.next() { + n += v.parse::().unwrap_or(0); + } + + // Ignore the rest + + n +} + +pub fn get_modified_time(path: &std::path::Path) -> SystemTime { + std::fs::metadata(path) + .map(|m| m.modified().unwrap_or(UNIX_EPOCH)) + .unwrap_or(UNIX_EPOCH) +} + +pub fn get_created_time(path: &std::path::Path) -> SystemTime { + std::fs::metadata(path) + .map(|m| m.created().unwrap_or(UNIX_EPOCH)) + .unwrap_or(UNIX_EPOCH) +} + +pub fn get_exe_time() -> SystemTime { + std::env::current_exe().map_or(UNIX_EPOCH, |path| { + let m = get_modified_time(&path); + let c = get_created_time(&path); + if m > c { + m + } else { + c + } + }) +} + +/// Known cases where machine_uid::get() may fail: +/// - Windows shutdown: "The media is write protected. (os error 19)" +/// - macOS (hard to reproduce, reproduced at login screen): "No matching IOPlatformUUID in `ioreg -rd1 -c IOPlatformExpertDevice` command" +pub fn get_uuid() -> Vec { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static CACHED_MACHINE_UID: std::sync::OnceLock> = std::sync::OnceLock::new(); + // Throttle only applies to the fallback machine_uid::get() log below, not the Once::call_once retry logs. + static LOG_COUNT: AtomicUsize = AtomicUsize::new(0); + + // Only macOS needs retry logic here because: + // - macOS: in testing, only one failure occurred when reading at 50ms intervals, so retry helps + // - Windows: failures during shutdown are persistent, retrying is pointless + #[cfg(target_os = "macos")] + { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + // Keep in sync with upstream handling: + // https://github.com/rustdesk/rustdesk/blob/85db6779828349b23ca3eba91cc7cd36c5337797/src/common.rs#L822 + let username = whoami::username().trim_end_matches('\0').to_owned(); + let max_retries = if username == "root" { 16 } else { 8 }; + for i in 0..max_retries { + match machine_uid::get() { + Ok(id) => { + let _ = CACHED_MACHINE_UID.set(id.into()); + return; + } + Err(e) => { + log::error!("Failed to get machine uid in macOS retry #{i}: {e}"); + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }); + } + + if let Some(uid) = CACHED_MACHINE_UID.get() { + return uid.clone(); + } + + match machine_uid::get() { + Ok(id) => { + let uid: Vec = id.into(); + let _ = CACHED_MACHINE_UID.set(uid.clone()); + return uid; + } + Err(e) => { + if LOG_COUNT + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| { + (count < 30).then_some(count + 1) + }) + .is_ok() + { + log::error!("Failed to get machine uid: {e}"); + } + } + } + } + Config::get_key_pair().1 +} + +#[inline] +pub fn get_time() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) as _ +} + +#[inline] +pub fn is_ipv4_str(id: &str) -> bool { + if let Ok(reg) = regex::Regex::new( + r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(:\d+)?$", + ) { + reg.is_match(id) + } else { + false + } +} + +#[inline] +pub fn is_ipv6_str(id: &str) -> bool { + if let Ok(reg) = regex::Regex::new( + r"^((([a-fA-F0-9]{1,4}:{1,2})+[a-fA-F0-9]{1,4})|(\[([a-fA-F0-9]{1,4}:{1,2})+[a-fA-F0-9]{1,4}\]:\d+))$", + ) { + reg.is_match(id) + } else { + false + } +} + +#[inline] +pub fn is_ip_str(id: &str) -> bool { + is_ipv4_str(id) || is_ipv6_str(id) +} + +#[inline] +pub fn is_domain_port_str(id: &str) -> bool { + // modified regex for RFC1123 hostname. check https://stackoverflow.com/a/106223 for original version for hostname. + // according to [TLD List](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) version 2023011700, + // there is no digits in TLD, and length is 2~63. + if let Ok(reg) = regex::Regex::new( + r"(?i)^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z-]{0,61}[a-z]:\d{1,5}$", + ) { + reg.is_match(id) + } else { + false + } +} + +pub fn init_log(_is_async: bool, _name: &str) -> Option { + static INIT: std::sync::Once = std::sync::Once::new(); + #[allow(unused_mut)] + let mut logger_holder: Option = None; + INIT.call_once(|| { + #[cfg(debug_assertions)] + { + use env_logger::*; + init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info,reqwest=warn,rustls=warn,webrtc-sctp=warn,webrtc=warn")); + } + #[cfg(not(debug_assertions))] + { + // https://docs.rs/flexi_logger/latest/flexi_logger/error_info/index.html#write + // though async logger more efficient, but it also causes more problems, disable it for now + let mut path = config::Config::log_path(); + #[cfg(target_os = "android")] + if !config::Config::get_home().exists() { + return; + } + if !_name.is_empty() { + path.push(_name); + } + use flexi_logger::*; + if let Ok(x) = Logger::try_with_env_or_str("debug,reqwest=warn,rustls=warn,webrtc-sctp=warn,webrtc=warn") { + logger_holder = x + .log_to_file(FileSpec::default().directory(path)) + .write_mode(if _is_async { + WriteMode::Async + } else { + WriteMode::Direct + }) + .format(opt_format) + .rotate( + Criterion::Age(Age::Day), + Naming::Timestamps, + Cleanup::KeepLogFiles(31), + ) + .start() + .ok(); + } + } + }); + logger_holder +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct VersionCheckRequest { + #[serde(default)] + pub os: String, + #[serde(default)] + pub os_version: String, + #[serde(default)] + pub arch: String, + #[serde(default)] + pub device_id: Vec, + #[serde(default)] + pub typ: String, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct VersionCheckResponse { + #[serde(default)] + pub url: String, +} + +pub const VER_TYPE_RUSTDESK_CLIENT: &str = "rustdesk-client"; +pub const VER_TYPE_RUSTDESK_SERVER: &str = "rustdesk-server"; + +pub fn version_check_request(typ: String) -> (VersionCheckRequest, String) { + const URL: &str = "https://api.rustdesk.com/version/latest"; + + use sysinfo::System; + let system = System::new(); + let os = system.distribution_id(); + let os_version = system.os_version().unwrap_or_default(); + let arch = std::env::consts::ARCH.to_string(); + #[allow(deprecated)] + let device_id = fingerprint::get_fingerprint(None, None); + ( + VersionCheckRequest { + os, + os_version, + arch, + device_id, + typ, + }, + URL.to_string(), + ) +} + +pub fn time_based_rand() -> u32 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + let mut x = nanos as u64; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + + (x % 32768) as u32 +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_mangle() { + let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(192, 168, 16, 32), 21116)); + assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); + + let addr = "[2001:db8::1]:8080".parse::().unwrap(); + assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); + + let addr = "[2001:db8:ff::1111]:80".parse::().unwrap(); + assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); + } + + #[test] + fn test_allow_err() { + allow_err!(Err("test err") as Result<(), &str>); + allow_err!( + Err("test err with msg") as Result<(), &str>, + "prompt {}", + "failed" + ); + } + + #[test] + fn test_ipv6() { + assert!(is_ipv6_str("1:2:3")); + assert!(is_ipv6_str("[ab:2:3]:12")); + assert!(is_ipv6_str("[ABEF:2a:3]:12")); + assert!(!is_ipv6_str("[ABEG:2a:3]:12")); + assert!(!is_ipv6_str("1[ab:2:3]:12")); + assert!(!is_ipv6_str("1.1.1.1")); + assert!(is_ip_str("1.1.1.1")); + assert!(!is_ipv6_str("1:2:")); + assert!(is_ipv6_str("1:2::0")); + assert!(is_ipv6_str("[1:2::0]:1")); + assert!(!is_ipv6_str("[1:2::0]:")); + assert!(!is_ipv6_str("1:2::0]:1")); + } + + #[test] + fn test_ipv4() { + assert!(is_ipv4_str("1.2.3.4")); + assert!(is_ipv4_str("1.2.3.4:90")); + assert!(is_ipv4_str("192.168.0.1")); + assert!(is_ipv4_str("0.0.0.0")); + assert!(is_ipv4_str("255.255.255.255")); + assert!(!is_ipv4_str("256.0.0.0")); + assert!(!is_ipv4_str("256.256.256.256")); + assert!(!is_ipv4_str("1:2:")); + assert!(!is_ipv4_str("192.168.0.256")); + assert!(!is_ipv4_str("192.168.0.1/24")); + assert!(!is_ipv4_str("192.168.0.")); + assert!(!is_ipv4_str("192.168..1")); + } + + #[test] + fn test_hostname_port() { + assert!(!is_domain_port_str("a:12")); + assert!(!is_domain_port_str("a.b.c:12")); + assert!(is_domain_port_str("test.com:12")); + assert!(is_domain_port_str("test-UPPER.com:12")); + assert!(is_domain_port_str("some-other.domain.com:12")); + assert!(!is_domain_port_str("under_score:12")); + assert!(!is_domain_port_str("a@bc:12")); + assert!(!is_domain_port_str("1.1.1.1:12")); + assert!(!is_domain_port_str("1.2.3:12")); + assert!(!is_domain_port_str("1.2.3.45:12")); + assert!(!is_domain_port_str("a.b.c:123456")); + assert!(!is_domain_port_str("---:12")); + assert!(!is_domain_port_str(".:12")); + // todo: should we also check for these edge cases? + // out-of-range port + assert!(is_domain_port_str("test.com:0")); + assert!(is_domain_port_str("test.com:98989")); + } + + #[test] + fn test_mangle2() { + let addr = "[::ffff:127.0.0.1]:8080".parse().unwrap(); + let addr_v4 = "127.0.0.1:8080".parse().unwrap(); + assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr)), addr_v4); + assert_eq!( + AddrMangle::decode(&AddrMangle::encode("[::127.0.0.1]:8080".parse().unwrap())), + addr_v4 + ); + assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v4)), addr_v4); + let addr_v6 = "[ef::fe]:8080".parse().unwrap(); + assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v6)), addr_v6); + let addr_v6 = "[::1]:8080".parse().unwrap(); + assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v6)), addr_v6); + } + + #[test] + fn test_get_version_number() { + assert_eq!(get_version_number("1.1.10"), 1001100); + assert_eq!(get_version_number("1.1.10-1"), 1001101); + assert_eq!(get_version_number("1.1.11-1"), 1001111); + assert_eq!(get_version_number("1.2.3"), 1002030); + } +} diff --git a/libs/hbb_common/src/mem.rs b/libs/hbb_common/src/mem.rs new file mode 100644 index 00000000000..90a5d6d402e --- /dev/null +++ b/libs/hbb_common/src/mem.rs @@ -0,0 +1,14 @@ +/// SAFETY: the returned Vec must not be resized or reserverd +pub unsafe fn aligned_u8_vec(cap: usize, align: usize) -> Vec { + use std::alloc::*; + + let layout = + Layout::from_size_align(cap, align).expect("invalid aligned value, must be power of 2"); + unsafe { + let ptr = alloc(layout); + if ptr.is_null() { + panic!("failed to allocate {} bytes", cap); + } + Vec::from_raw_parts(ptr, 0, cap) + } +} diff --git a/libs/hbb_common/src/password_security.rs b/libs/hbb_common/src/password_security.rs new file mode 100644 index 00000000000..3f5f32430a9 --- /dev/null +++ b/libs/hbb_common/src/password_security.rs @@ -0,0 +1,651 @@ +use crate::config::Config; +use sodiumoxide::{base64, crypto::secretbox}; +use std::sync::{Arc, RwLock}; + +lazy_static::lazy_static! { + pub static ref TEMPORARY_PASSWORD:Arc> = Arc::new(RwLock::new(get_auto_password())); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VerificationMethod { + OnlyUseTemporaryPassword, + OnlyUsePermanentPassword, + UseBothPasswords, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApproveMode { + Both, + Password, + Click, +} + +fn get_auto_password() -> String { + let len = temporary_password_length(); + if Config::get_bool_option(crate::config::keys::OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD) { + Config::get_auto_numeric_password(len) + } else { + Config::get_auto_password(len) + } +} + +// Should only be called in server +pub fn update_temporary_password() { + *TEMPORARY_PASSWORD.write().unwrap() = get_auto_password(); +} + +// Should only be called in server +pub fn temporary_password() -> String { + TEMPORARY_PASSWORD.read().unwrap().clone() +} + +fn verification_method() -> VerificationMethod { + let method = Config::get_option("verification-method"); + if method == "use-temporary-password" { + VerificationMethod::OnlyUseTemporaryPassword + } else if method == "use-permanent-password" { + VerificationMethod::OnlyUsePermanentPassword + } else { + VerificationMethod::UseBothPasswords // default + } +} + +pub fn temporary_password_length() -> usize { + let length = Config::get_option("temporary-password-length"); + if length == "8" { + 8 + } else if length == "10" { + 10 + } else { + 6 // default + } +} + +pub fn temporary_enabled() -> bool { + verification_method() != VerificationMethod::OnlyUsePermanentPassword +} + +pub fn permanent_enabled() -> bool { + verification_method() != VerificationMethod::OnlyUseTemporaryPassword +} + +pub fn has_valid_password() -> bool { + temporary_enabled() && !temporary_password().is_empty() + || permanent_enabled() && Config::has_permanent_password() +} + +pub fn approve_mode() -> ApproveMode { + let mode = Config::get_option("approve-mode"); + if mode == "password" { + ApproveMode::Password + } else if mode == "click" { + ApproveMode::Click + } else { + ApproveMode::Both + } +} + +pub fn hide_cm() -> bool { + approve_mode() == ApproveMode::Password + && verification_method() == VerificationMethod::OnlyUsePermanentPassword + && crate::config::option2bool("allow-hide-cm", &Config::get_option("allow-hide-cm")) +} + +const VERSION_LEN: usize = 2; +const FORMAT_V1: u8 = 1; + +// Check if data is already encrypted by verifying: +// 1) version prefix "00" +// 2) valid base64 payload +// 3) decoded payload length >= secretbox::MACBYTES +// +// We intentionally avoid trying to decrypt here because key mismatch would cause +// false negatives. +// The decoded payload may be either legacy ciphertext or FORMAT_V1 || nonce || ciphertext. +// Reference: secretbox::seal returns ciphertext length = plaintext length + MACBYTES +// https://github.com/sodiumoxide/sodiumoxide/blob/3057acb1a030ad86ed8892a223d64036ab5e8523/src/crypto/secretbox/xsalsa20poly1305.rs#L67 +fn is_encrypted(v: &[u8]) -> bool { + if v.len() <= VERSION_LEN || !v.starts_with(b"00") { + return false; + } + match base64::decode(&v[VERSION_LEN..], base64::Variant::Original) { + Ok(decoded) => decoded.len() >= sodiumoxide::crypto::secretbox::MACBYTES, + Err(_) => false, + } +} + +pub fn encrypt_str_or_original(s: &str, version: &str, max_len: usize) -> String { + if is_encrypted(s.as_bytes()) { + log::error!("Duplicate encryption!"); + return s.to_owned(); + } + if s.chars().count() > max_len { + return String::default(); + } + if version == "00" { + if let Ok(s) = encrypt(s.as_bytes()) { + return version.to_owned() + &s; + } + } + s.to_owned() +} + +// String: password +// bool: whether decryption is successful +// bool: whether should store to re-encrypt when load +// note: s.len() return length in bytes, s.chars().count() return char count +// &[..2] return the left 2 bytes, s.chars().take(2) return the left 2 chars +pub fn decrypt_str_or_original(s: &str, current_version: &str) -> (String, bool, bool) { + if s.len() > VERSION_LEN { + if s.starts_with("00") { + if let Ok(v) = decrypt(s[VERSION_LEN..].as_bytes()) { + return ( + String::from_utf8_lossy(&v).to_string(), + true, + "00" != current_version, + ); + } + } + } + + // For values that already look encrypted (version prefix + base64), avoid + // repeated store on each load when decryption fails. + ( + s.to_owned(), + false, + !s.is_empty() && !is_encrypted(s.as_bytes()), + ) +} + +pub fn encrypt_vec_or_original(v: &[u8], version: &str, max_len: usize) -> Vec { + if is_encrypted(v) { + log::error!("Duplicate encryption!"); + return v.to_owned(); + } + if v.len() > max_len { + return vec![]; + } + if version == "00" { + if let Ok(s) = encrypt(v) { + let mut version = version.to_owned().into_bytes(); + version.append(&mut s.into_bytes()); + return version; + } + } + v.to_owned() +} + +// Vec: password +// bool: whether decryption is successful +// bool: whether should store to re-encrypt when load +pub fn decrypt_vec_or_original(v: &[u8], current_version: &str) -> (Vec, bool, bool) { + if v.len() > VERSION_LEN { + let version = String::from_utf8_lossy(&v[..VERSION_LEN]); + if version == "00" { + if let Ok(v) = decrypt(&v[VERSION_LEN..]) { + return (v, true, version != current_version); + } + } + } + + // For values that already look encrypted (version prefix + base64), avoid + // repeated store on each load when decryption fails. + (v.to_owned(), false, !v.is_empty() && !is_encrypted(v)) +} + +fn encrypt(v: &[u8]) -> Result { + if !v.is_empty() { + symmetric_crypt(v, true).map(|v| base64::encode(v, base64::Variant::Original)) + } else { + Err(()) + } +} + +fn decrypt(v: &[u8]) -> Result, ()> { + if !v.is_empty() { + base64::decode(v, base64::Variant::Original).and_then(|v| symmetric_crypt(&v, false)) + } else { + Err(()) + } +} + +pub fn symmetric_crypt(data: &[u8], encrypt: bool) -> Result, ()> { + use sodiumoxide::crypto::secretbox; + use std::convert::TryInto; + + let uuid = crate::get_uuid(); + let mut keybuf = uuid.clone(); + keybuf.resize(secretbox::KEYBYTES, 0); + let key = secretbox::Key(keybuf.try_into().map_err(|_| ())?); + + if encrypt { + let nonce = secretbox::gen_nonce(); + let encrypted = secretbox::seal(data, &nonce, &key); + let mut output = Vec::with_capacity(1 + nonce.0.len() + encrypted.len()); + output.push(FORMAT_V1); + output.extend(nonce.0); + output.extend(encrypted); + Ok(output) + } else { + let res = open_secretbox_payload(data, &key); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if res.is_err() { + // Fallback: try pk if uuid decryption failed (in case encryption used pk due to machine_uid failure) + if let Some(key_pair) = Config::get_existing_key_pair() { + let pk = key_pair.1; + if pk != uuid { + let mut keybuf = pk; + keybuf.resize(secretbox::KEYBYTES, 0); + let pk_key = secretbox::Key(keybuf.try_into().map_err(|_| ())?); + return open_secretbox_payload(data, &pk_key); + } + } + } + res + } +} + +fn open_secretbox_payload(data: &[u8], key: &secretbox::Key) -> Result, ()> { + if data.first() == Some(&FORMAT_V1) + && data.len() >= 1 + secretbox::NONCEBYTES + secretbox::MACBYTES + { + let mut nonce = [0u8; secretbox::NONCEBYTES]; + nonce.copy_from_slice(&data[1..1 + secretbox::NONCEBYTES]); + let nonce = secretbox::Nonce(nonce); + if let Ok(decrypted) = secretbox::open(&data[1 + secretbox::NONCEBYTES..], &nonce, key) { + return Ok(decrypted); + } + } + + let legacy_nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + secretbox::open(data, &legacy_nonce, key) +} + +mod test { + + #[test] + fn test() { + use super::*; + use rand::{thread_rng, Rng}; + use std::time::Instant; + + let version = "00"; + let max_len = 128; + + println!("test str"); + let data = "1ü1111"; + let encrypted = encrypt_str_or_original(data, version, max_len); + let (decrypted, succ, store) = decrypt_str_or_original(&encrypted, version); + println!("data: {data}"); + println!("encrypted: {encrypted}"); + println!("decrypted: {decrypted}"); + assert_eq!(data, decrypted); + assert_eq!(version, &encrypted[..2]); + assert!(succ); + assert!(!store); + let (_, _, store) = decrypt_str_or_original(&encrypted, "99"); + assert!(store); + assert!(!decrypt_str_or_original(&decrypted, version).1); + assert_eq!( + encrypt_str_or_original(&encrypted, version, max_len), + encrypted + ); + + println!("test vec"); + let data: Vec = "1ü1111".as_bytes().to_vec(); + let encrypted = encrypt_vec_or_original(&data, version, max_len); + let (decrypted, succ, store) = decrypt_vec_or_original(&encrypted, version); + println!("data: {data:?}"); + println!("encrypted: {encrypted:?}"); + println!("decrypted: {decrypted:?}"); + assert_eq!(data, decrypted); + assert_eq!(version.as_bytes(), &encrypted[..2]); + assert!(!store); + assert!(succ); + let (_, _, store) = decrypt_vec_or_original(&encrypted, "99"); + assert!(store); + assert!(!decrypt_vec_or_original(&decrypted, version).1); + assert_eq!( + encrypt_vec_or_original(&encrypted, version, max_len), + encrypted + ); + + println!("test original"); + let data = version.to_string() + "Hello World"; + let (decrypted, succ, store) = decrypt_str_or_original(&data, version); + assert_eq!(data, decrypted); + assert!(store); + assert!(!succ); + let verbytes = version.as_bytes(); + let data: Vec = vec![verbytes[0], verbytes[1], 1, 2, 3, 4, 5, 6]; + let (decrypted, succ, store) = decrypt_vec_or_original(&data, version); + assert_eq!(data, decrypted); + assert!(store); + assert!(!succ); + let (_, succ, store) = decrypt_str_or_original("", version); + assert!(!store); + assert!(!succ); + let (_, succ, store) = decrypt_vec_or_original(&[], version); + assert!(!store); + assert!(!succ); + let data = "1ü1111"; + assert_eq!(decrypt_str_or_original(data, version).0, data); + let data: Vec = "1ü1111".as_bytes().to_vec(); + assert_eq!(decrypt_vec_or_original(&data, version).0, data); + + // Base64-shaped "00" prefixed values shorter than MACBYTES are treated + // as original/plain values and should be stored. + let data = "00YWJjZA=="; + let (decrypted, succ, store) = decrypt_str_or_original(data, version); + assert_eq!(decrypted, data); + assert!(!succ); + assert!(store); + let data = b"00YWJjZA==".to_vec(); + let (decrypted, succ, store) = decrypt_vec_or_original(&data, version); + assert_eq!(decrypted, data); + assert!(!succ); + assert!(store); + + // When decoded length reaches MACBYTES, it is treated as encrypted-like + // and should not trigger repeated store. + let exact_mac = vec![0u8; sodiumoxide::crypto::secretbox::MACBYTES]; + let exact_mac_b64 = + sodiumoxide::base64::encode(&exact_mac, sodiumoxide::base64::Variant::Original); + let data = format!("00{exact_mac_b64}"); + let (_, succ, store) = decrypt_str_or_original(&data, version); + assert!(!succ); + assert!(!store); + let data = data.into_bytes(); + let (_, succ, store) = decrypt_vec_or_original(&data, version); + assert!(!succ); + assert!(!store); + + println!("test speed"); + let test_speed = |len: usize, name: &str| { + let mut data: Vec = vec![]; + let mut rng = thread_rng(); + for _ in 0..len { + data.push(rng.gen_range(0..255)); + } + let start: Instant = Instant::now(); + let encrypted = encrypt_vec_or_original(&data, version, len); + assert_ne!(data, decrypted); + let t1 = start.elapsed(); + let start = Instant::now(); + let (decrypted, _, _) = decrypt_vec_or_original(&encrypted, version); + let t2 = start.elapsed(); + assert_eq!(data, decrypted); + println!("{name}"); + println!("encrypt:{:?}, decrypt:{:?}", t1, t2); + + let start: Instant = Instant::now(); + let encrypted = base64::encode(&data, base64::Variant::Original); + let t1 = start.elapsed(); + let start = Instant::now(); + let decrypted = base64::decode(&encrypted, base64::Variant::Original).unwrap(); + let t2 = start.elapsed(); + assert_eq!(data, decrypted); + println!("base64, encrypt:{:?}, decrypt:{:?}", t1, t2,); + }; + test_speed(128, "128"); + test_speed(1024, "1k"); + test_speed(1024 * 1024, "1M"); + test_speed(10 * 1024 * 1024, "10M"); + test_speed(100 * 1024 * 1024, "100M"); + } + + #[test] + fn test_is_encrypted() { + use super::*; + use sodiumoxide::base64::{encode, Variant}; + use sodiumoxide::crypto::secretbox; + + // Empty data should not be considered encrypted + assert!(!is_encrypted(b"")); + assert!(!is_encrypted(b"0")); + assert!(!is_encrypted(b"00")); + + // Data without "00" prefix should not be considered encrypted + assert!(!is_encrypted(b"01abcd")); + assert!(!is_encrypted(b"99abcd")); + assert!(!is_encrypted(b"hello world")); + + // Data with "00" prefix but invalid base64 should not be considered encrypted + assert!(!is_encrypted(b"00!!!invalid base64!!!")); + assert!(!is_encrypted(b"00@#$%")); + + // Data with "00" prefix and valid base64 but shorter than MACBYTES is not encrypted + assert!(!is_encrypted(b"00YWJjZA==")); // "abcd" in base64 + assert!(!is_encrypted(b"00SGVsbG8gV29ybGQ=")); // "Hello World" in base64 + + // Data with "00" prefix and valid base64 with decoded len == MACBYTES is considered encrypted + let exact_mac = vec![0u8; secretbox::MACBYTES]; + let exact_mac_b64 = encode(&exact_mac, Variant::Original); + let exact_mac_candidate = format!("00{exact_mac_b64}"); + assert!(is_encrypted(exact_mac_candidate.as_bytes())); + + // Real encrypted data should be detected + let version = "00"; + let max_len = 128; + let encrypted_str = encrypt_str_or_original("1", version, max_len); + assert!(is_encrypted(encrypted_str.as_bytes())); + let encrypted_vec = encrypt_vec_or_original(b"1", version, max_len); + assert!(is_encrypted(&encrypted_vec)); + + // Original unencrypted data should not be detected as encrypted + assert!(!is_encrypted(b"1")); + assert!(!is_encrypted("1".as_bytes())); + } + + #[test] + fn test_encrypted_payload_min_len_macbytes() { + use super::*; + use sodiumoxide::base64::{decode, Variant}; + use sodiumoxide::crypto::secretbox; + + let version = "00"; + let max_len = 128; + + let encrypted_str = encrypt_str_or_original("1", version, max_len); + let decoded = decode(&encrypted_str.as_bytes()[VERSION_LEN..], Variant::Original).unwrap(); + assert!( + decoded.len() >= secretbox::MACBYTES, + "decoded encrypted payload must be at least MACBYTES" + ); + + let encrypted_vec = encrypt_vec_or_original(b"1", version, max_len); + let decoded = decode(&encrypted_vec[VERSION_LEN..], Variant::Original).unwrap(); + assert!( + decoded.len() >= secretbox::MACBYTES, + "decoded encrypted payload must be at least MACBYTES" + ); + } + + #[test] + fn test_encryption_uses_random_nonce() { + use super::*; + + let data = b"test password 123"; + let encrypted1 = symmetric_crypt(data, true).unwrap(); + let encrypted2 = symmetric_crypt(data, true).unwrap(); + + assert_eq!(encrypted1.first(), Some(&FORMAT_V1)); + assert_eq!(encrypted2.first(), Some(&FORMAT_V1)); + assert_eq!( + encrypted1.len(), + 1 + secretbox::NONCEBYTES + data.len() + secretbox::MACBYTES + ); + assert_ne!(encrypted1, encrypted2); + assert_eq!(symmetric_crypt(&encrypted1, false).unwrap(), data); + assert_eq!(symmetric_crypt(&encrypted2, false).unwrap(), data); + } + + #[test] + fn test_decrypt_legacy_zero_nonce_payload() { + use super::*; + use std::convert::TryInto; + + let data = b"test password 123"; + let uuid = crate::get_uuid(); + let mut keybuf = uuid.clone(); + keybuf.resize(secretbox::KEYBYTES, 0); + let key = secretbox::Key(keybuf.try_into().unwrap()); + let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + let encrypted = secretbox::seal(data, &nonce, &key); + + assert_eq!(symmetric_crypt(&encrypted, false).unwrap(), data); + } + + #[test] + fn test_decrypt_legacy_payload_starting_with_v1_marker() { + use super::*; + use std::convert::TryInto; + + let mut keybuf = crate::get_uuid(); + keybuf.resize(secretbox::KEYBYTES, 0); + let key = secretbox::Key(keybuf.try_into().unwrap()); + let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + + for i in 0..=u16::MAX { + let data = format!("legacy collision payload {i:05}"); + let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key); + if encrypted.first() == Some(&FORMAT_V1) { + assert_eq!(symmetric_crypt(&encrypted, false).unwrap(), data.as_bytes()); + return; + } + } + + panic!("failed to find legacy payload starting with FORMAT_V1"); + } + + #[test] + fn test_invalid_short_v1_payload_returns_error() { + use super::*; + + let encrypted = vec![FORMAT_V1]; + + assert!(symmetric_crypt(&encrypted, false).is_err()); + } + + #[test] + fn test_decrypt_legacy_string_does_not_request_store() { + use super::*; + use sodiumoxide::base64::{encode, Variant}; + use std::convert::TryInto; + + let data = "test password 123"; + let uuid = crate::get_uuid(); + let mut keybuf = uuid.clone(); + keybuf.resize(secretbox::KEYBYTES, 0); + let key = secretbox::Key(keybuf.try_into().unwrap()); + let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key); + let encrypted = "00".to_owned() + &encode(encrypted, Variant::Original); + + let (decrypted, success, store) = decrypt_str_or_original(&encrypted, "00"); + + assert_eq!(decrypted, data); + assert!(success); + assert!(!store); + } + + #[test] + fn test_decrypt_legacy_vec_does_not_request_store() { + use super::*; + use sodiumoxide::base64::{encode, Variant}; + use std::convert::TryInto; + + let data = b"test password 123"; + let uuid = crate::get_uuid(); + let mut keybuf = uuid.clone(); + keybuf.resize(secretbox::KEYBYTES, 0); + let key = secretbox::Key(keybuf.try_into().unwrap()); + let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + let encrypted = secretbox::seal(data, &nonce, &key); + let encrypted = ("00".to_owned() + &encode(encrypted, Variant::Original)).into_bytes(); + + let (decrypted, success, store) = decrypt_vec_or_original(&encrypted, "00"); + + assert_eq!(decrypted, data); + assert!(success); + assert!(!store); + } + + // Test decryption fallback when data was encrypted with key_pair but decryption tries machine_uid first + #[test] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + fn test_decrypt_with_pk_fallback() { + use sodiumoxide::crypto::secretbox; + use std::convert::TryInto; + + let uuid = crate::get_uuid(); + let pk = crate::config::Config::get_key_pair().1; + + // Ensure uuid != pk, otherwise fallback branch won't be tested + if uuid == pk { + eprintln!("skip: uuid == pk, fallback branch won't be tested"); + return; + } + + let data = b"test password 123"; + let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); + + // Encrypt with pk (simulating machine_uid failure during encryption) + let mut pk_keybuf = pk; + pk_keybuf.resize(secretbox::KEYBYTES, 0); + let pk_key = secretbox::Key(pk_keybuf.try_into().unwrap()); + let encrypted = secretbox::seal(data, &nonce, &pk_key); + + // Decrypt using symmetric_crypt (should fallback to pk since uuid differs) + let decrypted = super::symmetric_crypt(&encrypted, false); + assert!( + decrypted.is_ok(), + "Decryption with pk fallback should succeed" + ); + assert_eq!(decrypted.unwrap(), data); + } + + #[test] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + fn test_decrypt_v1_with_pk_fallback() { + use super::*; + use sodiumoxide::base64::{encode, Variant}; + use sodiumoxide::crypto::secretbox; + use std::convert::TryInto; + + let uuid = crate::get_uuid(); + let pk = crate::config::Config::get_key_pair().1; + + if uuid == pk { + eprintln!("skip: uuid == pk, fallback branch won't be tested"); + return; + } + + let data = b"test password 123"; + let nonce = secretbox::gen_nonce(); + + let mut pk_keybuf = pk; + pk_keybuf.resize(secretbox::KEYBYTES, 0); + let pk_key = secretbox::Key(pk_keybuf.try_into().unwrap()); + let ciphertext = secretbox::seal(data, &nonce, &pk_key); + + let mut encrypted = Vec::with_capacity(1 + secretbox::NONCEBYTES + ciphertext.len()); + encrypted.push(FORMAT_V1); + encrypted.extend(nonce.0); + encrypted.extend(ciphertext); + + assert_eq!(super::symmetric_crypt(&encrypted, false).unwrap(), data); + + let encrypted_str = "00".to_owned() + &encode(&encrypted, Variant::Original); + let (decrypted, success, store) = decrypt_str_or_original(&encrypted_str, "00"); + assert_eq!(decrypted.as_bytes(), data); + assert!(success); + assert!(!store); + + let encrypted_vec = encrypted_str.into_bytes(); + let (decrypted, success, store) = decrypt_vec_or_original(&encrypted_vec, "00"); + assert_eq!(decrypted, data); + assert!(success); + assert!(!store); + } +} diff --git a/libs/hbb_common/src/platform/linux.rs b/libs/hbb_common/src/platform/linux.rs new file mode 100644 index 00000000000..d4b29bb20fa --- /dev/null +++ b/libs/hbb_common/src/platform/linux.rs @@ -0,0 +1,572 @@ +use crate::ResultType; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::Command, +}; +use users::{get_current_uid, get_user_by_uid, os::unix::UserExt}; + +use sctk::{ + output::OutputData, + output::{OutputHandler, OutputState}, + reexports::client::protocol::wl_output::WlOutput, + reexports::client::{globals, Proxy}, + reexports::client::{Connection, QueueHandle}, + registry::{ProvidesRegistryState, RegistryState}, +}; + +lazy_static::lazy_static! { + pub static ref DISTRO: Distro = Distro::new(); +} + +// to-do: There seems to be some runtime issue that causes the audit logs to be generated. +// We may need to fix this and remove this workaround in the future. +// +// We use the pre-search method to find the command path to avoid the audit logs on some systems. +// No idea why the audit logs happen. +// Though the audit logs may disappear after rebooting. +// +// See https://github.com/rustdesk/rustdesk/discussions/11959 +// +// `ausearch -x /usr/share/rustdesk/rustdesk` will return +// ... +// time->Tue Jun 24 10:40:43 2025 +// type=PROCTITLE msg=audit(1750776043.446:192757): proctitle=2F7573722F62696E2F727573746465736B002D2D73657276696365 +// type=PATH msg=audit(1750776043.446:192757): item=0 name="/usr/local/bin/sh" nametype=UNKNOWN cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 +// type=CWD msg=audit(1750776043.446:192757): cwd="/" +// type=SYSCALL msg=audit(1750776043.446:192757): arch=c000003e syscall=59 success=no exit=-2 a0=7fb7dbd22da0 a1=1d65f2c0 a2=7ffc25193360 a3=7ffc25194ec0 items=1 ppid=172208 pid=267565 auid=4294967295 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=(none) ses=4294967295 comm="rustdesk" exe="/usr/share/rustdesk/rustdesk" subj=unconfined key="processos_criados" +// ---- +// time->Tue Jun 24 10:40:43 2025 +// type=PROCTITLE msg=audit(1750776043.446:192758): proctitle=2F7573722F62696E2F727573746465736B002D2D73657276696365 +// type=PATH msg=audit(1750776043.446:192758): item=0 name="/usr/sbin/sh" nametype=UNKNOWN cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 +// ... +lazy_static::lazy_static! { + pub static ref CMD_LOGINCTL: String = find_cmd_path("loginctl"); + pub static ref CMD_PS: String = find_cmd_path("ps"); + pub static ref CMD_SH: String = find_cmd_path("sh"); +} + +pub const DISPLAY_SERVER_WAYLAND: &str = "wayland"; +pub const DISPLAY_SERVER_X11: &str = "x11"; +pub const DISPLAY_DESKTOP_KDE: &str = "KDE"; + +pub const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP"; + +pub struct Distro { + pub name: String, + pub version_id: String, +} + +impl Distro { + fn new() -> Self { + let name = run_cmds("awk -F'=' '/^NAME=/ {print $2}' /etc/os-release") + .unwrap_or_default() + .trim() + .trim_matches('"') + .to_string(); + let version_id = run_cmds("awk -F'=' '/^VERSION_ID=/ {print $2}' /etc/os-release") + .unwrap_or_default() + .trim() + .trim_matches('"') + .to_string(); + Self { name, version_id } + } +} + +fn find_cmd_path(cmd: &'static str) -> String { + let test_cmd = format!("/bin/{}", cmd); + if std::path::Path::new(&test_cmd).exists() { + return test_cmd; + } + let test_cmd = format!("/usr/bin/{}", cmd); + if std::path::Path::new(&test_cmd).exists() { + return test_cmd; + } + if let Ok(output) = Command::new("which").arg(cmd).output() { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + cmd.to_string() +} + +// Deprecated. Use `hbb_common::platform::linux::is_kde_session()` instead for now. +// Or we need to set the correct environment variable in the server process. +#[inline] +pub fn is_kde() -> bool { + if let Ok(env) = std::env::var(XDG_CURRENT_DESKTOP) { + env == DISPLAY_DESKTOP_KDE + } else { + false + } +} + +// Don't use `hbb_common::platform::linux::is_kde()` here. +// It's not correct in the server process. +pub fn is_kde_session() -> bool { + std::process::Command::new(CMD_SH.as_str()) + .arg("-c") + .arg("pgrep -f kded[0-9]+") + .stdout(std::process::Stdio::piped()) + .output() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false) +} + +#[inline] +pub fn is_gdm_user(username: &str) -> bool { + username == "gdm" || username == "sddm" + // || username == "lightgdm" +} + +#[inline] +pub fn is_desktop_wayland() -> bool { + get_display_server() == DISPLAY_SERVER_WAYLAND +} + +#[inline] +pub fn is_x11_or_headless() -> bool { + !is_desktop_wayland() +} + +// -1 +const INVALID_SESSION: &str = "4294967295"; + +pub fn get_display_server() -> String { + // Check for forced display server environment variable first + if let Ok(forced_display) = std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") { + return forced_display; + } + + // Check if `loginctl` can be called successfully + if run_loginctl(None).is_err() { + return DISPLAY_SERVER_X11.to_owned(); + } + + let mut session = get_values_of_seat0(&[0])[0].clone(); + if session.is_empty() { + // loginctl has not given the expected output. try something else. + if let Ok(sid) = std::env::var("XDG_SESSION_ID") { + // could also execute "cat /proc/self/sessionid" + session = sid; + } + if session.is_empty() { + session = run_cmds("cat /proc/self/sessionid").unwrap_or_default(); + if session == INVALID_SESSION { + session = "".to_owned(); + } + } + } + if session.is_empty() { + std::env::var("XDG_SESSION_TYPE").unwrap_or("x11".to_owned()) + } else { + get_display_server_of_session(&session) + } +} + +pub fn get_display_server_of_session(session: &str) -> String { + let mut display_server = if let Ok(output) = + run_loginctl(Some(vec!["show-session", "-p", "Type", session])) + // Check session type of the session + { + String::from_utf8_lossy(&output.stdout) + .replace("Type=", "") + .trim_end() + .into() + } else { + "".to_owned() + }; + if display_server.is_empty() || display_server == "tty" || display_server == "unspecified" { + if let Ok(sestype) = std::env::var("XDG_SESSION_TYPE") { + if !sestype.is_empty() { + return sestype.to_lowercase(); + } + } + display_server = "x11".to_owned(); + } + display_server.to_lowercase() +} + +#[inline] +fn line_values(indices: &[usize], line: &str) -> Vec { + indices + .into_iter() + .map(|idx| line.split_whitespace().nth(*idx).unwrap_or("").to_owned()) + .collect::>() +} + +#[inline] +pub fn get_values_of_seat0(indices: &[usize]) -> Vec { + _get_values_of_seat0(indices, true) +} + +#[inline] +pub fn get_values_of_seat0_with_gdm_wayland(indices: &[usize]) -> Vec { + _get_values_of_seat0(indices, false) +} + +// Ignore "3 sessions listed." +fn ignore_loginctl_line(line: &str) -> bool { + line.contains("sessions") || line.split(" ").count() < 4 +} + +fn _get_values_of_seat0(indices: &[usize], ignore_gdm_wayland: bool) -> Vec { + if let Ok(output) = run_loginctl(None) { + for line in String::from_utf8_lossy(&output.stdout).lines() { + if ignore_loginctl_line(line) { + continue; + } + if line.contains("seat0") { + if let Some(sid) = line.split_whitespace().next() { + if is_active(sid) { + if ignore_gdm_wayland { + if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) + && get_display_server_of_session(sid) == DISPLAY_SERVER_WAYLAND + { + continue; + } + } + return line_values(indices, line); + } + } + } + } + + // some case, there is no seat0 https://github.com/rustdesk/rustdesk/issues/73 + for line in String::from_utf8_lossy(&output.stdout).lines() { + if ignore_loginctl_line(line) { + continue; + } + if let Some(sid) = line.split_whitespace().next() { + if is_active(sid) { + let d = get_display_server_of_session(sid); + if ignore_gdm_wayland { + if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) + && d == DISPLAY_SERVER_WAYLAND + { + continue; + } + } + if d == "tty" || d == "unspecified" { + continue; + } + return line_values(indices, line); + } + } + } + } + + line_values(indices, "") +} + +pub fn is_active(sid: &str) -> bool { + if let Ok(output) = run_loginctl(Some(vec!["show-session", "-p", "State", sid])) { + String::from_utf8_lossy(&output.stdout).contains("active") + } else { + false + } +} + +pub fn is_active_and_seat0(sid: &str) -> bool { + if let Ok(output) = run_loginctl(Some(vec!["show-session", sid])) { + String::from_utf8_lossy(&output.stdout).contains("State=active") + && String::from_utf8_lossy(&output.stdout).contains("Seat=seat0") + } else { + false + } +} + +// Check both "Lock" and "Switch user" +pub fn is_session_locked(sid: &str) -> bool { + if let Ok(output) = run_loginctl(Some(vec!["show-session", sid, "--property=LockedHint"])) { + String::from_utf8_lossy(&output.stdout).contains("LockedHint=yes") + } else { + false + } +} + +// **Note** that the return value here, the last character is '\n'. +// Use `run_cmds_trim_newline()` if you want to remove '\n' at the end. +pub fn run_cmds(cmds: &str) -> ResultType { + let output = std::process::Command::new(CMD_SH.as_str()) + .args(vec!["-c", cmds]) + .output()?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +pub fn run_cmds_trim_newline(cmds: &str) -> ResultType { + let output = std::process::Command::new(CMD_SH.as_str()) + .args(vec!["-c", cmds]) + .output()?; + let out = String::from_utf8_lossy(&output.stdout); + Ok(if out.ends_with('\n') { + out[..out.len() - 1].to_string() + } else { + out.to_string() + }) +} + +fn run_loginctl(args: Option>) -> std::io::Result { + if std::env::var("FLATPAK_ID").is_ok() { + let mut l_args = CMD_LOGINCTL.to_string(); + if let Some(a) = args.as_ref() { + l_args = format!("{} {}", l_args, a.join(" ")); + } + let res = std::process::Command::new("flatpak-spawn") + .args(vec![String::from("--host"), l_args]) + .output(); + if res.is_ok() { + return res; + } + } + let mut cmd = std::process::Command::new(CMD_LOGINCTL.as_str()); + if let Some(a) = args { + return cmd.args(a).output(); + } + cmd.output() +} + +/// forever: may not work +#[cfg(target_os = "linux")] +pub fn system_message(title: &str, msg: &str, forever: bool) -> ResultType<()> { + let cmds: HashMap<&str, Vec<&str>> = HashMap::from([ + ("notify-send", [title, msg].to_vec()), + ( + "zenity", + [ + "--info", + "--timeout", + if forever { "0" } else { "3" }, + "--title", + title, + "--text", + msg, + ] + .to_vec(), + ), + ("kdialog", ["--title", title, "--msgbox", msg].to_vec()), + ( + "xmessage", + [ + "-center", + "-timeout", + if forever { "0" } else { "3" }, + title, + msg, + ] + .to_vec(), + ), + ]); + for (k, v) in cmds { + if Command::new(k).args(v).spawn().is_ok() { + return Ok(()); + } + } + crate::bail!("failed to post system message"); +} + +#[derive(Debug, Clone)] +pub struct WaylandDisplayInfo { + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub logical_size: Option<(i32, i32)>, + pub refresh_rate: i32, +} + +// Retrieves information about all connected displays via the Wayland protocol. +pub fn get_wayland_displays() -> ResultType> { + struct WaylandEnv { + registry_state: RegistryState, + output_state: OutputState, + } + + impl OutputHandler for WaylandEnv { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} + } + + impl ProvidesRegistryState for WaylandEnv { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + + sctk::registry_handlers!(); + } + + sctk::delegate_output!(WaylandEnv); + sctk::delegate_registry!(WaylandEnv); + + let conn = Connection::connect_to_env()?; + let (globals, mut event_queue) = globals::registry_queue_init(&conn)?; + let queue_handle = event_queue.handle(); + + let registry_state = RegistryState::new(&globals); + let output_state = OutputState::new(&globals, &queue_handle); + + let mut environment = WaylandEnv { + registry_state, + output_state, + }; + + event_queue.roundtrip(&mut environment)?; + + let outputs: Vec<_> = environment.output_state.outputs().collect(); + let mut display_infos = Vec::new(); + + for output in outputs { + if let Some(output_data) = output.data::() { + output_data.with_output_info(|info| { + if let Some(mode) = info.modes.iter().find(|m| m.current) { + let (x, y) = info.location; + let (width, height) = mode.dimensions; + let refresh_rate = mode.refresh_rate; + let name = info.name.clone().unwrap_or_default(); + let logical_size = info.logical_size; + display_infos.push(WaylandDisplayInfo { + name, + x, + y, + width, + height, + logical_size, + refresh_rate, + }); + } + }); + } + } + + Ok(display_infos) +} + +/// Escape a string for safe use in shell commands by wrapping in single quotes. +/// +/// This function handles the edge case of single quotes within the string by: +/// 1. Ending the current single-quoted section +/// 2. Adding an escaped single quote +/// 3. Starting a new single-quoted section +/// +/// Example: "it's here" -> "'it'\''s here'" +#[inline] +pub fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace("'", "'\\''")) +} + +/// Get the current user's home directory via getpwuid (trusted source). +/// +/// This function uses the system's password database (via `getpwuid`) to retrieve +/// the home directory, avoiding the security risk of relying on the `HOME` +/// environment variable which can be manipulated by untrusted input. +/// +/// # Returns +/// - `Some(PathBuf)` if the home directory was found and exists +/// - `None` if the user lookup failed or the directory doesn't exist +/// +/// # Security +/// This function is designed to be safe against confused-deputy attacks where +/// an attacker might manipulate environment variables to influence privileged +/// operations. +pub fn get_home_dir_trusted() -> Option { + let uid = get_current_uid(); + match get_user_by_uid(uid) { + Some(user) => { + let home = user.home_dir(); + if Path::is_dir(home) { + Some(PathBuf::from(home)) + } else { + log::warn!( + "Home directory for uid {} does not exist or is not a directory: {:?}", + uid, + home + ); + None + } + } + None => { + log::warn!("Failed to get user info for uid {}", uid); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run_cmds_trim_newline() { + assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123"); + assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123"); + assert_eq!( + run_cmds_trim_newline("whoami").unwrap() + "\n", + run_cmds("whoami").unwrap() + ); + } + + /// Test get_home_dir_trusted: returns valid path and ignores HOME env var + #[test] + fn test_get_home_dir_trusted() { + let original_home = std::env::var("HOME").ok(); + + // Set HOME to a fake/malicious path + std::env::set_var("HOME", "/tmp/fake_malicious_home"); + let result = get_home_dir_trusted(); + + // Restore original HOME + match original_home { + Some(home) => std::env::set_var("HOME", home), + None => std::env::remove_var("HOME"), + } + + // Verify: returns valid path that is NOT the fake HOME + if let Some(path) = result { + assert!(path.is_absolute(), "Path should be absolute: {:?}", path); + assert!(path.is_dir(), "Path should be a directory: {:?}", path); + assert_ne!( + path.to_string_lossy(), + "/tmp/fake_malicious_home", + "Should not use HOME env var" + ); + } + } + + /// Test shell_quote with normal strings + #[test] + fn test_shell_quote_normal() { + assert_eq!(shell_quote("hello"), "'hello'"); + assert_eq!(shell_quote("/home/user"), "'/home/user'"); + } + + /// Test shell_quote with spaces + #[test] + fn test_shell_quote_spaces() { + assert_eq!(shell_quote("/home/my user/file"), "'/home/my user/file'"); + assert_eq!(shell_quote("path with spaces"), "'path with spaces'"); + } + + /// Test shell_quote with single quotes (the tricky case) + #[test] + fn test_shell_quote_single_quotes() { + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + assert_eq!(shell_quote("don't stop"), "'don'\\''t stop'"); + } + + /// Test shell_quote with shell metacharacters + #[test] + fn test_shell_quote_metacharacters() { + // These should all be safely quoted + assert_eq!(shell_quote("test;rm -rf /"), "'test;rm -rf /'"); + assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'"); + assert_eq!(shell_quote("`id`"), "'`id`'"); + assert_eq!(shell_quote("a && b"), "'a && b'"); + assert_eq!(shell_quote("a | b"), "'a | b'"); + } +} diff --git a/libs/hbb_common/src/platform/macos.rs b/libs/hbb_common/src/platform/macos.rs new file mode 100644 index 00000000000..dd83a87385b --- /dev/null +++ b/libs/hbb_common/src/platform/macos.rs @@ -0,0 +1,55 @@ +use crate::ResultType; +use osascript; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct AlertParams { + title: String, + message: String, + alert_type: String, + buttons: Vec, +} + +#[derive(Deserialize)] +struct AlertResult { + #[serde(rename = "buttonReturned")] + button: String, +} + +/// Firstly run the specified app, then alert a dialog. Return the clicked button value. +/// +/// # Arguments +/// +/// * `app` - The app to execute the script. +/// * `alert_type` - Alert type. . informational, warning, critical +/// * `title` - The alert title. +/// * `message` - The alert message. +/// * `buttons` - The buttons to show. +pub fn alert( + app: String, + alert_type: String, + title: String, + message: String, + buttons: Vec, +) -> ResultType { + let script = osascript::JavaScript::new(&format!( + " + var App = Application('{}'); + App.includeStandardAdditions = true; + return App.displayAlert($params.title, {{ + message: $params.message, + 'as': $params.alert_type, + buttons: $params.buttons, + }}); + ", + app + )); + + let result: AlertResult = script.execute_with_params(AlertParams { + title, + message, + alert_type, + buttons, + })?; + Ok(result.button) +} diff --git a/libs/hbb_common/src/platform/mod.rs b/libs/hbb_common/src/platform/mod.rs new file mode 100644 index 00000000000..6818add407b --- /dev/null +++ b/libs/hbb_common/src/platform/mod.rs @@ -0,0 +1,82 @@ +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; + +#[cfg(not(debug_assertions))] +use crate::{config::Config, log}; +#[cfg(not(debug_assertions))] +use std::process::exit; + +#[cfg(not(debug_assertions))] +static mut GLOBAL_CALLBACK: Option> = None; + +#[cfg(not(debug_assertions))] +extern "C" fn breakdown_signal_handler(sig: i32) { + let mut stack = vec![]; + backtrace::trace(|frame| { + backtrace::resolve_frame(frame, |symbol| { + if let Some(name) = symbol.name() { + stack.push(name.to_string()); + } + }); + true // keep going to the next frame + }); + let mut info = String::default(); + if stack.iter().any(|s| { + s.contains(&"nouveau_pushbuf_kick") + || s.to_lowercase().contains("nvidia") + || s.contains("gdk_window_end_draw_frame") + || s.contains("glGetString") + }) { + Config::set_option("allow-always-software-render".to_string(), "Y".to_string()); + info = "Always use software rendering will be set.".to_string(); + log::info!("{}", info); + } + if stack.iter().any(|s| { + s.to_lowercase().contains("nvidia") + || s.to_lowercase().contains("amf") + || s.to_lowercase().contains("mfx") + || s.contains("cuProfilerStop") + }) { + Config::set_option("enable-hwcodec".to_string(), "N".to_string()); + info = "Perhaps hwcodec causing the crash, disable it first".to_string(); + log::info!("{}", info); + } + log::error!( + "Got signal {} and exit. stack:\n{}", + sig, + stack.join("\n").to_string() + ); + if !info.is_empty() { + #[cfg(target_os = "linux")] + linux::system_message( + "RustDesk", + &format!("Got signal {} and exit.{}", sig, info), + true, + ) + .ok(); + } + unsafe { + #[allow(static_mut_refs)] + if let Some(callback) = &GLOBAL_CALLBACK { + callback() + } + } + exit(0); +} + +#[cfg(not(debug_assertions))] +pub fn register_breakdown_handler(callback: T) +where + T: Fn() + 'static, +{ + unsafe { + GLOBAL_CALLBACK = Some(Box::new(callback)); + libc::signal(libc::SIGSEGV, breakdown_signal_handler as _); + } +} diff --git a/libs/hbb_common/src/platform/windows.rs b/libs/hbb_common/src/platform/windows.rs new file mode 100644 index 00000000000..7481631ace1 --- /dev/null +++ b/libs/hbb_common/src/platform/windows.rs @@ -0,0 +1,198 @@ +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::Instant, +}; +use winapi::{ + shared::minwindef::{DWORD, FALSE, TRUE}, + um::{ + handleapi::CloseHandle, + pdh::{ + PdhAddEnglishCounterA, PdhCloseQuery, PdhCollectQueryData, PdhCollectQueryDataEx, + PdhGetFormattedCounterValue, PdhOpenQueryA, PDH_FMT_COUNTERVALUE, PDH_FMT_DOUBLE, + PDH_HCOUNTER, PDH_HQUERY, + }, + synchapi::{CreateEventA, WaitForSingleObject}, + sysinfoapi::VerSetConditionMask, + winbase::{VerifyVersionInfoW, INFINITE, WAIT_OBJECT_0}, + winnt::{ + HANDLE, OSVERSIONINFOEXW, VER_BUILDNUMBER, VER_GREATER_EQUAL, VER_MAJORVERSION, + VER_MINORVERSION, VER_SERVICEPACKMAJOR, VER_SERVICEPACKMINOR, + }, + }, +}; + +lazy_static::lazy_static! { + static ref CPU_USAGE_ONE_MINUTE: Arc>> = Arc::new(Mutex::new(None)); +} + +// https://github.com/mgostIH/process_list/blob/master/src/windows/mod.rs +#[repr(transparent)] +pub struct RAIIHandle(pub HANDLE); + +impl Drop for RAIIHandle { + fn drop(&mut self) { + // This never gives problem except when running under a debugger. + unsafe { CloseHandle(self.0) }; + } +} + +#[repr(transparent)] +pub(self) struct RAIIPDHQuery(pub PDH_HQUERY); + +impl Drop for RAIIPDHQuery { + fn drop(&mut self) { + unsafe { PdhCloseQuery(self.0) }; + } +} + +pub fn start_cpu_performance_monitor() { + // Code from: + // https://learn.microsoft.com/en-us/windows/win32/perfctrs/collecting-performance-data + // https://learn.microsoft.com/en-us/windows/win32/api/pdh/nf-pdh-pdhcollectquerydataex + // Why value lower than taskManager: + // https://aaron-margosis.medium.com/task-managers-cpu-numbers-are-all-but-meaningless-2d165b421e43 + // Therefore we should compare with Precess Explorer rather than taskManager + + let f = || unsafe { + // load avg or cpu usage, test with prime95. + // Prefer cpu usage because we can get accurate value from Precess Explorer. + // const COUNTER_PATH: &'static str = "\\System\\Processor Queue Length\0"; + const COUNTER_PATH: &'static str = "\\Processor(_total)\\% Processor Time\0"; + const SAMPLE_INTERVAL: DWORD = 2; // 2 second + + let mut ret; + let mut query: PDH_HQUERY = std::mem::zeroed(); + ret = PdhOpenQueryA(std::ptr::null() as _, 0, &mut query); + if ret != 0 { + log::error!("PdhOpenQueryA failed: 0x{:X}", ret); + return; + } + let _query = RAIIPDHQuery(query); + let mut counter: PDH_HCOUNTER = std::mem::zeroed(); + ret = PdhAddEnglishCounterA(query, COUNTER_PATH.as_ptr() as _, 0, &mut counter); + if ret != 0 { + log::error!("PdhAddEnglishCounterA failed: 0x{:X}", ret); + return; + } + ret = PdhCollectQueryData(query); + if ret != 0 { + log::error!("PdhCollectQueryData failed: 0x{:X}", ret); + return; + } + let mut _counter_type: DWORD = 0; + let mut counter_value: PDH_FMT_COUNTERVALUE = std::mem::zeroed(); + let event = CreateEventA(std::ptr::null_mut(), FALSE, FALSE, std::ptr::null() as _); + if event.is_null() { + log::error!("CreateEventA failed"); + return; + } + let _event: RAIIHandle = RAIIHandle(event); + ret = PdhCollectQueryDataEx(query, SAMPLE_INTERVAL, event); + if ret != 0 { + log::error!("PdhCollectQueryDataEx failed: 0x{:X}", ret); + return; + } + + let mut queue: VecDeque = VecDeque::new(); + let mut recent_valid: VecDeque = VecDeque::new(); + loop { + // latest one minute + if queue.len() == 31 { + queue.pop_front(); + } + if recent_valid.len() == 31 { + recent_valid.pop_front(); + } + // allow get value within one minute + if queue.len() > 0 && recent_valid.iter().filter(|v| **v).count() > queue.len() / 2 { + let sum: f64 = queue.iter().map(|f| f.to_owned()).sum(); + let avg = sum / (queue.len() as f64); + *CPU_USAGE_ONE_MINUTE.lock().unwrap() = Some((avg, Instant::now())); + } else { + *CPU_USAGE_ONE_MINUTE.lock().unwrap() = None; + } + if WAIT_OBJECT_0 != WaitForSingleObject(event, INFINITE) { + recent_valid.push_back(false); + continue; + } + if PdhGetFormattedCounterValue( + counter, + PDH_FMT_DOUBLE, + &mut _counter_type, + &mut counter_value, + ) != 0 + || counter_value.CStatus != 0 + { + recent_valid.push_back(false); + continue; + } + queue.push_back(counter_value.u.doubleValue().clone()); + recent_valid.push_back(true); + } + }; + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + std::thread::spawn(f); + }); +} + +pub fn cpu_uage_one_minute() -> Option { + let v = CPU_USAGE_ONE_MINUTE.lock().unwrap().clone(); + if let Some((v, instant)) = v { + if instant.elapsed().as_secs() < 30 { + return Some(v); + } + } + None +} + +pub fn sync_cpu_usage(cpu_usage: Option) { + let v = match cpu_usage { + Some(cpu_usage) => Some((cpu_usage, Instant::now())), + None => None, + }; + *CPU_USAGE_ONE_MINUTE.lock().unwrap() = v; + log::info!("cpu usage synced: {:?}", cpu_usage); +} + +// https://learn.microsoft.com/en-us/windows/win32/sysinfo/targeting-your-application-at-windows-8-1 +// https://github.com/nodejs/node-convergence-archive/blob/e11fe0c2777561827cdb7207d46b0917ef3c42a7/deps/uv/src/win/util.c#L780 +pub fn is_windows_version_or_greater( + os_major: u32, + os_minor: u32, + build_number: u32, + service_pack_major: u32, + service_pack_minor: u32, +) -> bool { + let mut osvi: OSVERSIONINFOEXW = unsafe { std::mem::zeroed() }; + osvi.dwOSVersionInfoSize = std::mem::size_of::() as DWORD; + osvi.dwMajorVersion = os_major as _; + osvi.dwMinorVersion = os_minor as _; + osvi.dwBuildNumber = build_number as _; + osvi.wServicePackMajor = service_pack_major as _; + osvi.wServicePackMinor = service_pack_minor as _; + + let result = unsafe { + let mut condition_mask = 0; + let op = VER_GREATER_EQUAL; + condition_mask = VerSetConditionMask(condition_mask, VER_MAJORVERSION, op); + condition_mask = VerSetConditionMask(condition_mask, VER_MINORVERSION, op); + condition_mask = VerSetConditionMask(condition_mask, VER_BUILDNUMBER, op); + condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMAJOR, op); + condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMINOR, op); + + VerifyVersionInfoW( + &mut osvi as *mut OSVERSIONINFOEXW, + VER_MAJORVERSION + | VER_MINORVERSION + | VER_BUILDNUMBER + | VER_SERVICEPACKMAJOR + | VER_SERVICEPACKMINOR, + condition_mask, + ) + }; + + result == TRUE +} diff --git a/libs/hbb_common/src/protos/mod.rs b/libs/hbb_common/src/protos/mod.rs new file mode 100644 index 00000000000..57d9b68fe34 --- /dev/null +++ b/libs/hbb_common/src/protos/mod.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/protos/mod.rs")); diff --git a/libs/hbb_common/src/proxy.rs b/libs/hbb_common/src/proxy.rs new file mode 100644 index 00000000000..d3b8a76d29c --- /dev/null +++ b/libs/hbb_common/src/proxy.rs @@ -0,0 +1,716 @@ +use std::{ + io::Error as IoError, + net::{SocketAddr, ToSocketAddrs}, +}; + +use anyhow::bail; +use async_recursion::async_recursion; +use base64::{engine::general_purpose, Engine}; +use httparse::{Error as HttpParseError, Response, EMPTY_HEADER}; +use thiserror::Error as ThisError; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufStream}; +use tokio_native_tls::{native_tls, TlsConnector, TlsStream}; +use tokio_rustls::{client::TlsStream as RustlsTlsStream, TlsConnector as RustlsTlsConnector}; +use tokio_socks::{tcp::Socks5Stream, IntoTargetAddr, TargetAddr}; +use tokio_util::codec::Framed; +use url::Url; + +use crate::{ + bytes_codec::BytesCodec, + config::Socks5Server, + tcp::{DynTcpStream, FramedStream}, + tls::{get_cached_tls_accept_invalid_cert, get_cached_tls_type, upsert_tls_cache, TlsType}, + ResultType, +}; + +#[derive(Debug, ThisError)] +pub enum ProxyError { + #[error("IO Error: {0}")] + IoError(#[from] IoError), + #[error("Target parse error: {0}")] + TargetParseError(String), + #[error("HTTP parse error: {0}")] + HttpParseError(#[from] HttpParseError), + #[error("The maximum response header length is exceeded: {0}")] + MaximumResponseHeaderLengthExceeded(usize), + #[error("The end of file is reached")] + EndOfFile, + #[error("The url is error: {0}")] + UrlBadScheme(String), + #[error("The url parse error: {0}")] + UrlParseScheme(#[from] url::ParseError), + #[error("No HTTP code was found in the response")] + NoHttpCode, + #[error("The HTTP code is not equal 200: {0}")] + HttpCode200(u16), + #[error("The proxy address resolution failed: {0}")] + AddressResolutionFailed(String), + #[error("The native tls error: {0}")] + NativeTlsError(#[from] tokio_native_tls::native_tls::Error), +} + +const MAXIMUM_RESPONSE_HEADER_LENGTH: usize = 4096; +/// The maximum HTTP Headers, which can be parsed. +const MAXIMUM_RESPONSE_HEADERS: usize = 16; +const DEFINE_TIME_OUT: u64 = 600; + +pub trait IntoUrl { + // Besides parsing as a valid `Url`, the `Url` must be a valid + // `http::Uri`, in that it makes sense to use in a network request. + fn into_url(self) -> Result; + + fn as_str(&self) -> &str; +} + +impl IntoUrl for Url { + fn into_url(self) -> Result { + if self.has_host() { + Ok(self) + } else { + Err(ProxyError::UrlBadScheme(self.to_string())) + } + } + + fn as_str(&self) -> &str { + self.as_ref() + } +} + +impl<'a> IntoUrl for &'a str { + fn into_url(self) -> Result { + Url::parse(self) + .map_err(ProxyError::UrlParseScheme)? + .into_url() + } + + fn as_str(&self) -> &str { + self + } +} + +impl<'a> IntoUrl for &'a String { + fn into_url(self) -> Result { + (&**self).into_url() + } + + fn as_str(&self) -> &str { + self.as_ref() + } +} + +impl<'a> IntoUrl for String { + fn into_url(self) -> Result { + (&*self).into_url() + } + + fn as_str(&self) -> &str { + self.as_ref() + } +} + +#[derive(Clone)] +pub struct Auth { + user_name: String, + password: String, +} + +impl Auth { + fn get_proxy_authorization(&self) -> String { + format!( + "Proxy-Authorization: Basic {}\r\n", + self.get_basic_authorization() + ) + } + + pub fn get_basic_authorization(&self) -> String { + let authorization = format!("{}:{}", &self.user_name, &self.password); + general_purpose::STANDARD.encode(authorization.as_bytes()) + } + + pub fn username(&self) -> &str { + &self.user_name + } + + pub fn password(&self) -> &str { + &self.password + } +} + +#[derive(Clone)] +pub enum ProxyScheme { + Http { + auth: Option, + host: String, + }, + Https { + auth: Option, + host: String, + }, + Socks5 { + addr: SocketAddr, + auth: Option, + remote_dns: bool, + }, +} + +impl ProxyScheme { + pub fn maybe_auth(&self) -> Option<&Auth> { + match self { + ProxyScheme::Http { auth, .. } + | ProxyScheme::Https { auth, .. } + | ProxyScheme::Socks5 { auth, .. } => auth.as_ref(), + } + } + + fn socks5(addr: SocketAddr) -> Result { + Ok(ProxyScheme::Socks5 { + addr, + auth: None, + remote_dns: false, + }) + } + + fn http(host: &str) -> Result { + Ok(ProxyScheme::Http { + auth: None, + host: host.to_string(), + }) + } + fn https(host: &str) -> Result { + Ok(ProxyScheme::Https { + auth: None, + host: host.to_string(), + }) + } + + fn set_basic_auth, U: Into>(&mut self, username: T, password: U) { + let auth = Auth { + user_name: username.into(), + password: password.into(), + }; + match self { + ProxyScheme::Http { auth: a, .. } => *a = Some(auth), + ProxyScheme::Https { auth: a, .. } => *a = Some(auth), + ProxyScheme::Socks5 { auth: a, .. } => *a = Some(auth), + } + } + + fn parse(url: Url) -> Result { + use url::Position; + + // Resolve URL to a host and port + let to_addr = || { + let addrs = url.socket_addrs(|| match url.scheme() { + "socks5" => Some(1080), + _ => None, + })?; + addrs + .into_iter() + .next() + .ok_or_else(|| ProxyError::UrlParseScheme(url::ParseError::EmptyHost)) + }; + + let mut scheme: Self = match url.scheme() { + "http" => Self::http(&url[Position::BeforeHost..Position::AfterPort])?, + "https" => Self::https(&url[Position::BeforeHost..Position::AfterPort])?, + "socks5" => Self::socks5(to_addr()?)?, + e => return Err(ProxyError::UrlBadScheme(e.to_string())), + }; + + if let Some(pwd) = url.password() { + let username = url.username(); + scheme.set_basic_auth(username, pwd); + } + + Ok(scheme) + } + pub async fn socket_addrs(&self) -> Result { + log::trace!("Resolving socket address"); + match self { + ProxyScheme::Http { host, .. } => self.resolve_host(host, 80).await, + ProxyScheme::Https { host, .. } => self.resolve_host(host, 443).await, + ProxyScheme::Socks5 { addr, .. } => Ok(addr.clone()), + } + } + + async fn resolve_host(&self, host: &str, default_port: u16) -> Result { + let (host_str, port) = match host.split_once(':') { + Some((h, p)) => (h, p.parse::().ok()), + None => (host, None), + }; + let addr = (host_str, port.unwrap_or(default_port)) + .to_socket_addrs()? + .next() + .ok_or_else(|| ProxyError::AddressResolutionFailed(host.to_string()))?; + Ok(addr) + } + + pub fn get_domain(&self) -> Result { + match self { + ProxyScheme::Http { host, .. } | ProxyScheme::Https { host, .. } => { + let domain = host + .split(':') + .next() + .ok_or_else(|| ProxyError::AddressResolutionFailed(host.clone()))?; + Ok(domain.to_string()) + } + ProxyScheme::Socks5 { addr, .. } => match addr { + SocketAddr::V4(addr_v4) => Ok(addr_v4.ip().to_string()), + SocketAddr::V6(addr_v6) => Ok(addr_v6.ip().to_string()), + }, + } + } + pub fn get_host_and_port(&self) -> Result { + match self { + ProxyScheme::Http { host, .. } => Ok(self.append_default_port(host, 80)), + ProxyScheme::Https { host, .. } => Ok(self.append_default_port(host, 443)), + ProxyScheme::Socks5 { addr, .. } => Ok(format!("{}", addr)), + } + } + fn append_default_port(&self, host: &str, default_port: u16) -> String { + if host.contains(':') { + host.to_string() + } else { + format!("{}:{}", host, default_port) + } + } +} + +pub trait IntoProxyScheme { + fn into_proxy_scheme(self) -> Result; +} + +impl IntoProxyScheme for S { + fn into_proxy_scheme(self) -> Result { + // validate the URL + let url = match self.as_str().into_url() { + Ok(ok) => ok, + Err(e) => { + match e { + // If the string does not contain protocol headers, try to parse it using the socks5 protocol + ProxyError::UrlParseScheme(_source) => { + let try_this = format!("socks5://{}", self.as_str()); + try_this.into_url()? + } + _ => { + return Err(e); + } + } + } + }; + ProxyScheme::parse(url) + } +} + +impl IntoProxyScheme for ProxyScheme { + fn into_proxy_scheme(self) -> Result { + Ok(self) + } +} + +#[derive(Clone)] +pub struct Proxy { + pub intercept: ProxyScheme, + ms_timeout: u64, +} + +impl Proxy { + pub fn new(proxy_scheme: U, ms_timeout: u64) -> Result { + Ok(Self { + intercept: proxy_scheme.into_proxy_scheme()?, + ms_timeout, + }) + } + + pub fn is_http_or_https(&self) -> bool { + return match self.intercept { + ProxyScheme::Socks5 { .. } => false, + _ => true, + }; + } + + pub fn from_conf(conf: &Socks5Server, ms_timeout: Option) -> Result { + let mut proxy; + match ms_timeout { + None => { + proxy = Self::new(&conf.proxy, DEFINE_TIME_OUT)?; + } + Some(time_out) => { + proxy = Self::new(&conf.proxy, time_out)?; + } + } + + if !conf.password.is_empty() && !conf.username.is_empty() { + proxy = proxy.basic_auth(&conf.username, &conf.password); + } + Ok(proxy) + } + + pub async fn proxy_addrs(&self) -> Result { + self.intercept.socket_addrs().await + } + + fn basic_auth(mut self, username: &str, password: &str) -> Proxy { + self.intercept.set_basic_auth(username, password); + self + } + + async fn new_stream( + &self, + local: SocketAddr, + proxy: SocketAddr, + ) -> ResultType { + let stream = super::timeout( + self.ms_timeout, + crate::tcp::new_socket(local, true)?.connect(proxy), + ) + .await??; + stream.set_nodelay(true).ok(); + Ok(stream) + } + + pub async fn connect<'t, T>( + &self, + target: T, + local_addr: Option, + ) -> ResultType + where + T: IntoTargetAddr<'t>, + { + log::trace!("Connect to proxy server"); + let proxy = self.proxy_addrs().await?; + + let target_addr = target + .into_target_addr() + .map_err(|e| ProxyError::TargetParseError(e.to_string()))?; + + let local = if let Some(addr) = local_addr { + addr + } else { + crate::config::Config::get_any_listen_addr(proxy.is_ipv4()) + }; + + let stream = self.new_stream(local, proxy).await?; + let addr = stream.local_addr()?; + + return match self.intercept { + ProxyScheme::Http { .. } => { + log::trace!("Connect to remote http proxy server: {}", proxy); + let stream = + super::timeout(self.ms_timeout, self.http_connect(stream, &target_addr)) + .await??; + Ok(FramedStream( + Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), + addr, + None, + 0, + )) + } + ProxyScheme::Https { .. } => { + log::trace!("Connect to remote https proxy server: {}", proxy); + let url = format!("https://{}", self.intercept.get_host_and_port()?); + let tls_type = get_cached_tls_type(&url); + let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(&url); + let stream = match tls_type.unwrap_or(TlsType::Rustls) { + TlsType::Rustls => { + self.https_connect_rustls_wrap_danger( + &url, + local, + proxy, + Some(stream), + &target_addr, + tls_type.is_some(), + danger_accept_invalid_cert, + danger_accept_invalid_cert, + ) + .await? + } + TlsType::NativeTls => { + self.https_connect_nativetls_wrap_danger( + &url, + local, + proxy, + &target_addr, + danger_accept_invalid_cert, + ) + .await? + } + _ => { + // Unreachable + crate::bail!("Unreachable, TlsType::Plain in HTTPS proxy"); + } + }; + Ok(FramedStream( + Framed::new(stream, BytesCodec::new()), + addr, + None, + 0, + )) + } + ProxyScheme::Socks5 { .. } => { + log::trace!("Connect to remote socket5 proxy server: {}", proxy); + let stream = if let Some(auth) = self.intercept.maybe_auth() { + super::timeout( + self.ms_timeout, + Socks5Stream::connect_with_password_and_socket( + stream, + target_addr, + &auth.user_name, + &auth.password, + ), + ) + .await?? + } else { + super::timeout( + self.ms_timeout, + Socks5Stream::connect_with_socket(stream, target_addr), + ) + .await?? + }; + Ok(FramedStream( + Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), + addr, + None, + 0, + )) + } + }; + } + + async fn https_connect_nativetls_wrap_danger<'a>( + &self, + url: &str, + local: SocketAddr, + proxy: SocketAddr, + target_addr: &TargetAddr<'a>, + danger_accept_invalid_cert: Option, + ) -> ResultType { + let stream = self.new_stream(local, proxy).await?; + let s = super::timeout( + self.ms_timeout, + self.https_connect_nativetls( + stream, + &target_addr, + danger_accept_invalid_cert.unwrap_or(false), + ), + ) + .await??; + upsert_tls_cache( + url, + TlsType::NativeTls, + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(DynTcpStream(Box::new(s))) + } + + pub async fn https_connect_nativetls<'a, Input>( + &self, + io: Input, + target_addr: &TargetAddr<'a>, + danger_accept_invalid_cert: bool, + ) -> Result>, ProxyError> + where + Input: AsyncRead + AsyncWrite + Unpin, + { + let mut tls_connector_builder = native_tls::TlsConnector::builder(); + if danger_accept_invalid_cert { + tls_connector_builder.danger_accept_invalid_certs(true); + } + let tls_connector = TlsConnector::from(tls_connector_builder.build()?); + let stream = tls_connector + .connect(&self.intercept.get_domain()?, io) + .await?; + self.http_connect(stream, target_addr).await + } + + #[async_recursion] + async fn https_connect_rustls_wrap_danger<'a>( + &self, + url: &str, + local: SocketAddr, + proxy: SocketAddr, + stream: Option, + target_addr: &TargetAddr<'a>, + is_tls_type_cached: bool, + danger_accept_invalid_cert: Option, + origin_danger_accept_invalid_cert: Option, + ) -> ResultType { + let stream = stream.unwrap_or(self.new_stream(local, proxy).await?); + match super::timeout( + self.ms_timeout, + self.https_connect_rustls( + stream, + target_addr, + danger_accept_invalid_cert.unwrap_or(false), + ), + ) + .await? + { + Ok(s) => { + upsert_tls_cache( + &url, + TlsType::Rustls, + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(DynTcpStream(Box::new(s))) + } + Err(e) => { + // NOTE: Maybe it's better to check if the error is related to TLS here. (ProxyError::IoError(e), or ProxyError::NativeTlsError(e)) + // But we can only get the error when the TLS protocol is TLSv1.1. + // The error message of the following is unclear: + // https://github.com/rustdesk/rustdesk-server-pro/issues/189#issuecomment-1895701480 + // So we just try to fallback unconditionally here. + // + // If the protocol is TLS 1.1, the error is: + // 1. "IO Error: received fatal alert: ProtocolVersion" + // 2. "IO Error: An existing connection was forcibly closed by the remote host. (os error 10054)" on Windows sometimes. + // + // If the cert verification fails, the error is: + // "IO Error: invalid peer certificate: UnknownIssuer" + + let s = if danger_accept_invalid_cert.is_none() { + log::warn!( + "Falling back to rustls-tls (accept invalid cert) for HTTPS proxy server." + ); + self.https_connect_rustls_wrap_danger( + &url, + local, + proxy, + None, + target_addr, + is_tls_type_cached, + Some(true), + origin_danger_accept_invalid_cert, + ) + .await? + } else if !is_tls_type_cached { + log::warn!("Falling back to native-tls for HTTPS proxy server."); + self.https_connect_nativetls_wrap_danger( + &url, + local, + proxy, + &target_addr, + origin_danger_accept_invalid_cert, + ) + .await? + } else { + log::error!( + "Failed to connect to HTTPS proxy server with native-tls: {:?}.", + e + ); + bail!(e) + }; + Ok(s) + } + } + } + + pub async fn https_connect_rustls<'a, Input>( + &self, + io: Input, + target_addr: &TargetAddr<'a>, + danger_accept_invalid_cert: bool, + ) -> Result>, ProxyError> + where + Input: AsyncRead + AsyncWrite + Unpin, + { + use std::convert::TryFrom; + + let url_domain = self.intercept.get_domain()?; + let domain = rustls_pki_types::ServerName::try_from(url_domain.as_str()) + .map_err(|e| ProxyError::AddressResolutionFailed(e.to_string()))? + .to_owned(); + let client_config = crate::verifier::client_config(danger_accept_invalid_cert) + .map_err(|e| ProxyError::IoError(std::io::Error::other(e)))?; + let tls_connector = RustlsTlsConnector::from(std::sync::Arc::new(client_config)); + let stream = tls_connector.connect(domain, io).await?; + self.http_connect(stream, target_addr).await + } + + pub async fn http_connect<'a, Input>( + &self, + io: Input, + target_addr: &TargetAddr<'a>, + ) -> Result, ProxyError> + where + Input: AsyncRead + AsyncWrite + Unpin, + { + let mut stream = BufStream::new(io); + let (domain, port) = get_domain_and_port(target_addr)?; + + let request = self.make_request(&domain, port); + stream.write_all(request.as_bytes()).await?; + stream.flush().await?; + recv_and_check_response(&mut stream).await?; + Ok(stream) + } + + fn make_request(&self, host: &str, port: u16) -> String { + let mut request = format!( + "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n", + host = host, + port = port + ); + + if let Some(auth) = self.intercept.maybe_auth() { + request = format!("{}{}", request, auth.get_proxy_authorization()); + } + + request.push_str("\r\n"); + request + } +} + +fn get_domain_and_port<'a>(target_addr: &TargetAddr<'a>) -> Result<(String, u16), ProxyError> { + match target_addr { + tokio_socks::TargetAddr::Ip(addr) => Ok((addr.ip().to_string(), addr.port())), + tokio_socks::TargetAddr::Domain(name, port) => Ok((name.to_string(), *port)), + } +} + +async fn get_response(stream: &mut BufStream) -> Result +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + use tokio::io::AsyncBufReadExt; + let mut response = String::new(); + + loop { + if stream.read_line(&mut response).await? == 0 { + return Err(ProxyError::EndOfFile); + } + + if MAXIMUM_RESPONSE_HEADER_LENGTH < response.len() { + return Err(ProxyError::MaximumResponseHeaderLengthExceeded( + response.len(), + )); + } + + if response.ends_with("\r\n\r\n") { + return Ok(response); + } + } +} + +async fn recv_and_check_response(stream: &mut BufStream) -> Result<(), ProxyError> +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let response_string = get_response(stream).await?; + + let mut response_headers = [EMPTY_HEADER; MAXIMUM_RESPONSE_HEADERS]; + let mut response = Response::new(&mut response_headers); + let response_bytes = response_string.into_bytes(); + response.parse(&response_bytes)?; + + return match response.code { + Some(code) => { + if code == 200 { + Ok(()) + } else { + Err(ProxyError::HttpCode200(code)) + } + } + None => Err(ProxyError::NoHttpCode), + }; +} diff --git a/libs/hbb_common/src/socket_client.rs b/libs/hbb_common/src/socket_client.rs new file mode 100644 index 00000000000..9178b74b5df --- /dev/null +++ b/libs/hbb_common/src/socket_client.rs @@ -0,0 +1,348 @@ +#[cfg(feature = "webrtc")] +use crate::webrtc::{self, is_webrtc_endpoint}; +use crate::{ + config::{Config, NetworkType}, + tcp::FramedStream, + udp::FramedSocket, + websocket::{self, check_ws, is_ws_endpoint}, + ResultType, Stream, +}; +use anyhow::Context; +use std::{net::SocketAddr, sync::Arc}; +use tokio::net::{ToSocketAddrs, UdpSocket}; +use tokio_socks::{IntoTargetAddr, TargetAddr}; + +#[inline] +pub fn check_port(host: T, port: i32) -> String { + let host = host.to_string(); + if crate::is_ipv6_str(&host) { + if host.starts_with('[') { + return host; + } + return format!("[{host}]:{port}"); + } + if !host.contains(':') { + return format!("{host}:{port}"); + } + host +} + +#[inline] +pub fn increase_port(host: T, offset: i32) -> String { + let host = host.to_string(); + if crate::is_ipv6_str(&host) { + if host.starts_with('[') { + let tmp: Vec<&str> = host.split("]:").collect(); + if tmp.len() == 2 { + let port: i32 = tmp[1].parse().unwrap_or(0); + if port > 0 { + return format!("{}]:{}", tmp[0], port + offset); + } + } + } + } else if host.contains(':') { + let tmp: Vec<&str> = host.split(':').collect(); + if tmp.len() == 2 { + let port: i32 = tmp[1].parse().unwrap_or(0); + if port > 0 { + return format!("{}:{}", tmp[0], port + offset); + } + } + } + host +} + +pub fn split_host_port(host: T) -> Option<(String, i32)> { + let host = host.to_string(); + if crate::is_ipv6_str(&host) { + if host.starts_with('[') { + let tmp: Vec<&str> = host.split("]:").collect(); + if tmp.len() == 2 { + let port: i32 = tmp[1].parse().unwrap_or(0); + if port > 0 { + return Some((format!("{}]", tmp[0]), port)); + } + } + } + } else if host.contains(':') { + let tmp: Vec<&str> = host.split(':').collect(); + if tmp.len() == 2 { + let port: i32 = tmp[1].parse().unwrap_or(0); + if port > 0 { + return Some((tmp[0].to_string(), port)); + } + } + } + None +} + +pub fn test_if_valid_server(host: &str, test_with_proxy: bool) -> String { + let host = check_port(host, 0); + use std::net::ToSocketAddrs; + + if test_with_proxy && NetworkType::ProxySocks == Config::get_network_type() { + test_if_valid_server_for_proxy_(&host) + } else { + match host.to_socket_addrs() { + Err(err) => err.to_string(), + Ok(_) => "".to_owned(), + } + } +} + +#[inline] +pub fn test_if_valid_server_for_proxy_(host: &str) -> String { + // `&host.into_target_addr()` is defined in `tokio-socs`, but is a common pattern for testing, + // it can be used for both `socks` and `http` proxy. + match &host.into_target_addr() { + Err(err) => err.to_string(), + Ok(_) => "".to_owned(), + } +} + +pub trait IsResolvedSocketAddr { + fn resolve(&self) -> Option<&SocketAddr>; +} + +impl IsResolvedSocketAddr for SocketAddr { + fn resolve(&self) -> Option<&SocketAddr> { + Some(self) + } +} + +impl IsResolvedSocketAddr for String { + fn resolve(&self) -> Option<&SocketAddr> { + None + } +} + +impl IsResolvedSocketAddr for &str { + fn resolve(&self) -> Option<&SocketAddr> { + None + } +} + +// This function checks if the target is a websocket endpoint and connects accordingly. +#[inline] +pub async fn connect_tcp< + 't, + T: IntoTargetAddr<'t> + ToSocketAddrs + IsResolvedSocketAddr + std::fmt::Display, +>( + target: T, + ms_timeout: u64, +) -> ResultType { + #[cfg(feature = "webrtc")] + if is_webrtc_endpoint(&target.to_string()) { + return Ok(Stream::WebRTC( + webrtc::WebRTCStream::new(&target.to_string(), false, ms_timeout).await?, + )); + } + let target_str = check_ws(&target.to_string()); + if is_ws_endpoint(&target_str) { + return Ok(Stream::WebSocket( + websocket::WsFramedStream::new(target_str, None, None, ms_timeout).await?, + )); + } + connect_tcp_local(target, None, ms_timeout).await +} + +// This function connects directly to the target without checking for websocket endpoints. +pub async fn connect_tcp_local< + 't, + T: IntoTargetAddr<'t> + ToSocketAddrs + IsResolvedSocketAddr + std::fmt::Display, +>( + target: T, + local: Option, + ms_timeout: u64, +) -> ResultType { + if let Some(conf) = Config::get_socks() { + return Ok(Stream::Tcp( + FramedStream::connect(target, local, &conf, ms_timeout).await?, + )); + } + + if let Some(target_addr) = target.resolve() { + if let Some(local_addr) = local { + if local_addr.is_ipv6() && target_addr.is_ipv4() { + let resolved_target = query_nip_io(target_addr).await?; + return Ok(Stream::Tcp( + FramedStream::new(resolved_target, Some(local_addr), ms_timeout).await?, + )); + } + } + } + + Ok(Stream::Tcp( + FramedStream::new(target, local, ms_timeout).await?, + )) +} + +#[inline] +pub fn is_ipv4(target: &TargetAddr<'_>) -> bool { + match target { + TargetAddr::Ip(addr) => addr.is_ipv4(), + _ => true, + } +} + +#[inline] +pub async fn query_nip_io(addr: &SocketAddr) -> ResultType { + tokio::net::lookup_host(format!("{}.nip.io:{}", addr.ip(), addr.port())) + .await? + .find(|x| x.is_ipv6()) + .context("Failed to get ipv6 from nip.io") +} + +#[inline] +pub fn ipv4_to_ipv6(addr: String, ipv4: bool) -> String { + if !ipv4 && crate::is_ipv4_str(&addr) { + if let Some(ip) = addr.split(':').next() { + return addr.replace(ip, &format!("{ip}.nip.io")); + } + } + addr +} + +async fn test_target(target: &str) -> ResultType { + if let Ok(Ok(s)) = super::timeout(1000, tokio::net::TcpStream::connect(target)).await { + if let Ok(addr) = s.peer_addr() { + return Ok(addr); + } + } + tokio::net::lookup_host(target) + .await? + .next() + .context(format!("Failed to look up host for {target}")) +} + +#[inline] +pub async fn new_direct_udp_for(target: &str) -> ResultType<(Arc, SocketAddr)> { + let peer_addr = test_target(target).await?; + let local_addr = Config::get_any_listen_addr(peer_addr.is_ipv4()); + let socket = UdpSocket::bind(local_addr).await?; + Ok((Arc::new(socket), peer_addr)) +} + +#[inline] +pub async fn new_udp_for( + target: &str, + ms_timeout: u64, +) -> ResultType<(FramedSocket, TargetAddr<'static>)> { + let (ipv4, target) = if NetworkType::Direct == Config::get_network_type() { + let addr = test_target(target).await?; + (addr.is_ipv4(), addr.into_target_addr()?) + } else { + (true, target.into_target_addr()?) + }; + Ok(( + new_udp(Config::get_any_listen_addr(ipv4), ms_timeout).await?, + target.to_owned(), + )) +} + +async fn new_udp(local: T, ms_timeout: u64) -> ResultType { + match Config::get_socks() { + None => Ok(FramedSocket::new(local).await?), + Some(conf) => { + let socket = FramedSocket::new_proxy( + conf.proxy.as_str(), + local, + conf.username.as_str(), + conf.password.as_str(), + ms_timeout, + ) + .await?; + Ok(socket) + } + } +} + +pub async fn rebind_udp_for( + target: &str, +) -> ResultType)>> { + if Config::get_network_type() != NetworkType::Direct { + return Ok(None); + } + let addr = test_target(target).await?; + let v4 = addr.is_ipv4(); + Ok(Some(( + FramedSocket::new(Config::get_any_listen_addr(v4)).await?, + addr.into_target_addr()?.to_owned(), + ))) +} + +#[cfg(test)] +mod tests { + use std::net::ToSocketAddrs; + + use super::*; + + #[test] + fn test_nat64() { + test_nat64_async(); + } + + #[tokio::main(flavor = "current_thread")] + async fn test_nat64_async() { + assert_eq!(ipv4_to_ipv6("1.1.1.1".to_owned(), true), "1.1.1.1"); + assert_eq!(ipv4_to_ipv6("1.1.1.1".to_owned(), false), "1.1.1.1.nip.io"); + assert_eq!( + ipv4_to_ipv6("1.1.1.1:8080".to_owned(), false), + "1.1.1.1.nip.io:8080" + ); + assert_eq!( + ipv4_to_ipv6("rustdesk.com".to_owned(), false), + "rustdesk.com" + ); + if ("rustdesk.com:80") + .to_socket_addrs() + .unwrap() + .next() + .unwrap() + .is_ipv6() + { + assert!(query_nip_io(&"1.1.1.1:80".parse().unwrap()) + .await + .unwrap() + .is_ipv6()); + return; + } + assert!(query_nip_io(&"1.1.1.1:80".parse().unwrap()).await.is_err()); + } + + #[test] + fn test_test_if_valid_server() { + assert!(!test_if_valid_server("a", false).is_empty()); + // on Linux, "1" is resolved to "0.0.0.1" + assert!(test_if_valid_server("1.1.1.1", false).is_empty()); + assert!(test_if_valid_server("1.1.1.1:1", false).is_empty()); + assert!(test_if_valid_server("microsoft.com", false).is_empty()); + assert!(test_if_valid_server("microsoft.com:1", false).is_empty()); + + // with proxy + // `:0` indicates `let host = check_port(host, 0);` is called. + assert!(test_if_valid_server_for_proxy_("a:0").is_empty()); + assert!(test_if_valid_server_for_proxy_("1.1.1.1:0").is_empty()); + assert!(test_if_valid_server_for_proxy_("1.1.1.1:1").is_empty()); + assert!(test_if_valid_server_for_proxy_("abc.com:0").is_empty()); + assert!(test_if_valid_server_for_proxy_("abcd.com:1").is_empty()); + } + + #[test] + fn test_check_port() { + assert_eq!(check_port("[1:2]:12", 32), "[1:2]:12"); + assert_eq!(check_port("1:2", 32), "[1:2]:32"); + assert_eq!(check_port("z1:2", 32), "z1:2"); + assert_eq!(check_port("1.1.1.1", 32), "1.1.1.1:32"); + assert_eq!(check_port("1.1.1.1:32", 32), "1.1.1.1:32"); + assert_eq!(check_port("test.com:32", 0), "test.com:32"); + assert_eq!(increase_port("[1:2]:12", 1), "[1:2]:13"); + assert_eq!(increase_port("1.2.2.4:12", 1), "1.2.2.4:13"); + assert_eq!(increase_port("1.2.2.4", 1), "1.2.2.4"); + assert_eq!(increase_port("test.com", 1), "test.com"); + assert_eq!(increase_port("test.com:13", 4), "test.com:17"); + assert_eq!(increase_port("1:13", 4), "1:13"); + assert_eq!(increase_port("22:1:13", 4), "22:1:13"); + assert_eq!(increase_port("z1:2", 1), "z1:3"); + } +} diff --git a/libs/hbb_common/src/stream.rs b/libs/hbb_common/src/stream.rs new file mode 100644 index 00000000000..a8e6b6c2d1b --- /dev/null +++ b/libs/hbb_common/src/stream.rs @@ -0,0 +1,149 @@ +use crate::{config, tcp, websocket, ResultType}; +#[cfg(feature = "webrtc")] +use crate::webrtc; +use sodiumoxide::crypto::secretbox::Key; +use std::net::SocketAddr; +use tokio::net::TcpStream; + +// support Websocket and tcp. +pub enum Stream { + #[cfg(feature = "webrtc")] + WebRTC(webrtc::WebRTCStream), + WebSocket(websocket::WsFramedStream), + Tcp(tcp::FramedStream), +} + +impl Stream { + #[inline] + pub fn set_send_timeout(&mut self, ms: u64) { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.set_send_timeout(ms), + Stream::WebSocket(s) => s.set_send_timeout(ms), + Stream::Tcp(s) => s.set_send_timeout(ms), + } + } + + #[inline] + pub fn set_raw(&mut self) { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.set_raw(), + Stream::WebSocket(s) => s.set_raw(), + Stream::Tcp(s) => s.set_raw(), + } + } + + #[inline] + pub async fn send_bytes(&mut self, bytes: bytes::Bytes) -> ResultType<()> { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.send_bytes(bytes).await, + Stream::WebSocket(s) => s.send_bytes(bytes).await, + Stream::Tcp(s) => s.send_bytes(bytes).await, + } + } + + #[inline] + pub async fn send_raw(&mut self, bytes: Vec) -> ResultType<()> { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.send_raw(bytes).await, + Stream::WebSocket(s) => s.send_raw(bytes).await, + Stream::Tcp(s) => s.send_raw(bytes).await, + } + } + + #[inline] + pub fn set_key(&mut self, key: Key) { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.set_key(key), + Stream::WebSocket(s) => s.set_key(key), + Stream::Tcp(s) => s.set_key(key), + } + } + + #[inline] + pub fn is_secured(&self) -> bool { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.is_secured(), + Stream::WebSocket(s) => s.is_secured(), + Stream::Tcp(s) => s.is_secured(), + } + } + + #[inline] + pub async fn next_timeout( + &mut self, + timeout: u64, + ) -> Option> { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => s.next_timeout(timeout).await, + Stream::WebSocket(s) => s.next_timeout(timeout).await, + Stream::Tcp(s) => s.next_timeout(timeout).await, + } + } + + /// establish connect from websocket + #[inline] + pub async fn connect_websocket( + url: impl AsRef, + local_addr: Option, + proxy_conf: Option<&config::Socks5Server>, + timeout_ms: u64, + ) -> ResultType { + let ws_stream = + websocket::WsFramedStream::new(url, local_addr, proxy_conf, timeout_ms).await?; + log::debug!("WebSocket connection established"); + Ok(Self::WebSocket(ws_stream)) + } + + /// send message + #[inline] + pub async fn send(&mut self, msg: &impl protobuf::Message) -> ResultType<()> { + match self { + #[cfg(feature = "webrtc")] + Self::WebRTC(s) => s.send(msg).await, + Self::WebSocket(ws) => ws.send(msg).await, + Self::Tcp(tcp) => tcp.send(msg).await, + } + } + + /// receive message + #[inline] + pub async fn next(&mut self) -> Option> { + match self { + #[cfg(feature = "webrtc")] + Self::WebRTC(s) => s.next().await, + Self::WebSocket(ws) => ws.next().await, + Self::Tcp(tcp) => tcp.next().await, + } + } + + #[inline] + pub fn local_addr(&self) -> SocketAddr { + match self { + #[cfg(feature = "webrtc")] + Self::WebRTC(s) => s.local_addr(), + Self::WebSocket(ws) => ws.local_addr(), + Self::Tcp(tcp) => tcp.local_addr(), + } + } + + #[inline] + pub fn from(stream: TcpStream, stream_addr: SocketAddr) -> Self { + Self::Tcp(tcp::FramedStream::from(stream, stream_addr)) + } + + #[inline] + #[cfg(feature = "webrtc")] + pub fn get_webrtc_stream(&self) -> Option { + match self { + Self::WebRTC(s) => Some(s.clone()), + _ => None, + } + } +} diff --git a/libs/hbb_common/src/tcp.rs b/libs/hbb_common/src/tcp.rs new file mode 100644 index 00000000000..2296edb1d3b --- /dev/null +++ b/libs/hbb_common/src/tcp.rs @@ -0,0 +1,344 @@ +use crate::{bail, bytes_codec::BytesCodec, ResultType, config::Socks5Server, proxy::Proxy}; +use anyhow::Context as AnyhowCtx; +use bytes::{BufMut, Bytes, BytesMut}; +use futures::{SinkExt, StreamExt}; +use protobuf::Message; +use sodiumoxide::crypto::{ + box_, + secretbox::{self, Key, Nonce}, +}; +use std::{ + io::{self, Error, ErrorKind}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + ops::{Deref, DerefMut}, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::{lookup_host, TcpListener, TcpSocket, ToSocketAddrs}, +}; +use tokio_socks::IntoTargetAddr; +use tokio_util::codec::Framed; + +pub trait TcpStreamTrait: AsyncRead + AsyncWrite + Unpin {} +pub struct DynTcpStream(pub Box); + +#[derive(Clone)] +pub struct Encrypt(pub Key, pub u64, pub u64); + +pub struct FramedStream( + pub Framed, + pub SocketAddr, + pub Option, + pub u64, +); + +impl Deref for FramedStream { + type Target = Framed; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for FramedStream { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Deref for DynTcpStream { + type Target = Box; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DynTcpStream { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +pub(crate) fn new_socket(addr: std::net::SocketAddr, reuse: bool) -> Result { + let socket = match addr { + std::net::SocketAddr::V4(..) => TcpSocket::new_v4()?, + std::net::SocketAddr::V6(..) => TcpSocket::new_v6()?, + }; + if reuse { + // windows has no reuse_port, but its reuse_address + // almost equals to unix's reuse_port + reuse_address, + // though may introduce nondeterministic behavior + // illumos has no support for SO_REUSEPORT + #[cfg(all(unix, not(target_os = "illumos")))] + socket.set_reuseport(true).ok(); + socket.set_reuseaddr(true).ok(); + } + socket.bind(addr)?; + Ok(socket) +} + +impl FramedStream { + pub async fn new( + remote_addr: T, + local_addr: Option, + ms_timeout: u64, + ) -> ResultType { + for remote_addr in lookup_host(&remote_addr).await? { + let local = if let Some(addr) = local_addr { + addr + } else { + crate::config::Config::get_any_listen_addr(remote_addr.is_ipv4()) + }; + if let Ok(socket) = new_socket(local, true) { + if let Ok(Ok(stream)) = + super::timeout(ms_timeout, socket.connect(remote_addr)).await + { + stream.set_nodelay(true).ok(); + let addr = stream.local_addr()?; + return Ok(Self( + Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), + addr, + None, + 0, + )); + } + } + } + bail!(format!("Failed to connect to {remote_addr}")); + } + + pub async fn connect<'t, T>( + target: T, + local_addr: Option, + proxy_conf: &Socks5Server, + ms_timeout: u64, + ) -> ResultType + where + T: IntoTargetAddr<'t>, + { + let proxy = Proxy::from_conf(proxy_conf, Some(ms_timeout))?; + proxy.connect::(target, local_addr).await + } + + pub fn local_addr(&self) -> SocketAddr { + self.1 + } + + pub fn set_send_timeout(&mut self, ms: u64) { + self.3 = ms; + } + + pub fn from(stream: impl TcpStreamTrait + Send + Sync + 'static, addr: SocketAddr) -> Self { + Self( + Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), + addr, + None, + 0, + ) + } + + pub fn set_raw(&mut self) { + self.0.codec_mut().set_raw(); + self.2 = None; + } + + pub fn is_secured(&self) -> bool { + self.2.is_some() + } + + #[inline] + pub async fn send(&mut self, msg: &impl Message) -> ResultType<()> { + self.send_raw(msg.write_to_bytes()?).await + } + + #[inline] + pub async fn send_raw(&mut self, msg: Vec) -> ResultType<()> { + let mut msg = msg; + if let Some(key) = self.2.as_mut() { + msg = key.enc(&msg); + } + self.send_bytes(bytes::Bytes::from(msg)).await?; + Ok(()) + } + + #[inline] + pub async fn send_bytes(&mut self, bytes: Bytes) -> ResultType<()> { + if self.3 > 0 { + super::timeout(self.3, self.0.send(bytes)).await??; + } else { + self.0.send(bytes).await?; + } + Ok(()) + } + + #[inline] + pub async fn next(&mut self) -> Option> { + let mut res = self.0.next().await; + if let Some(Ok(bytes)) = res.as_mut() { + if let Some(key) = self.2.as_mut() { + if let Err(err) = key.dec(bytes) { + return Some(Err(err)); + } + } + } + res + } + + #[inline] + pub async fn next_timeout(&mut self, ms: u64) -> Option> { + if let Ok(res) = super::timeout(ms, self.next()).await { + res + } else { + None + } + } + + pub fn set_key(&mut self, key: Key) { + self.2 = Some(Encrypt::new(key)); + } + + fn get_nonce(seqnum: u64) -> Nonce { + let mut nonce = Nonce([0u8; secretbox::NONCEBYTES]); + nonce.0[..std::mem::size_of_val(&seqnum)].copy_from_slice(&seqnum.to_le_bytes()); + nonce + } +} + +const DEFAULT_BACKLOG: u32 = 128; + +pub async fn new_listener(addr: T, reuse: bool) -> ResultType { + if !reuse { + Ok(TcpListener::bind(addr).await?) + } else { + let addr = lookup_host(&addr) + .await? + .next() + .context("could not resolve to any address")?; + new_socket(addr, true)? + .listen(DEFAULT_BACKLOG) + .map_err(anyhow::Error::msg) + } +} + +pub async fn listen_any(port: u16) -> ResultType { + if let Ok(mut socket) = TcpSocket::new_v6() { + #[cfg(unix)] + { + // illumos has no support for SO_REUSEPORT + #[cfg(not(target_os = "illumos"))] + socket.set_reuseport(true).ok(); + socket.set_reuseaddr(true).ok(); + use std::os::unix::io::{FromRawFd, IntoRawFd}; + let raw_fd = socket.into_raw_fd(); + let sock2 = unsafe { socket2::Socket::from_raw_fd(raw_fd) }; + sock2.set_only_v6(false).ok(); + socket = unsafe { TcpSocket::from_raw_fd(sock2.into_raw_fd()) }; + } + #[cfg(windows)] + { + use std::os::windows::prelude::{FromRawSocket, IntoRawSocket}; + let raw_socket = socket.into_raw_socket(); + let sock2 = unsafe { socket2::Socket::from_raw_socket(raw_socket) }; + sock2.set_only_v6(false).ok(); + socket = unsafe { TcpSocket::from_raw_socket(sock2.into_raw_socket()) }; + } + if socket + .bind(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port)) + .is_ok() + { + if let Ok(l) = socket.listen(DEFAULT_BACKLOG) { + return Ok(l); + } + } + } + Ok(new_socket( + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), + true, + )? + .listen(DEFAULT_BACKLOG)?) +} + +impl Unpin for DynTcpStream {} + +impl AsyncRead for DynTcpStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + AsyncRead::poll_read(Pin::new(&mut self.0), cx, buf) + } +} + +impl AsyncWrite for DynTcpStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + AsyncWrite::poll_write(Pin::new(&mut self.0), cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + AsyncWrite::poll_flush(Pin::new(&mut self.0), cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + AsyncWrite::poll_shutdown(Pin::new(&mut self.0), cx) + } +} + +impl TcpStreamTrait for R {} + +impl Encrypt { + pub fn new(key: Key) -> Self { + Self(key, 0, 0) + } + + pub fn dec(&mut self, bytes: &mut BytesMut) -> Result<(), Error> { + if bytes.len() <= 1 { + return Ok(()); + } + self.2 += 1; + let nonce = FramedStream::get_nonce(self.2); + match secretbox::open(bytes, &nonce, &self.0) { + Ok(res) => { + bytes.clear(); + bytes.put_slice(&res); + Ok(()) + } + Err(()) => Err(Error::new(ErrorKind::Other, "decryption error")), + } + } + + pub fn enc(&mut self, data: &[u8]) -> Vec { + self.1 += 1; + let nonce = FramedStream::get_nonce(self.1); + secretbox::seal(&data, &nonce, &self.0) + } + + pub fn decode( + symmetric_data: &[u8], + their_pk_b: &[u8], + our_sk_b: &box_::SecretKey, + ) -> ResultType { + if their_pk_b.len() != box_::PUBLICKEYBYTES { + anyhow::bail!("Handshake failed: pk length {}", their_pk_b.len()); + } + let nonce = box_::Nonce([0u8; box_::NONCEBYTES]); + let mut pk_ = [0u8; box_::PUBLICKEYBYTES]; + pk_[..].copy_from_slice(their_pk_b); + let their_pk_b = box_::PublicKey(pk_); + let symmetric_key = box_::open(symmetric_data, &nonce, &their_pk_b, &our_sk_b) + .map_err(|_| anyhow::anyhow!("Handshake failed: box decryption failure"))?; + if symmetric_key.len() != secretbox::KEYBYTES { + anyhow::bail!("Handshake failed: invalid secret key length from peer"); + } + let mut key = [0u8; secretbox::KEYBYTES]; + key[..].copy_from_slice(&symmetric_key); + Ok(Key(key)) + } +} diff --git a/libs/hbb_common/src/tls.rs b/libs/hbb_common/src/tls.rs new file mode 100644 index 00000000000..b0862369bc9 --- /dev/null +++ b/libs/hbb_common/src/tls.rs @@ -0,0 +1,121 @@ +use std::{collections::HashMap, sync::RwLock}; + +use crate::config::allow_insecure_tls_fallback; + +#[derive(Debug, Clone, Copy)] +pub enum TlsType { + Plain, + NativeTls, + Rustls, +} + +lazy_static::lazy_static! { + static ref URL_TLS_TYPE: RwLock> = RwLock::new(HashMap::new()); + static ref URL_TLS_DANGER_ACCEPT_INVALID_CERTS: RwLock> = RwLock::new(HashMap::new()); +} + +#[inline] +pub fn is_plain(url: &str) -> bool { + url.starts_with("ws://") || url.starts_with("http://") +} + +// Extract domain from URL. +// e.g., "https://example.com/path" -> "example.com" +// "https://example.com:8080/path" -> "example.com:8080" +// See the tests for more examples. +#[inline] +fn get_domain_and_port_from_url(url: &str) -> &str { + // Remove scheme (e.g., http://, https://, ws://, wss://) + let scheme_end = url.find("://").map(|pos| pos + 3).unwrap_or(0); + let url2 = &url[scheme_end..]; + // If userinfo is present, domain is after last '@' + let after_at = match url2.rfind('@') { + Some(pos) => &url2[pos + 1..], + None => url2, + }; + // Find the end of domain (before '/' or '?') + let domain_end = after_at.find(&['/', '?'][..]).unwrap_or(after_at.len()); + &after_at[..domain_end] +} + +#[inline] +pub fn upsert_tls_cache(url: &str, tls_type: TlsType, danger_accept_invalid_cert: bool) { + if is_plain(url) { + return; + } + + let domain_port = get_domain_and_port_from_url(url); + // Use curly braces to ensure the lock is released immediately. + { + URL_TLS_TYPE + .write() + .unwrap() + .insert(domain_port.to_string(), tls_type); + } + { + URL_TLS_DANGER_ACCEPT_INVALID_CERTS + .write() + .unwrap() + .insert(domain_port.to_string(), danger_accept_invalid_cert); + } +} + +#[inline] +pub fn reset_tls_cache() { + // Use curly braces to ensure the lock is released immediately. + { + URL_TLS_TYPE.write().unwrap().clear(); + } + { + URL_TLS_DANGER_ACCEPT_INVALID_CERTS.write().unwrap().clear(); + } +} + +#[inline] +pub fn get_cached_tls_type(url: &str) -> Option { + if is_plain(url) { + return Some(TlsType::Plain); + } + let domain_port = get_domain_and_port_from_url(url); + URL_TLS_TYPE.read().unwrap().get(domain_port).cloned() +} + +#[inline] +pub fn get_cached_tls_accept_invalid_cert(url: &str) -> Option { + if !allow_insecure_tls_fallback() { + return Some(false); + } + + if is_plain(url) { + return Some(false); + } + let domain_port = get_domain_and_port_from_url(url); + URL_TLS_DANGER_ACCEPT_INVALID_CERTS + .read() + .unwrap() + .get(domain_port) + .cloned() +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_domain_and_port_from_url() { + for (url, expected_domain_port) in vec![ + ("http://example.com", "example.com"), + ("https://example.com", "example.com"), + ("ws://example.com/path", "example.com"), + ("wss://example.com:8080/path", "example.com:8080"), + ("https://user:pass@example.com", "example.com"), + ("https://example.com?query=param", "example.com"), + ("https://example.com:8443?query=param", "example.com:8443"), + ("ftp://example.com/resource", "example.com"), // ftp scheme + ("example.com/path", "example.com"), // no scheme + ("example.com:8080/path", "example.com:8080"), + ] { + let domain_port = get_domain_and_port_from_url(url); + assert_eq!(domain_port, expected_domain_port); + } + } +} diff --git a/libs/hbb_common/src/udp.rs b/libs/hbb_common/src/udp.rs new file mode 100644 index 00000000000..fbdf332f305 --- /dev/null +++ b/libs/hbb_common/src/udp.rs @@ -0,0 +1,171 @@ +use crate::ResultType; +use anyhow::{anyhow, Context}; +use bytes::{Bytes, BytesMut}; +use futures::{SinkExt, StreamExt}; +use protobuf::Message; +use socket2::{Domain, Socket, Type}; +use std::net::SocketAddr; +use tokio::net::{lookup_host, ToSocketAddrs, UdpSocket}; +use tokio_socks::{udp::Socks5UdpFramed, IntoTargetAddr, TargetAddr, ToProxyAddrs}; +use tokio_util::{codec::BytesCodec, udp::UdpFramed}; + +pub enum FramedSocket { + Direct(UdpFramed), + ProxySocks(Socks5UdpFramed), +} + +fn new_socket(addr: SocketAddr, reuse: bool, buf_size: usize) -> Result { + let socket = match addr { + SocketAddr::V4(..) => Socket::new(Domain::ipv4(), Type::dgram(), None), + SocketAddr::V6(..) => Socket::new(Domain::ipv6(), Type::dgram(), None), + }?; + if reuse { + // windows has no reuse_port, but its reuse_address + // almost equals to unix's reuse_port + reuse_address, + // though may introduce nondeterministic behavior + // illumos has no support for SO_REUSEPORT + #[cfg(all(unix, not(target_os = "illumos")))] + socket.set_reuse_port(true).ok(); + socket.set_reuse_address(true).ok(); + } + // only nonblocking work with tokio, https://stackoverflow.com/questions/64649405/receiver-on-tokiompscchannel-only-receives-messages-when-buffer-is-full + socket.set_nonblocking(true)?; + if buf_size > 0 { + socket.set_recv_buffer_size(buf_size).ok(); + } + log::debug!( + "Receive buf size of udp {}: {:?}", + addr, + socket.recv_buffer_size() + ); + if addr.is_ipv6() && addr.ip().is_unspecified() && addr.port() > 0 { + socket.set_only_v6(false).ok(); + } + socket.bind(&addr.into())?; + Ok(socket) +} + +impl FramedSocket { + pub async fn new(addr: T) -> ResultType { + Self::new_reuse(addr, false, 0).await + } + + pub async fn new_reuse( + addr: T, + reuse: bool, + buf_size: usize, + ) -> ResultType { + let addr = lookup_host(&addr) + .await? + .next() + .context("could not resolve to any address")?; + Ok(Self::Direct(UdpFramed::new( + UdpSocket::from_std(new_socket(addr, reuse, buf_size)?.into_udp_socket())?, + BytesCodec::new(), + ))) + } + + pub async fn new_proxy<'a, 't, P: ToProxyAddrs, T: ToSocketAddrs>( + proxy: P, + local: T, + username: &'a str, + password: &'a str, + ms_timeout: u64, + ) -> ResultType { + let framed = if username.trim().is_empty() { + super::timeout(ms_timeout, Socks5UdpFramed::connect(proxy, Some(local))).await?? + } else { + super::timeout( + ms_timeout, + Socks5UdpFramed::connect_with_password(proxy, Some(local), username, password), + ) + .await?? + }; + log::trace!( + "Socks5 udp connected, local addr: {:?}, target addr: {}", + framed.local_addr(), + framed.socks_addr() + ); + Ok(Self::ProxySocks(framed)) + } + + #[inline] + pub async fn send( + &mut self, + msg: &impl Message, + addr: impl IntoTargetAddr<'_>, + ) -> ResultType<()> { + let addr = addr.into_target_addr()?.to_owned(); + let send_data = Bytes::from(msg.write_to_bytes()?); + match self { + Self::Direct(f) => { + if let TargetAddr::Ip(addr) = addr { + f.send((send_data, addr)).await? + } + } + Self::ProxySocks(f) => f.send((send_data, addr)).await?, + }; + Ok(()) + } + + // https://stackoverflow.com/a/68733302/1926020 + #[inline] + pub async fn send_raw( + &mut self, + msg: &'static [u8], + addr: impl IntoTargetAddr<'static>, + ) -> ResultType<()> { + let addr = addr.into_target_addr()?.to_owned(); + + match self { + Self::Direct(f) => { + if let TargetAddr::Ip(addr) = addr { + f.send((Bytes::from(msg), addr)).await? + } + } + Self::ProxySocks(f) => f.send((Bytes::from(msg), addr)).await?, + }; + Ok(()) + } + + #[inline] + pub async fn next(&mut self) -> Option)>> { + match self { + Self::Direct(f) => match f.next().await { + Some(Ok((data, addr))) => { + Some(Ok((data, addr.into_target_addr().ok()?.to_owned()))) + } + Some(Err(e)) => Some(Err(anyhow!(e))), + None => None, + }, + Self::ProxySocks(f) => match f.next().await { + Some(Ok((data, _))) => Some(Ok((data.data, data.dst_addr))), + Some(Err(e)) => Some(Err(anyhow!(e))), + None => None, + }, + } + } + + #[inline] + pub async fn next_timeout( + &mut self, + ms: u64, + ) -> Option)>> { + if let Ok(res) = + tokio::time::timeout(std::time::Duration::from_millis(ms), self.next()).await + { + res + } else { + None + } + } + + pub fn local_addr(&self) -> Option { + if let FramedSocket::Direct(x) = self { + if let Ok(v) = x.get_ref().local_addr() { + return Some(v); + } + } + None + } +} diff --git a/libs/hbb_common/src/verifier.rs b/libs/hbb_common/src/verifier.rs new file mode 100644 index 00000000000..9f628549e1b --- /dev/null +++ b/libs/hbb_common/src/verifier.rs @@ -0,0 +1,257 @@ +use crate::ResultType; +use rustls_pki_types::{ServerName, UnixTime}; +use std::sync::Arc; +use tokio_rustls::rustls::{self, client::WebPkiServerVerifier, ClientConfig}; +use tokio_rustls::rustls::{ + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + DigitallySignedStruct, Error as TLSError, SignatureScheme, +}; + +// https://github.com/seanmonstar/reqwest/blob/fd61bc93e6f936454ce0b978c6f282f06eee9287/src/tls.rs#L608 +#[derive(Debug)] +pub(crate) struct NoVerifier; + +impl ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer, + _intermediates: &[rustls_pki_types::CertificateDer], + _server_name: &ServerName, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA1, + SignatureScheme::ECDSA_SHA1_Legacy, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} + +/// A certificate verifier that tries a primary verifier first, +/// and falls back to a platform verifier if the primary fails. +#[cfg(any(target_os = "android", target_os = "ios"))] +#[derive(Debug)] +struct FallbackPlatformVerifier { + primary: Arc, + fallback: Arc, +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +impl FallbackPlatformVerifier { + fn with_platform_fallback( + primary: Arc, + provider: Arc, + ) -> Result { + #[cfg(target_os = "android")] + if !crate::config::ANDROID_RUSTLS_PLATFORM_VERIFIER_INITIALIZED + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(TLSError::General( + "rustls-platform-verifier not initialized".to_string(), + )); + } + let fallback = Arc::new(rustls_platform_verifier::Verifier::new(provider)?); + Ok(Self { primary, fallback }) + } +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +impl ServerCertVerifier for FallbackPlatformVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls_pki_types::CertificateDer<'_>, + intermediates: &[rustls_pki_types::CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + match self.primary.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Ok(verified) => Ok(verified), + Err(primary_err) => { + match self.fallback.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Ok(verified) => Ok(verified), + Err(fallback_err) => { + log::error!( + "Both primary and fallback verifiers failed to verify server certificate, primary error: {:?}, fallback error: {:?}", + primary_err, + fallback_err + ); + Err(primary_err) + } + } + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls_pki_types::CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + // Both WebPkiServerVerifier and rustls_platform_verifier use the same signature verification implementation. + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/webpki/server_verifier.rs#L278 + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/crypto/mod.rs#L17 + // https://github.com/rustls/rustls-platform-verifier/blob/1099f161bfc5e3ac7f90aad88b1bf788e72906cb/rustls-platform-verifier/src/verification/android.rs#L9 + // https://github.com/rustls/rustls-platform-verifier/blob/1099f161bfc5e3ac7f90aad88b1bf788e72906cb/rustls-platform-verifier/src/verification/apple.rs#L6 + self.primary.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls_pki_types::CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + // Same implementation as verify_tls12_signature. + self.primary.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + // Both WebPkiServerVerifier and rustls_platform_verifier use the same crypto provider, + // so their supported signature schemes are identical. + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/webpki/server_verifier.rs#L172C52-L172C85 + // https://github.com/rustls/rustls-platform-verifier/blob/1099f161bfc5e3ac7f90aad88b1bf788e72906cb/rustls-platform-verifier/src/verification/android.rs#L327 + // https://github.com/rustls/rustls-platform-verifier/blob/1099f161bfc5e3ac7f90aad88b1bf788e72906cb/rustls-platform-verifier/src/verification/apple.rs#L304 + self.primary.supported_verify_schemes() + } +} + +fn webpki_server_verifier( + provider: Arc, +) -> ResultType> { + // Load root certificates from both bundled webpki_roots and system-native certificate stores. + // This approach is consistent with how reqwest and tokio-tungstenite handle root certificates. + // https://github.com/snapview/tokio-tungstenite/blob/35d110c24c9d030d1608ec964d70c789dfb27452/src/tls.rs#L95 + // https://github.com/seanmonstar/reqwest/blob/b126ca49da7897e5d676639cdbf67a0f6838b586/src/async_impl/client.rs#L643 + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_native_certs(); + if !errors.is_empty() { + log::warn!("native root CA certificate loading errors: {errors:?}"); + } + root_cert_store.add_parsable_certificates(certs); + + // Build verifier using with_root_certificates behavior (WebPkiServerVerifier without CRLs). + // Both reqwest and tokio-tungstenite use this approach. + // https://github.com/seanmonstar/reqwest/blob/b126ca49da7897e5d676639cdbf67a0f6838b586/src/async_impl/client.rs#L749 + // https://github.com/snapview/tokio-tungstenite/blob/35d110c24c9d030d1608ec964d70c789dfb27452/src/tls.rs#L127 + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/client/builder.rs#L47 + // with_root_certificates creates a WebPkiServerVerifier without revocation checking: + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/webpki/server_verifier.rs#L177 + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/webpki/server_verifier.rs#L168 + // Since no CRL is provided (as is the case here), we must explicitly set allow_unknown_revocation_status() + // to match the behavior of with_root_certificates, which allows unknown revocation status by default. + // https://github.com/rustls/rustls/blob/1ee126adb3352a2dcd72420dcd6040351a6ddc1e/rustls/src/webpki/server_verifier.rs#L37 + // Note: build() only returns an error if the root certificate store is empty, which won't happen here. + let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(root_cert_store), + provider.clone(), + ) + .allow_unknown_revocation_status() + .build() + .map_err(|e| anyhow::anyhow!(e))?; + Ok(verifier) +} + +pub fn client_config(danger_accept_invalid_cert: bool) -> ResultType { + if danger_accept_invalid_cert { + client_config_danger() + } else { + client_config_safe() + } +} + +pub fn client_config_safe() -> ResultType { + // Use the default builder which uses the default protocol versions and crypto provider. + // The with_protocol_versions API has been removed in rustls master branch: + // https://github.com/rustls/rustls/pull/2599 + // This approach is consistent with tokio-tungstenite's usage: + // https://github.com/snapview/tokio-tungstenite/blob/35d110c24c9d030d1608ec964d70c789dfb27452/src/tls.rs#L126 + let config_builder = rustls::ClientConfig::builder(); + let provider = config_builder.crypto_provider().clone(); + let webpki_verifier = webpki_server_verifier(provider.clone())?; + #[cfg(any(target_os = "android", target_os = "ios"))] + { + match FallbackPlatformVerifier::with_platform_fallback(webpki_verifier.clone(), provider) { + Ok(fallback_verifier) => { + let config = config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(fallback_verifier)) + .with_no_client_auth(); + Ok(config) + } + Err(e) => { + log::error!( + "Failed to create fallback verifier: {:?}, use webpki verifier instead", + e + ); + let config = config_builder + .with_webpki_verifier(webpki_verifier) + .with_no_client_auth(); + Ok(config) + } + } + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let config = config_builder + .with_webpki_verifier(webpki_verifier) + .with_no_client_auth(); + Ok(config) + } +} + +pub fn client_config_danger() -> ResultType { + let config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + .with_no_client_auth(); + Ok(config) +} diff --git a/libs/hbb_common/src/webrtc.rs b/libs/hbb_common/src/webrtc.rs new file mode 100644 index 00000000000..8f3c410cc76 --- /dev/null +++ b/libs/hbb_common/src/webrtc.rs @@ -0,0 +1,770 @@ +use std::collections::HashMap; +use std::io::{Error, ErrorKind}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use webrtc::api::setting_engine::SettingEngine; +use webrtc::api::APIBuilder; +use webrtc::data_channel::RTCDataChannel; +use webrtc::ice::mdns::MulticastDnsMode; +use webrtc::ice_transport::ice_server::RTCIceServer; +use webrtc::peer_connection::configuration::RTCConfiguration; +use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; +use webrtc::peer_connection::policy::ice_transport_policy::RTCIceTransportPolicy; +use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +use webrtc::peer_connection::RTCPeerConnection; + +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use bytes::{Bytes, BytesMut}; +use tokio::sync::watch; +use tokio::sync::Mutex; +use tokio::time::timeout; +use url::Url; + +use crate::config; +use crate::protobuf::Message; +use crate::sodiumoxide::crypto::secretbox::Key; +use crate::ResultType; + +pub struct WebRTCStream { + pc: Arc, + stream: Arc>>, + state_notify: watch::Receiver, + send_timeout: u64, +} + +/// Standard maximum message size for WebRTC data channels (RFC 8831, 65535 bytes). +/// Most browsers, including Chromium, enforce this protocol limit. +const DATA_CHANNEL_BUFFER_SIZE: u16 = u16::MAX; + +// use 3 public STUN servers to find out the NAT type, 2 must be the same address but different ports +// https://stackoverflow.com/questions/72805316/determine-nat-mapping-behaviour-using-two-stun-servers +// luckily nextcloud supports two ports for STUN +// unluckily webrtc-rs does not use the same port to do the STUN request +static DEFAULT_ICE_SERVERS: [&str; 3] = [ + "stun:stun.cloudflare.com:3478", + "stun:stun.nextcloud.com:3478", + "stun:stun.nextcloud.com:443", +]; + +lazy_static::lazy_static! { + static ref SESSIONS: Arc::>> = Default::default(); +} + +impl Clone for WebRTCStream { + fn clone(&self) -> Self { + WebRTCStream { + pc: self.pc.clone(), + stream: self.stream.clone(), + state_notify: self.state_notify.clone(), + send_timeout: self.send_timeout, + } + } +} + +impl WebRTCStream { + #[inline] + fn get_remote_offer(endpoint: &str) -> ResultType { + // Ensure the endpoint starts with the "webrtc://" prefix + if !endpoint.starts_with("webrtc://") { + return Err( + Error::new(ErrorKind::InvalidInput, "Invalid WebRTC endpoint format").into(), + ); + } + + // Extract the Base64-encoded SDP part + let encoded_sdp = &endpoint["webrtc://".len()..]; + // Decode the Base64 string + let decoded_bytes = BASE64_STANDARD + .decode(encoded_sdp) + .map_err(|_| Error::new(ErrorKind::InvalidInput, "Failed to decode Base64 SDP"))?; + Ok(String::from_utf8(decoded_bytes).map_err(|_| { + Error::new( + ErrorKind::InvalidInput, + "Failed to convert decoded bytes to UTF-8", + ) + })?) + } + + #[inline] + fn sdp_to_endpoint(sdp: &str) -> String { + let encoded_sdp = BASE64_STANDARD.encode(sdp); + format!("webrtc://{}", encoded_sdp) + } + + #[inline] + fn get_key_for_sdp(sdp: &RTCSessionDescription) -> ResultType { + let binding = sdp.unmarshal()?; + let Some(fingerprint) = binding.attribute("fingerprint") else { + // find fingerprint attribute in media descriptions + for media in &binding.media_descriptions { + if media.media_name.media != "application" { + continue; + } + if let Some(fp) = media + .attributes + .iter() + .find(|x| x.key == "fingerprint") + .and_then(|x| x.value.clone()) + { + return Ok(fp); + } + } + return Err(anyhow::anyhow!("SDP fingerprint attribute not found")); + }; + Ok(fingerprint.to_string()) + } + + #[inline] + fn get_key_for_sdp_json(sdp_json: &str) -> ResultType { + if sdp_json.is_empty() { + return Ok("".to_string()); + } + let sdp = serde_json::from_str::(&sdp_json)?; + Self::get_key_for_sdp(&sdp) + } + + #[inline] + async fn get_key_for_peer(pc: &Arc, is_local: bool) -> ResultType { + let Some(desc) = (match is_local { + true => pc.local_description().await, + false => pc.remote_description().await, + }) else { + return Err(anyhow::anyhow!("PeerConnection description is not set")); + }; + Self::get_key_for_sdp(&desc) + } + + #[inline] + fn get_ice_server_from_url(url: &str) -> Option { + // standard url format with turn scheme: turn://user:pass@host:port + match Url::parse(url) { + Ok(u) => { + if u.scheme() == "turn" + || u.scheme() == "turns" + || u.scheme() == "stun" + || u.scheme() == "stuns" + { + Some(RTCIceServer { + urls: vec![format!( + "{}:{}:{}", + u.scheme(), + u.host_str().unwrap_or_default(), + u.port().unwrap_or(3478) + )], + username: u.username().to_string(), + credential: u.password().unwrap_or_default().to_string(), + ..Default::default() + }) + } else { + None + } + } + Err(_) => None, + } + } + + #[inline] + fn get_ice_servers() -> Vec { + let mut ice_servers = Vec::new(); + let cfg = config::Config::get_option(config::keys::OPTION_ICE_SERVERS); + + let mut has_stun = false; + + for url in cfg.split(',').map(str::trim) { + if let Some(ice_server) = Self::get_ice_server_from_url(url) { + // Detect STUN in user config + if ice_server + .urls + .iter() + .any(|u| u.starts_with("stun:") || u.starts_with("stuns:")) + { + has_stun = true; + } + + ice_servers.push(ice_server); + } + } + + // If there is no STUN (either TURN-only or empty config) → prepend defaults + if !has_stun { + ice_servers.insert( + 0, + RTCIceServer { + urls: DEFAULT_ICE_SERVERS.iter().map(|s| s.to_string()).collect(), + ..Default::default() + }, + ); + } + ice_servers + } + + pub async fn new( + remote_endpoint: &str, + force_relay: bool, + ms_timeout: u64, + ) -> ResultType { + log::debug!("New webrtc stream to endpoint: {}", remote_endpoint); + let remote_offer = if remote_endpoint.is_empty() { + "".into() + } else { + Self::get_remote_offer(remote_endpoint)? + }; + + let mut key = Self::get_key_for_sdp_json(&remote_offer)?; + let sessions_lock = SESSIONS.lock().await; + if let Some(cached_stream) = sessions_lock.get(&key) { + if !key.is_empty() { + log::debug!("Start webrtc with cached peer"); + return Ok(cached_stream.clone()); + } + } + drop(sessions_lock); + + let start_local_offer = remote_offer.is_empty(); + // Create a SettingEngine and enable Detach + let mut s = SettingEngine::default(); + s.detach_data_channels(); + s.set_ice_multicast_dns_mode(MulticastDnsMode::Disabled); + + // Create the API object + let api = APIBuilder::new().with_setting_engine(s).build(); + + // Prepare the configuration, get ICE servers from config + let config = RTCConfiguration { + ice_servers: Self::get_ice_servers(), + ice_transport_policy: if force_relay { + RTCIceTransportPolicy::Relay + } else { + RTCIceTransportPolicy::All + }, + ..Default::default() + }; + + let (notify_tx, notify_rx) = watch::channel(false); + // Create a new RTCPeerConnection + let pc = Arc::new(api.new_peer_connection(config).await?); + let bootstrap_dc = if start_local_offer { + let dc_open_notify = notify_tx.clone(); + // Create a data channel with label "bootstrap" + let dc = pc.create_data_channel("bootstrap", None).await?; + dc.on_open(Box::new(move || { + log::debug!("Local data channel bootstrap open."); + let _ = dc_open_notify.send(true); + Box::pin(async {}) + })); + dc + } else { + // Wait for the data channel to be created by the remote peer + // Here we create a dummy data channel to satisfy the type system + Arc::new(RTCDataChannel::default()) + }; + + let stream = Arc::new(Mutex::new(bootstrap_dc)); + if !start_local_offer { + // Register data channel creation handling + let dc_open_notify = notify_tx.clone(); + let stream_for_dc = stream.clone(); + pc.on_data_channel(Box::new(move |dc: Arc| { + let d_label = dc.label().to_owned(); + let dc_open_notify2 = dc_open_notify.clone(); + let stream_for_dc_clone = stream_for_dc.clone(); + log::debug!("Remote data channel {} ready", d_label); + Box::pin(async move { + let mut stream_lock = stream_for_dc_clone.lock().await; + *stream_lock = dc.clone(); + drop(stream_lock); + dc.on_open(Box::new(move || { + let _ = dc_open_notify2.send(true); + Box::pin(async {}) + })); + }) + })); + } + + // This will notify you when the peer has connected/disconnected + let stream_for_close = stream.clone(); + let pc_for_close = pc.clone(); + pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { + let stream_for_close2 = stream_for_close.clone(); + let on_connection_notify = notify_tx.clone(); + let pc_for_close2 = pc_for_close.clone(); + Box::pin(async move { + log::debug!("WebRTC session peer connection state: {}", s); + match s { + RTCPeerConnectionState::Disconnected + | RTCPeerConnectionState::Failed + | RTCPeerConnectionState::Closed => { + let _ = on_connection_notify.send(true); + log::debug!("WebRTC session closing due to disconnected"); + let _ = stream_for_close2.lock().await.close().await; + log::debug!("WebRTC session stream closed"); + + let mut sessions_lock = SESSIONS.lock().await; + match Self::get_key_for_peer(&pc_for_close2, start_local_offer).await { + Ok(k) => { + sessions_lock.remove(&k); + log::debug!("WebRTC session removed key: {}", k); + } + Err(e) => { + log::error!( + "Failed to extract key for peer during session cleanup: {:?}", + e + ); + // Fallback: try to remove any session associated with this peer connection + let keys_to_remove: Vec = sessions_lock + .iter() + .filter_map(|(key, session)| { + if Arc::ptr_eq(&session.pc, &pc_for_close2) { + Some(key.clone()) + } else { + None + } + }) + .collect(); + for k in keys_to_remove { + sessions_lock.remove(&k); + log::debug!("WebRTC session removed by fallback key: {}", k); + } + } + } + } + _ => {} + } + }) + })); + + // process offer/answer + if start_local_offer { + let sdp = pc.create_offer(None).await?; + let mut gather_complete = pc.gathering_complete_promise().await; + pc.set_local_description(sdp.clone()).await?; + let _ = gather_complete.recv().await; + + log::debug!("local offer:\n{}", sdp.sdp); + // get local sdp key + key = Self::get_key_for_sdp(&sdp)?; + log::debug!("Start webrtc with local key: {}", key); + } else { + let sdp = serde_json::from_str::(&remote_offer)?; + pc.set_remote_description(sdp.clone()).await?; + let answer = pc.create_answer(None).await?; + let mut gather_complete = pc.gathering_complete_promise().await; + pc.set_local_description(answer).await?; + let _ = gather_complete.recv().await; + + log::debug!("remote offer:\n{}", sdp.sdp); + // get remote sdp key + key = Self::get_key_for_sdp(&sdp)?; + log::debug!("Start webrtc with remote key: {}", key); + } + + let mut final_lock = SESSIONS.lock().await; + if let Some(session) = final_lock.get(&key) { + pc.close().await.ok(); + return Ok(session.clone()); + } + + let webrtc_stream = Self { + pc, + stream, + state_notify: notify_rx, + send_timeout: ms_timeout, + }; + final_lock.insert(key, webrtc_stream.clone()); + Ok(webrtc_stream) + } + + #[inline] + pub async fn get_local_endpoint(&self) -> ResultType { + if let Some(local_desc) = self.pc.local_description().await { + let sdp = serde_json::to_string(&local_desc)?; + let endpoint = Self::sdp_to_endpoint(&sdp); + Ok(endpoint) + } else { + Err(anyhow::anyhow!("Local desc is not set")) + } + } + + #[inline] + pub async fn set_remote_endpoint(&self, endpoint: &str) -> ResultType<()> { + let offer = Self::get_remote_offer(endpoint)?; + log::debug!("WebRTC set remote sdp: {}", offer); + let sdp = serde_json::from_str::(&offer)?; + self.pc.set_remote_description(sdp).await?; + Ok(()) + } + + #[inline] + pub fn set_raw(&mut self) { + // not-supported + } + + #[inline] + pub fn local_addr(&self) -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) + } + + #[inline] + pub fn set_send_timeout(&mut self, ms: u64) { + self.send_timeout = ms; + } + + #[inline] + pub fn set_key(&mut self, _key: Key) { + // not-supported + // WebRTC uses built-in DTLS encryption for secure communication. + // DTLS handles key exchange and encryption automatically, so explicit key management is not required. + } + + #[inline] + pub fn is_secured(&self) -> bool { + true + } + + #[inline] + pub async fn send(&mut self, msg: &impl Message) -> ResultType<()> { + self.send_raw(msg.write_to_bytes()?).await + } + + #[inline] + pub async fn send_raw(&mut self, msg: Vec) -> ResultType<()> { + self.send_bytes(Bytes::from(msg)).await + } + + #[inline] + async fn wait_for_connect_result(&mut self) { + if *self.state_notify.borrow() { + return; + } + let _ = self.state_notify.changed().await; + } + + pub async fn send_bytes(&mut self, bytes: Bytes) -> ResultType<()> { + if self.send_timeout > 0 { + match timeout( + Duration::from_millis(self.send_timeout), + self.wait_for_connect_result(), + ) + .await + { + Ok(_) => {} + Err(_) => { + self.pc.close().await.ok(); + return Err(Error::new( + ErrorKind::TimedOut, + "WebRTC send wait for connect timeout", + ) + .into()); + } + } + } else { + self.wait_for_connect_result().await; + } + let stream = self.stream.lock().await.clone(); + stream.send(&bytes).await?; + Ok(()) + } + + #[inline] + pub async fn next(&mut self) -> Option> { + self.wait_for_connect_result().await; + let stream = self.stream.lock().await.clone(); + + // TODO reuse buffer? + let mut buffer = BytesMut::zeroed(DATA_CHANNEL_BUFFER_SIZE as usize); + let dc = stream.detach().await.ok()?; + let n = match dc.read(&mut buffer).await { + Ok(n) => n, + Err(err) => { + self.pc.close().await.ok(); + return Some(Err(Error::new( + ErrorKind::Other, + format!("data channel read error: {}", err), + ))); + } + }; + if n == 0 { + self.pc.close().await.ok(); + return Some(Err(Error::new( + ErrorKind::Other, + "data channel read exited with 0 bytes", + ))); + } + buffer.truncate(n); + Some(Ok(buffer)) + } + + #[inline] + pub async fn next_timeout(&mut self, ms: u64) -> Option> { + match timeout(Duration::from_millis(ms), self.next()).await { + Ok(res) => res, + Err(_) => None, + } + } +} + +pub fn is_webrtc_endpoint(endpoint: &str) -> bool { + // use sdp base64 json string as endpoint, or prefix webrtc: + endpoint.starts_with("webrtc://") +} + +#[cfg(test)] +mod tests { + use crate::config; + use crate::webrtc::WebRTCStream; + use crate::webrtc::DEFAULT_ICE_SERVERS; + use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; + + #[test] + fn test_webrtc_ice_url() { + assert_eq!( + WebRTCStream::get_ice_server_from_url("turn://example.com:3478") + .unwrap_or_default() + .urls[0], + "turn:example.com:3478" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("turn://example.com") + .unwrap_or_default() + .urls[0], + "turn:example.com:3478" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("turn://123@example.com") + .unwrap_or_default() + .username, + "123" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("turn://123@example.com") + .unwrap_or_default() + .credential, + "" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("turn://123:321@example.com") + .unwrap_or_default() + .credential, + "321" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("stun://example.com:3478") + .unwrap_or_default() + .urls[0], + "stun:example.com:3478" + ); + + assert_eq!( + WebRTCStream::get_ice_server_from_url("http://123:123@example.com:3478"), + None + ); + + config::Config::set_option("ice-servers".to_string(), "".to_string()); + assert_eq!( + WebRTCStream::get_ice_servers()[0].urls[0], + DEFAULT_ICE_SERVERS[0].to_string() + ); + + config::Config::set_option( + "ice-servers".to_string(), + ",stun://example.com,turn://example.com,sdf".to_string(), + ); + assert_eq!( + WebRTCStream::get_ice_servers()[0].urls[0], + "stun:example.com:3478" + ); + assert_eq!( + WebRTCStream::get_ice_servers()[1].urls[0], + "turn:example.com:3478" + ); + assert_eq!(WebRTCStream::get_ice_servers().len(), 2); + config::Config::set_option( + "ice-servers".to_string(), + "".to_string(), + ); + } + + #[test] + fn test_webrtc_session_key() { + let mut sdp_str = "".to_owned(); + assert_eq!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer(sdp_str).unwrap_or_default() + ) + .unwrap_or_default(), + "" + ); + + sdp_str = "\ +v=0 +o=- 7400546379179479477 208696200 IN IP4 0.0.0.0 +s=- +t=0 0 +a=fingerprint:sha-256 97:52:D6:1F:1E:87:6C:DA:B8:21:95:64:A5:85:89:FA:02:71:C7:4D:B3:FD:25:92:40:FB:6B:65:24:3C:79:88 +a=group:BUNDLE 0 +a=extmap-allow-mixed +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=setup:actpass +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=ice-ufrag:RMWjjpXfpXbDPdMz +a=ice-pwd:BtIqlWHfwhsJdFiBROeLuEbNmYfHxRfT".to_owned(); + assert_eq!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer(sdp_str).unwrap_or_default() + ).unwrap_or_default(), + "sha-256 97:52:D6:1F:1E:87:6C:DA:B8:21:95:64:A5:85:89:FA:02:71:C7:4D:B3:FD:25:92:40:FB:6B:65:24:3C:79:88" + ); + + sdp_str = "\ +v=0 +o=- 7400546379179479477 208696200 IN IP4 0.0.0.0 +s=- +t=0 0 +a=group:BUNDLE 0 +a=extmap-allow-mixed +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=fingerprint:sha-256 97:52:D6:1F:1E:87:6C:DA:B8:21:95:64:A5:85:89:FA:02:71:C7:4D:B3:FD:25:92:40:FB:6B:65:24:3C:79:88 +a=setup:actpass +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=ice-ufrag:RMWjjpXfpXbDPdMz +a=ice-pwd:BtIqlWHfwhsJdFiBROeLuEbNmYfHxRfT".to_owned(); + assert_eq!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer(sdp_str).unwrap_or_default() + ).unwrap_or_default(), + "sha-256 97:52:D6:1F:1E:87:6C:DA:B8:21:95:64:A5:85:89:FA:02:71:C7:4D:B3:FD:25:92:40:FB:6B:65:24:3C:79:88" + ); + + sdp_str = "\ +v=0 +o=- 7400546379179479477 208696200 IN IP4 0.0.0.0 +s=- +t=0 0 +a=group:BUNDLE 0 +a=extmap-allow-mixed +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=setup:actpass +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=ice-ufrag:RMWjjpXfpXbDPdMz +a=ice-pwd:BtIqlWHfwhsJdFiBROeLuEbNmYfHxRfT" + .to_owned(); + assert!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer(sdp_str).unwrap_or_default() + ) + .is_err(), + "can not find fingerprint attribute" + ); + + sdp_str = "\ +v=0 +o=- 7400546379179479477 208696200 IN IP4 0.0.0.0 +s=- +t=0 0 +a=group:BUNDLE 0 +a=extmap-allow-mixed +m=audio 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=fingerprint:sha-256 97:52:D6:1F:1E:87:6C:DA:B8:21:95:64:A5:85:89:FA:02:71:C7:4D:B3:FD:25:92:40:FB:6B:65:24:3C:79:88 +a=setup:actpass +a=mid:0 +a=sendrecv +a=sctp-port:5000 +a=ice-ufrag:RMWjjpXfpXbDPdMz +a=ice-pwd:BtIqlWHfwhsJdFiBROeLuEbNmYfHxRfT".to_owned(); + assert!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer(sdp_str).unwrap_or_default() + ) + .is_err(), + "can not find datachannel fingerprint attribute" + ); + + assert!( + WebRTCStream::get_key_for_sdp( + &RTCSessionDescription::offer("".to_owned()).unwrap_or_default() + ) + .is_err(), + "invalid sdp should error" + ); + + assert!( + WebRTCStream::get_key_for_sdp_json("{}").is_err(), + "empty sdp json should error" + ); + + assert!( + WebRTCStream::get_key_for_sdp_json("{ss}").is_err(), + "invalid sdp json should error" + ); + + let endpoint = "webrtc://eyJ0eXBlIjoiYW5zd2VyIiwic2RwIjoidj0wXHJcbm89LSA0MTA1NDk3NTY2NDgyMTQzODEwIDYwMzk1NzQw\ +MCBJTiBJUDQgMC4wLjAuMFxyXG5zPS1cclxudD0wIDBcclxuYT1maW5nZXJwcmludDpzaGEtMjU2IDYxOjYwOjc0OjQwOjI4OkNFOjBCOjBDOjc1OjRCOj\ +EwOjlBOkVFOjc3OkY1OjQ0OjU3Ojg0OjUxOkRCOjA0OjkyOjRBOjEwOjFDOjRFOjVGOjdFOkYxOkIzOjcxOjIyXHJcbmE9Z3JvdXA6QlVORExFIDBcclxu\ +YT1leHRtYXAtYWxsb3ctbWl4ZWRcclxubT1hcHBsaWNhdGlvbiA5IFVEUC9EVExTL1NDVFAgd2VicnRjLWRhdGFjaGFubmVsXHJcbmM9SU4gSVA0IDAuMC\ +4wLjBcclxuYT1zZXR1cDphY3RpdmVcclxuYT1taWQ6MFxyXG5hPXNlbmRyZWN2XHJcbmE9c2N0cC1wb3J0OjUwMDBcclxuYT1pY2UtdWZyYWc6SHlnU1Rr\ +V2RsRlpHRG1XWlxyXG5hPWljZS1wd2Q6SkJneFZWaGZveVhHdHZha1VWcnBQeHVOSVpMU3llS1pcclxuYT1jYW5kaWRhdGU6OTYzOTg4MzQ4IDEgdWRwID\ +IxMzA3MDY0MzEgMTkyLjE2OC4xLjIgNjQwMDcgdHlwIGhvc3RcclxuYT1jYW5kaWRhdGU6OTYzOTg4MzQ4IDIgdWRwIDIxMzA3MDY0MzEgMTkyLjE2OC4x\ +LjIgNjQwMDcgdHlwIGhvc3RcclxuYT1jYW5kaWRhdGU6MTg2MTA0NTE5MCAxIHVkcCAxNjk0NDk4ODE1IDE0LjIxMi42OC4xMiAyNzAwNCB0eXAgc3JmbH\ +ggcmFkZHIgMC4wLjAuMCBycG9ydCA2NDAwOFxyXG5hPWNhbmRpZGF0ZToxODYxMDQ1MTkwIDIgdWRwIDE2OTQ0OTg4MTUgMTQuMjEyLjY4LjEyIDI3MDA0\ +IHR5cCBzcmZseCByYWRkciAwLjAuMC4wIHJwb3J0IDY0MDA4XHJcbmE9ZW5kLW9mLWNhbmRpZGF0ZXNcclxuIn0=".to_owned(); + assert_eq!( + WebRTCStream::get_key_for_sdp_json( + &WebRTCStream::get_remote_offer(&endpoint).unwrap_or_default() + ).unwrap_or_default(), + "sha-256 61:60:74:40:28:CE:0B:0C:75:4B:10:9A:EE:77:F5:44:57:84:51:DB:04:92:4A:10:1C:4E:5F:7E:F1:B3:71:22" + ); + } + + #[tokio::test] + async fn test_webrtc_new_stream() { + let mut endpoint = "webrtc://sdfsdf".to_owned(); + assert!( + WebRTCStream::new(&endpoint, false, 10000).await.is_err(), + "invalid webrtc endpoint should error" + ); + + endpoint = "wss://sdfsdf".to_owned(); + assert!( + WebRTCStream::new(&endpoint, false, 10000).await.is_err(), + "invalid webrtc endpoint should error" + ); + + assert!( + WebRTCStream::new("", false, 10000).await.is_ok(), + "local webrtc endpoint should ok" + ); + + endpoint = "webrtc://eyJ0eXBlIjoiYW5zd2VyIiwic2RwIjoidj0wXHJcbm89LSA0MTA1NDk3NTY2NDgyMTQzODEwIDYwMzk1NzQw\ +MCBJTiBJUDQgMC4wLjAuMFxyXG5zPS1cclxudD0wIDBcclxuYT1maW5nZXJwcmludDpzaGEtMjU2IDYxOjYwOjc0OjQwOjI4OkNFOjBCOjBDOjc1OjRCOj\ +EwOjlBOkVFOjc3OkY1OjQ0OjU3Ojg0OjUxOkRCOjA0OjkyOjRBOjEwOjFDOjRFOjVGOjdFOkYxOkIzOjcxOjIyXHJcbmE9Z3JvdXA6QlVORExFIDBcclxu\ +YT1leHRtYXAtYWxsb3ctbWl4ZWRcclxubT1hcHBsaWNhdGlvbiA5IFVEUC9EVExTL1NDVFAgd2VicnRjLWRhdGFjaGFubmVsXHJcbmM9SU4gSVA0IDAuMC\ +4wLjBcclxuYT1zZXR1cDphY3RpdmVcclxuYT1taWQ6MFxyXG5hPXNlbmRyZWN2XHJcbmE9c2N0cC1wb3J0OjUwMDBcclxuYT1pY2UtdWZyYWc6SHlnU1Rr\ +V2RsRlpHRG1XWlxyXG5hPWljZS1wd2Q6SkJneFZWaGZveVhHdHZha1VWcnBQeHVOSVpMU3llS1pcclxuYT1jYW5kaWRhdGU6OTYzOTg4MzQ4IDEgdWRwID\ +IxMzA3MDY0MzEgMTkyLjE2OC4xLjIgNjQwMDcgdHlwIGhvc3RcclxuYT1jYW5kaWRhdGU6OTYzOTg4MzQ4IDIgdWRwIDIxMzA3MDY0MzEgMTkyLjE2OC4x\ +LjIgNjQwMDcgdHlwIGhvc3RcclxuYT1jYW5kaWRhdGU6MTg2MTA0NTE5MCAxIHVkcCAxNjk0NDk4ODE1IDE0LjIxMi42OC4xMiAyNzAwNCB0eXAgc3JmbH\ +ggcmFkZHIgMC4wLjAuMCBycG9ydCA2NDAwOFxyXG5hPWNhbmRpZGF0ZToxODYxMDQ1MTkwIDIgdWRwIDE2OTQ0OTg4MTUgMTQuMjEyLjY4LjEyIDI3MDA0\ +IHR5cCBzcmZseCByYWRkciAwLjAuMC4wIHJwb3J0IDY0MDA4XHJcbmE9ZW5kLW9mLWNhbmRpZGF0ZXNcclxuIn0=".to_owned(); + assert!( + WebRTCStream::new(&endpoint, false, 10000).await.is_err(), + "connect to an 'answer' webrtc endpoint should error" + ); + } +} diff --git a/libs/hbb_common/src/websocket.rs b/libs/hbb_common/src/websocket.rs new file mode 100644 index 00000000000..7bf21084090 --- /dev/null +++ b/libs/hbb_common/src/websocket.rs @@ -0,0 +1,539 @@ +use crate::{ + config::{ + keys::OPTION_RELAY_SERVER, use_ws, Config, Socks5Server, RELAY_PORT, RENDEZVOUS_PORT, + }, + protobuf::Message, + socket_client::split_host_port, + sodiumoxide::crypto::secretbox::Key, + tcp::Encrypt, + tls::{get_cached_tls_accept_invalid_cert, get_cached_tls_type, upsert_tls_cache, TlsType}, + ResultType, +}; +use anyhow::bail; +use async_recursion::async_recursion; +use bytes::{Bytes, BytesMut}; +use futures::{SinkExt, StreamExt}; +use std::{ + io::{Error, ErrorKind}, + net::SocketAddr, + sync::Arc, + time::Duration, +}; +use tokio::{net::TcpStream, time::timeout}; +use tokio_native_tls::native_tls::TlsConnector; +use tokio_tungstenite::{ + connect_async_tls_with_config, tungstenite::protocol::Message as WsMessage, Connector, + MaybeTlsStream, WebSocketStream, +}; +use tungstenite::client::IntoClientRequest; +use tungstenite::protocol::Role; + +pub struct WsFramedStream { + stream: WebSocketStream>, + addr: SocketAddr, + encrypt: Option, + send_timeout: u64, +} + +impl WsFramedStream { + #[inline] + fn get_connector( + tls_type: &TlsType, + danger_accept_invalid_certs: bool, + ) -> ResultType> { + match tls_type { + TlsType::Plain => Ok(Some(Connector::Plain)), + TlsType::NativeTls => { + let connector = TlsConnector::builder() + .danger_accept_invalid_certs(danger_accept_invalid_certs) + .build()?; + Ok(Some(Connector::NativeTls(connector))) + } + TlsType::Rustls => { + let connector = match crate::verifier::client_config(danger_accept_invalid_certs) { + Ok(client_config) => Some(Connector::Rustls(Arc::new(client_config))), + Err(e) => { + log::warn!( + "Failed to get client config: {:?}, fallback to default connector", + e + ); + None + } + }; + Ok(connector) + } + } + } + + async fn connect( + url: &str, + ms_timeout: u64, + ) -> ResultType>> { + // to-do: websocket proxy. + + let tls_type = get_cached_tls_type(url); + let is_tls_type_cached = tls_type.is_some(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(&url); + Self::try_connect( + url, + ms_timeout, + tls_type, + is_tls_type_cached, + danger_accept_invalid_cert, + danger_accept_invalid_cert, + ) + .await + } + + #[async_recursion] + async fn try_connect( + url: &str, + ms_timeout: u64, + tls_type: TlsType, + is_tls_type_cached: bool, + danger_accept_invalid_cert: Option, + original_danger_accept_invalid_certs: Option, + ) -> ResultType>> { + let ws_config = None; + let disable_nagle = false; + let request = url + .into_client_request() + .map_err(|e| Error::new(ErrorKind::Other, e))?; + let connector = + Self::get_connector(&tls_type, danger_accept_invalid_cert.unwrap_or(false))?; + match timeout( + Duration::from_millis(ms_timeout), + connect_async_tls_with_config(request, ws_config, disable_nagle, connector), + ) + .await? + { + Ok((ws_stream, _)) => { + upsert_tls_cache(url, tls_type, danger_accept_invalid_cert.unwrap_or(false)); + Ok(ws_stream) + } + Err(e) => match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { + (TlsType::Rustls, _, None) => { + log::warn!( + "WebSocket connection with rustls-tls failed, try accept invalid certs: {}, {:?}", + url, + e + ); + Self::try_connect( + url, + ms_timeout, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_certs, + ) + .await + } + (TlsType::Rustls, false, Some(_)) => { + log::warn!( + "WebSocket connection with rustls-tls failed, try native-tls: {}, {:?}", + url, + e + ); + Self::try_connect( + url, + ms_timeout, + TlsType::NativeTls, + is_tls_type_cached, + original_danger_accept_invalid_certs, + original_danger_accept_invalid_certs, + ) + .await + } + (TlsType::NativeTls, _, None) => { + log::warn!( + "WebSocket connection with native-tls failed, try accept invalid certs: {}, {:?}", + url, + e + ); + Self::try_connect( + url, + ms_timeout, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_certs, + ) + .await + } + _ => { + log::error!( + "WebSocket connection failed with tls_type {:?}: {}, {:?}", + tls_type, + url, + e + ); + if let tungstenite::Error::Http(response) = &e { + if response.status().is_redirection() { + bail!( + "WebSocket connection failed ({}). The server may not support WebSocket.", + e + ) + } + } + bail!("WebSocket error: {}", e) + } + }, + } + } + + pub async fn new>( + url: T, + _local_addr: Option, + _proxy_conf: Option<&Socks5Server>, + ms_timeout: u64, + ) -> ResultType { + let stream = Self::connect(url.as_ref(), ms_timeout).await?; + let addr = match stream.get_ref() { + MaybeTlsStream::Plain(tcp) => tcp.peer_addr()?, + MaybeTlsStream::NativeTls(tls) => tls.get_ref().get_ref().get_ref().peer_addr()?, + MaybeTlsStream::Rustls(tls) => tls.get_ref().0.peer_addr()?, + _ => return Err(Error::new(ErrorKind::Other, "Unsupported stream type").into()), + }; + + let ws = Self { + stream, + addr, + encrypt: None, + send_timeout: ms_timeout, + }; + + Ok(ws) + } + + #[inline] + pub fn set_raw(&mut self) { + self.encrypt = None; + } + + #[inline] + pub async fn from_tcp_stream(stream: TcpStream, addr: SocketAddr) -> ResultType { + let ws_stream = + WebSocketStream::from_raw_socket(MaybeTlsStream::Plain(stream), Role::Client, None) + .await; + + Ok(Self { + stream: ws_stream, + addr, + encrypt: None, + send_timeout: 0, + }) + } + + #[inline] + pub fn local_addr(&self) -> SocketAddr { + self.addr + } + + #[inline] + pub fn set_send_timeout(&mut self, ms: u64) { + self.send_timeout = ms; + } + + #[inline] + pub fn set_key(&mut self, key: Key) { + self.encrypt = Some(Encrypt::new(key)); + } + + #[inline] + pub fn is_secured(&self) -> bool { + self.encrypt.is_some() + } + + #[inline] + pub async fn send(&mut self, msg: &impl Message) -> ResultType<()> { + self.send_raw(msg.write_to_bytes()?).await + } + + #[inline] + pub async fn send_raw(&mut self, msg: Vec) -> ResultType<()> { + let mut msg = msg; + if let Some(key) = self.encrypt.as_mut() { + msg = key.enc(&msg); + } + self.send_bytes(Bytes::from(msg)).await + } + + pub async fn send_bytes(&mut self, bytes: Bytes) -> ResultType<()> { + let msg = WsMessage::Binary(bytes); + if self.send_timeout > 0 { + timeout( + Duration::from_millis(self.send_timeout), + self.stream.send(msg), + ) + .await?? + } else { + self.stream.send(msg).await? + }; + Ok(()) + } + + #[inline] + pub async fn next(&mut self) -> Option> { + while let Some(msg) = self.stream.next().await { + let msg = match msg { + Ok(msg) => msg, + Err(e) => { + log::error!("{}", e); + return Some(Err(Error::new( + ErrorKind::Other, + format!("WebSocket protocol error: {}", e), + ))); + } + }; + + match msg { + WsMessage::Binary(data) => { + let mut bytes = BytesMut::from(&data[..]); + if let Some(key) = self.encrypt.as_mut() { + if let Err(err) = key.dec(&mut bytes) { + return Some(Err(err)); + } + } + return Some(Ok(bytes)); + } + WsMessage::Text(text) => { + let bytes = BytesMut::from(text.as_bytes()); + return Some(Ok(bytes)); + } + WsMessage::Close(_) => { + return None; + } + _ => { + continue; + } + } + } + + None + } + + #[inline] + pub async fn next_timeout(&mut self, ms: u64) -> Option> { + match timeout(Duration::from_millis(ms), self.next()).await { + Ok(res) => res, + Err(_) => None, + } + } +} + +pub fn is_ws_endpoint(endpoint: &str) -> bool { + endpoint.starts_with("ws://") || endpoint.starts_with("wss://") +} + +/** + * Core function to convert an endpoint to WebSocket format + * + * Converts between different address formats: + * 1. IPv4 address with/without port -> ws://ipv4:port + * 2. IPv6 address with/without port -> ws://[ipv6]:port + * 3. Domain with/without port -> ws(s)://domain/ws/path + * + * @param endpoint The endpoint to convert + * @return The converted WebSocket endpoint + */ +pub fn check_ws(endpoint: &str) -> String { + if !use_ws() { + return endpoint.to_string(); + } + + if endpoint.is_empty() { + return endpoint.to_string(); + } + + if is_ws_endpoint(endpoint) { + return endpoint.to_string(); + } + + let Some((endpoint_host, endpoint_port)) = split_host_port(endpoint) else { + debug_assert!(false, "endpoint doesn't have port"); + return endpoint.to_string(); + }; + + let custom_rendezvous_server = Config::get_rendezvous_server(); + let relay_server = Config::get_option(OPTION_RELAY_SERVER); + let rendezvous_port = split_host_port(&custom_rendezvous_server) + .map(|(_, p)| p) + .unwrap_or(RENDEZVOUS_PORT); + let relay_port = split_host_port(&relay_server) + .map(|(_, p)| p) + .unwrap_or(RELAY_PORT); + + let (relay, dst_port) = if endpoint_port == rendezvous_port { + // rendezvous + (false, endpoint_port + 2) + } else if endpoint_port == rendezvous_port - 1 { + // online + (false, endpoint_port + 3) + } else if endpoint_port == relay_port || endpoint_port == rendezvous_port + 1 { + // relay + // https://github.com/rustdesk/rustdesk/blob/6ffbcd1375771f2482ec4810680623a269be70f1/src/rendezvous_mediator.rs#L615 + // https://github.com/rustdesk/rustdesk-server/blob/235a3c326ceb665e941edb50ab79faa1208f7507/src/relay_server.rs#L83, based on relay port. + (true, endpoint_port + 2) + } else { + // fallback relay + // for controlling side, relay server is passed from the controlled side, not related to local config. + (true, endpoint_port + 2) + }; + + let (address, is_domain) = if crate::is_ip_str(endpoint) { + (format!("{}:{}", endpoint_host, dst_port), false) + } else { + let domain_path = if relay { "/ws/relay" } else { "/ws/id" }; + (format!("{}{}", endpoint_host, domain_path), true) + }; + let protocol = if is_domain { + let api_server = Config::get_option("api-server"); + if api_server.starts_with("https") { + "wss" + } else { + "ws" + } + } else { + "ws" + }; + format!("{}://{}", protocol, address) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{keys, Config}; + + #[test] + fn test_check_ws() { + // enable websocket + Config::set_option(keys::OPTION_ALLOW_WEBSOCKET.to_string(), "Y".to_string()); + + // not set custom-rendezvous-server + Config::set_option("custom-rendezvous-server".to_string(), "".to_string()); + Config::set_option("relay-server".to_string(), "".to_string()); + Config::set_option("api-server".to_string(), "".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + assert_eq!(check_ws("rustdesk.com:21115"), "ws://rustdesk.com/ws/id"); + assert_eq!(check_ws("rustdesk.com:21116"), "ws://rustdesk.com/ws/id"); + assert_eq!(check_ws("rustdesk.com:21117"), "ws://rustdesk.com/ws/relay"); + // set relay-server without port + Config::set_option("relay-server".to_string(), "127.0.0.1".to_string()); + Config::set_option( + "api-server".to_string(), + "https://api.rustdesk.com".to_string(), + ); + assert_eq!( + check_ws("[0:0:0:0:0:0:0:1]:21115"), + "ws://[0:0:0:0:0:0:0:1]:21118" + ); + assert_eq!( + check_ws("[0:0:0:0:0:0:0:1]:21116"), + "ws://[0:0:0:0:0:0:0:1]:21118" + ); + assert_eq!( + check_ws("[0:0:0:0:0:0:0:1]:21117"), + "ws://[0:0:0:0:0:0:0:1]:21119" + ); + assert_eq!(check_ws("rustdesk.com:21115"), "wss://rustdesk.com/ws/id"); + assert_eq!(check_ws("rustdesk.com:21116"), "wss://rustdesk.com/ws/id"); + assert_eq!( + check_ws("rustdesk.com:21117"), + "wss://rustdesk.com/ws/relay" + ); + // set relay-server with default port + Config::set_option("relay-server".to_string(), "127.0.0.1:21117".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with custom port + Config::set_option("relay-server".to_string(), "127.0.0.1:34567".to_string()); + assert_eq!(check_ws("rustdesk.com:21115"), "wss://rustdesk.com/ws/id"); + assert_eq!(check_ws("rustdesk.com:21116"), "wss://rustdesk.com/ws/id"); + assert_eq!( + check_ws("rustdesk.com:34567"), + "wss://rustdesk.com/ws/relay" + ); + + // set custom-rendezvous-server without port + Config::set_option( + "custom-rendezvous-server".to_string(), + "127.0.0.1".to_string(), + ); + Config::set_option("relay-server".to_string(), "".to_string()); + Config::set_option("api-server".to_string(), "".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server without port + Config::set_option("relay-server".to_string(), "127.0.0.1".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with default port + Config::set_option("relay-server".to_string(), "127.0.0.1:21117".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with custom port + Config::set_option("relay-server".to_string(), "127.0.0.1:34567".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:34567"), "ws://127.0.0.1:34569"); + + // set custom-rendezvous-server without default port + Config::set_option( + "custom-rendezvous-server".to_string(), + "127.0.0.1".to_string(), + ); + Config::set_option("relay-server".to_string(), "".to_string()); + Config::set_option("api-server".to_string(), "".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server without port + Config::set_option("relay-server".to_string(), "127.0.0.1".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with default port + Config::set_option("relay-server".to_string(), "127.0.0.1:21117".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with custom port + Config::set_option("relay-server".to_string(), "127.0.0.1:34567".to_string()); + assert_eq!(check_ws("127.0.0.1:21115"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:21116"), "ws://127.0.0.1:21118"); + assert_eq!(check_ws("127.0.0.1:34567"), "ws://127.0.0.1:34569"); + + // set custom-rendezvous-server with custom port + Config::set_option( + "custom-rendezvous-server".to_string(), + "127.0.0.1:23456".to_string(), + ); + Config::set_option("relay-server".to_string(), "".to_string()); + Config::set_option("api-server".to_string(), "".to_string()); + assert_eq!(check_ws("127.0.0.1:23455"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:23456"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:23457"), "ws://127.0.0.1:23459"); + // set relay-server without port + Config::set_option("relay-server".to_string(), "127.0.0.1".to_string()); + assert_eq!(check_ws("127.0.0.1:23455"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:23456"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with default port + Config::set_option("relay-server".to_string(), "127.0.0.1:21117".to_string()); + assert_eq!(check_ws("127.0.0.1:23455"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:23456"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:21117"), "ws://127.0.0.1:21119"); + // set relay-server with custom port + Config::set_option("relay-server".to_string(), "127.0.0.1:34567".to_string()); + assert_eq!(check_ws("127.0.0.1:23455"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:23456"), "ws://127.0.0.1:23458"); + assert_eq!(check_ws("127.0.0.1:34567"), "ws://127.0.0.1:34569"); + } +} diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2def8..609f3fc43d9 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -21,7 +21,7 @@ cfg-if = "1.0" num_cpus = "1.15" lazy_static = "1.4" hbb_common = { path = "../hbb_common" } -webm = { git = "https://github.com/rustdesk-org/rust-webm" } +webm = { path = "../../third_party/webm" } serde = {version="1.0", features=["derive"]} [dependencies.winapi] @@ -60,9 +60,8 @@ gstreamer-video = { version = "0.16", optional = true } zbus = { version = "3.15", optional = true } [dependencies.hwcodec] -git = "https://github.com/rustdesk-org/hwcodec" +path = "../../third_party/hwcodec" optional = true [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] -nokhwa = { git = "https://github.com/rustdesk-org/nokhwa.git", branch = "fix_from_raw_parts", features = ["input-native"] } - +nokhwa = { path = "../../third_party/nokhwa", features = ["input-native"] } diff --git a/libs/virtual_display/Cargo.lock b/libs/virtual_display/Cargo.lock index 22fa681b23a..1c509cc109d 100644 --- a/libs/virtual_display/Cargo.lock +++ b/libs/virtual_display/Cargo.lock @@ -118,7 +118,6 @@ dependencies = [ [[package]] name = "confy" version = "0.4.0" -source = "git+https://github.com/open-trade/confy#630cc28a396cb7d01eefdd9f3824486fe4d8554b" dependencies = [ "directories-next", "serde", @@ -1055,7 +1054,6 @@ dependencies = [ [[package]] name = "tokio-socks" version = "0.5.1-1" -source = "git+https://github.com/open-trade/tokio-socks#7034e79263ce25c348be072808d7601d82cd892d" dependencies = [ "bytes", "either", diff --git a/plans/upstream-independent-build/plan.md b/plans/upstream-independent-build/plan.md new file mode 100644 index 00000000000..25991f97251 --- /dev/null +++ b/plans/upstream-independent-build/plan.md @@ -0,0 +1,375 @@ +# Upstream-Independent Build Migration + +## Goal + +Make RustDesk build from owned, tracked source/vendor inputs without the +RustDesk upstream submodule or a `rustdesk-org` network dependency. This plan +covers the local source/vendor migration only. External fork publication, +remote changes, commits, pushes, and release/publication work are separate +gated activities and are not part of this execution. + +## Baseline (Phase 0) + +- Worktree: `/home/bash/projects/rustdesk-independent` +- Base: `origin/rustqs/min-test` +- Baseline HEAD: `3fc94150f05eb41ea7b40ec0c8e69dc798795223` +- Worktree was clean before this migration. +- `libs/hbb_common` was a gitlink at `a920d00945e1d2441b3f77b2677054cb8c3d9dd2`. +- `.gitmodules` contained only `libs/hbb_common`, sourced from + `https://github.com/rustdesk/hbb_common`. +- Established inventory: 31 Cargo git dependencies in active manifests; + Flutter git packages; workflow third-party sources; no final offline proof + yet. +- The checked-out `libs/hbb_common` source was clean, detached at the gitlink + commit, and contained the existing deterministic `BUILD_DATE` generation in + `src/lib.rs`. No new BUILD_DATE change is introduced by this phase. +- Before the change, `cargo metadata --locked --no-deps --format-version 1` + reported 9 workspace members including `hbb_common`. + +## Phases and stop gates + +### Phase 0 — Baseline and inventory + +Record the branch/base, worktree status, gitlink commit, submodule metadata, +and known dependency/source inventory. Scan for submodules and confirm the +scope boundary. + +**Stop gate:** stop if the worktree is not clean, the target base is not +`origin/rustqs/min-test`, unexpected submodules exist, or the checked-out +`hbb_common` source is dirty or cannot be identified deterministically. + +### Phase 1 — Local `hbb_common` source ownership + +Copy the checked-out `libs/hbb_common` source at `a920d009` into the parent as +ordinary tracked files. Remove the `libs/hbb_common` gitlink and remove +`.gitmodules` because it has no other entries. Keep the existing Cargo path +member/dependency declarations unchanged. Preserve source content exactly, +including the existing deterministic BUILD_DATE generator; do not invent a +new BUILD_DATE modification. + +**Stop gate:** stop if the source cannot be copied without loss, if another +submodule is discovered, if `.gitmodules` contains another entry, or if the +gitlink cannot be replaced safely. Do not begin Phase 2 in this phase. + +### Phase 2 — Cargo dependency source migration + +Copy the 44 exact locked package roots formerly supplied by git sources from the locally verified vendor tree +into tracked `third_party/` and change active manifests to relative path +dependencies, preserving package versions, features, optionality, and target +conditions. Update copied nested manifests only where required for path-owned +family members (cacao → Core Foundation, cpal → cidre, pam → pam-sys, +tfc → x11, plus nokhwa/tao/Core Foundation/webm path relationships). + +Registry dependencies remain registry dependencies. `.cargo/config.vendor.toml` +continues to describe the future relative `vendor/` registry source and forces +offline mode, but no full registry vendor tree is copied in this phase. + +`scripts/verify-cargo-vendor.py` records the external vendor provenance using a +deterministic file-tree SHA-256 in both directory and archive-compatible form: +sorted `vendor/\0file\0SHA256(file-bytes)\n` records. Directory mode must +match `vendor-provenance.json`'s `vendor_tree.tree_sha256`; archive mode retains +the immutable archive SHA-256 and member-count checks. The available external +directory and archive produce the same tree digest. + +**Stop gate:** every changed dependency must have an owned source and a +reproducible resolution; no URL substitution may be accepted without a +focused build/metadata check. + +**Current blocker:** full registry-vendor ownership/publication remains open. +Metadata succeeds with the verified external vendor directory override, while +the committed relative `vendor/` directory is intentionally absent. Phase 2 +does not claim registry independence. + +### Phase 3 — Flutter and workflow source migration + +Copy the exact resolved Flutter package roots and the exact +`RustDeskTempTopMostWindow` source into tracked `third_party/` paths. Active +Flutter dependencies use relative `path:` entries and the lockfile preserves +the package versions and dependency graph while changing only the source type +to `path`. The reusable TopMostWindow workflow checks out the repository, +validates the local owned source manifest pin, and builds the preserved +project/output paths without a source clone. + +The owned source manifest is `third_party/source-ownership.yaml`. It records +these exact refs and current tree-derived integrity counts: + +| Input | Ref | Source files | +| --- | --- | ---: | +| `dash_chat_2` | `bd6b5b41254e57c5bcece202ebfb234de63e6487` | 133 | +| `desktop_multi_window` | `b47e8385e5a75d38319ad706a64b0ead3108b093` | 115 | +| `dynamic_layouts` | `24cb88413fa5181d949ddacbb30a65d5c459e7d9` | 145 | +| `flutter_gpu_texture_renderer` | `08a471bb8ceccdd50483c81cdfa8b81b07b14b87` | 49 | +| `flutter_texture_rgba_renderer` (`texture_rgba_renderer`) | `42797e0f03141dc2b585f76c64a13974508058b4` | 98 | +| `uni_links` | `f416118d843a7e9ed117c7bb7bdc2deda5a9e86f` | 111 | +| `window_manager` | `85789bfe6e4cfaf4ecc00c52857467fdb7f26879` | 110 | +| `flutter-desktop-embedding/plugins/window_size` | `eb3964990cf19629c89ff8cb4a37640c7b3d5601` | 181 | +| `RustDeskTempTopMostWindow` | `ecd8d6a139eee76845ea66423fb739af450fda90` | 25 | + +The copied source total is 967 current tree files across the eight Flutter +roots and TopMost root. Repository `.git` metadata is absent, and every copied +source file, including the six `window_size` example generated files, is an +integrity input; mutable `.gitignore` rules cannot omit source. Package assets, +licenses, and tracked symlinks are retained. +No Flutter SDK, Flutter engine/toolchain, full Cargo registry vendor tree, or +generated build archive was copied. Non-owned external build inputs remain +unproven/absent. The active `third_party/hwcodec` input is a separate owned +source tree and is present. Its pre-cleanup inventory was 440 files; four +untracked per-user `.vcxproj.user` outputs were removed, leaving 436 current + integrity files and 9,010,415 bytes. The current externals-only inventory is 360 files, +8,382,509 bytes, consisting of 274 `.h`, 73 `.cpp`, 3 `.vcxproj`, 2 `.cmake`, +2 `.mk`, 2 `.txt`, and one each of `.dll`, `.filters`, `.lib`, and `.map`. +These checked-in +SDK/header/library inputs do not establish full SDK or engine independence. + +**Stop gate:** active build workflows and Flutter dependency resolution must be +auditable without an unapproved RustDesk-org network dependency. + +### Phase 4 — Offline/reproducibility proof (not executed here) + +Run clean, locked metadata/build checks in a network-isolated environment and +record the exact cache/vendor inputs, platform scope, and residual limitations. + +**Stop gate:** no final independence claim is made until the documented proof +passes for the accepted build matrix, or remaining gaps are explicitly +reported. + +## Acceptance criteria + +### Phase 0/1 acceptance (pre-migration baseline record) + +1. Canonical `plan.md` and `todo.md` exist under this directory. +2. The parent repository records `libs/hbb_common` as ordinary files, not mode + `160000`, and `.gitmodules` is absent because no other submodules exist. +3. The copied source matches the checked-out `a920d009` source, excluding the + submodule's private `.git` metadata. +4. `Cargo.toml` still declares `libs/hbb_common` as a path workspace member and + dependency. +5. `cargo metadata --locked --no-deps --format-version 1` succeeds and still + includes `hbb_common`, when feasible in the environment. +6. `git diff --check` is run; any inherited whitespace in the exact migrated + source is recorded without rewriting that source, and non-migrated files + pass the check. The requested submodule scan is run and has no nested + submodules. +7. Pre-migration baseline: no Cargo dependency URLs, Flutter package sources, + workflows, or DeskForge files were changed by the Phase 0/1 work package. + This is not a statement about the current Phase 2/3 worktree. + +## Phase 0/1 verification record + +- Source comparison against the checked-out submodule object at `a920d009` + passed with no differences, excluding private `.git` metadata. +- The parent index now records 32 ordinary `100644` files under + `libs/hbb_common`; the gitlink is gone. +- `.gitmodules` is absent, `git submodule status` and recursive submodule scan + are empty, and no nested `libs/hbb_common/.git` remains. +- `cargo metadata --locked --no-deps --format-version 1` passed; it reported 9 + workspace members and one path member for `hbb_common`. +- Cargo TOML parsing passed for the parent and copied `hbb_common` manifests; + existing parent path declarations are unchanged. +- `git diff --check` was run. It reports trailing whitespace inherited from + the copied `a920d009` protobuf source (`message.proto` and + `rendezvous.proto`). The source was not rewritten because Phase 1 is an + exact source migration; `git diff --check` passes when the copied source + path is excluded. +- The Phase 0/1 work package did not change Cargo dependency URLs, Flutter + package sources, workflows, or DeskForge files. Later Phase 2/3 changes are + recorded below. No commit or push was performed. + +### Phase 2 verification record + +- Copied 44 exact locked package roots formerly supplied by git sources from + `/home/bash/projects/DeskForge/offline-kit/artifacts/rustdesk-src/vendor` + into tracked `third_party/`; no full registry `vendor/` tree was copied. +- Root/library manifests and required copied nested manifests now use relative + path dependencies. The root workspace excludes `third_party` so copied + package workspace metadata cannot absorb the main workspace. +- Root and nested Cargo lockfiles retain their prior locked package versions, + features, and checksums, with zero active git sources. Normal + `cargo metadata --locked --format-version 1` and the externally overridden + offline form both pass with 9 workspace members and 1,017 resolved metadata + packages. +- `scripts/check-cargo-git-sources.py` passes across 54 manifests and 9 + lockfiles. The current worktree and index `git diff --check` checks are clean. + A full commit-range check over the migration history reports inherited/copied + third-party whitespace, including the Phase 1 whitespace in + `libs/hbb_common/protos/message.proto` and `rendezvous.proto` and the 12 + intentional CRLF vendor files covered by `.gitattributes -text`. +- No DeskForge/PR #4 worktree was modified; no commit, push, fork, release, or + publication was performed. + +Remaining Phase 2 gates are the full registry vendor tree and the accepted +clean offline build matrix; Phase 2 does not claim registry independence. + +### Phase 3 verification record (current) + +- Exact source-tree comparisons passed for all eight Flutter package roots and + the TopMostWindow root at the refs above. The manifest now verifies 967 + current Flutter/TopMost tree files plus 436 active hwcodec files using + deterministic SHA-256 tree digests and byte counts; additions, removals, or + changes fail closed. +- No `.git` directory was copied under `third_party/`. +- `python3 scripts/check-flutter-source-ownership.py` is stdlib-only and scans the active Flutter + and copied third-party `pubspec.yaml`/`pubspec.lock` manifests, active + workflows, every copied third-party `.gitmodules` file, and all files under + the active owned `third_party/hwcodec` input for forbidden executable-source + URLs. `hwcodec/build.rs` consumes `externals/`, so that tree is not inert; + all nested `.gitmodules` declarations are classified as active/inert with + source presence checks; an active missing target or forbidden active URL + fails. `kcp-sys/kcp` is present and active, while tao's `deps/apk-builder` + and webm's `src/sys/libwebm` declarations are absent and inert. The copied + `third_party/flutter/desktop_multi_window/example` manifests are explicitly + excluded and documented as non-build example material; their exact Git + dependency is retained byte-for-byte. The two remaining `rustdesk-org` URLs + in `third_party/flutter/desktop_multi_window/lib/src/window_controller.dart` + are exact-line comment references at lines 40 and 102. They are listed in + `scan_policy.allowed_reference_urls` with exact path, line, content, URL, and + reason `source-preserved documentation only`. This is an allowlist for + informational source-preserved comments only: executable code, manifests, + workflows, and `.gitmodules` still reject every other `rustdesk-org` URL. + Documentation/history outside declared owned roots remains outside the active + scan scope. +- YAML parsing passed for the active workflows, Flutter manifests/lockfile, + ownership manifest, and copied package manifests. Lock consistency checks + confirmed all eight local paths exist and use `source: path` with unchanged + versions. +- The reusable TopMostWindow workflow validates the exact ownership record + name, path, and ref through the ownership scanner. The four remaining active + checkout uses in `bridge.yml`, `rustqs-android.yml`, `rustqs-linux.yml`, and + `rustqs-windows-min-test.yml` use the same approved full SHA + `3d3c42e5aac5ba805825da76410c181273ba90b1`. +- Local `flutter pub get` was not run because local Flutter/Dart tooling is not + required for this migration. Flutter dependency/build validation is deferred + to the repository's GitHub Actions/F-Droid build workflows and their + toolchains. No real GitHub Actions run has been performed, and the accepted + build matrix remains unverified. +- Existing Cargo metadata and source checks remain green: locked no-deps + metadata reports 9 workspace members; `scripts/check-cargo-git-sources.py` + passes for 54 Cargo manifests and 9 lockfiles. +- The current worktree and index `git diff --check` checks are clean. A full + commit-range check reports inherited/copied third-party whitespace, including + the Phase 1 trailing whitespace in `libs/hbb_common/protos/message.proto` and + `rendezvous.proto` and the 12 intentional CRLF vendor files covered by + `.gitattributes -text`; the Phase 3 files introduce no new whitespace errors. +- No other worktree was modified; no commit, push, fork, release, or + publication was performed. Mutable non-checkout actions remain out of scope; + this phase does not broaden action pinning beyond the documented checkout-only + guarantee. + +Repository-owned checks cover source trees, policy, manifests, workflow pins, +Cargo metadata, and available external vendor input. Approved but unavailable + inputs remain separate gates: the committed full registry `vendor/` tree, + CI-provided Flutter SDK/engine/toolchain validation, and the accepted offline + build matrix. + +### Final policy-fix verification (current) + +- Cargo config discovery now enumerates actual files under `.cargo/`, so both + `config.toml` and `config.vendor.toml` are structurally scanned; the Cargo + scan count is 54 manifests, 2 config files, and 9 lockfiles. +- Nested `.gitmodules` discovery is scoped deterministically to declared owned + roots and relevant repository metadata, pruning ignored/generated artifact + directories such as `target/`; the three copied nested declarations remain + discovered and classified. The tracked + `flutter/android/flutter_hbb_android.iml` predates this migration (last + introduced by `6de0fa781` in 2022) and is left unchanged as pre-existing IDE + metadata. +- `.rc` and `.map` are no longer binary suffix exclusions. Text `.rc`/`.map` + inputs are UTF-8 decoded and forbidden-URL scanned; the one copied Sciter + `SAr` resource archive is recognized by its binary format signature and + checked for raw forbidden URL bytes without treating the suffix itself as + binary. +- The ownership scanner passed with the required TopMost pin + `ecd8d6a139eee76845ea66423fb739af450fda90`: 35 active manifests, 3 nested + `.gitmodules`, and 3,286 owned-root files scanned (including all 44 Cargo + path-owned roots); all 10 integrity roots + matched their recorded counts, byte counts, and SHA-256 tree digests. +- The exact documented-reference allowlist contains only + `window_controller.dart:40` and `window_controller.dart:102`, each bound to + its complete comment line and exact URL with reason `source-preserved + documentation only`. A negative test rejected an unlisted + `rustdesk-org` URL. The scanner still independently scans code, manifests, + workflows, and every `.gitmodules`; the allowlist does not apply to those + categories as a general URL exemption. +- A `.gitignore` bypass test confirmed ignored files are still included in + owned-entry traversal, and a symlink/link integrity test confirmed an + undeclared link is rejected. The positive scanner run also preserved the + existing six-link allowlist and all integrity digests, including the six + retained `window_size` example generated files. +- Both external Cargo vendor inputs passed: + `/home/bash/projects/DeskForge/offline-kit/artifacts/rustdesk-src/vendor` + and `/home/bash/projects/DeskForge/offline-kit/artifacts/vendor-1.4.8.tar.gz` + each verified 1,005 registry packages and 44 copied package roots, including + byte/file-list/checksum comparisons for every copied root under the explicit + 44-root contract. The Cargo + source scan passed for 54 manifests and 9 lockfiles. Cargo metadata passed + with 9 workspace members (`--no-deps`) and 9 members/1,017 packages (offline + locked metadata). YAML parsing passed for 37 files; workflow checks passed + for 5 workflow files and 5 full-SHA checkout refs. +- The current worktree and index `git diff --check` checks are clean. A full + commit-range check reports inherited/copied third-party whitespace, including + `libs/hbb_common/protos/message.proto`, `rendezvous.proto`, and the 12 + intentional CRLF vendor files covered by `.gitattributes -text`. No copied + source contents were changed, and no commit, push, or publication occurred. +- Local Flutter/Dart tooling was not used because it is not required for this + migration. Flutter dependency/build validation remains deferred to the + repository's GitHub Actions/F-Droid build workflows; no real GitHub Actions + run has been performed, and the accepted network-isolated build matrix has + not been executed. The committed relative full registry `vendor/` tree is + intentionally absent. The external vendor directory/archive verification + above is available-input evidence, not a claim of CI success or full offline + independence. + +### Vendor provenance newline repair (follow-up) + +The copied-root comparison was re-run at follow-up HEAD `18507d6fd` and +correctly failed in both external-input modes at `kcp-sys/kcp/.gitignore`. +The complete mismatch set was: + +- `third_party/kcp-sys/kcp/.gitignore` +- `third_party/kcp-sys/kcp/.travis.yml` +- `third_party/kcp-sys/kcp/README.en.md` +- `third_party/kcp-sys/kcp/README.md` +- `third_party/kcp-sys/kcp/ikcp.c` +- `third_party/kcp-sys/kcp/ikcp.h` +- `third_party/kcp-sys/kcp/protocol.txt` +- `third_party/kcp-sys/kcp/test.cpp` +- `third_party/kcp-sys/kcp/test.h` +- `third_party/sysinfo/md_doc/sid.md` +- `third_party/sysinfo/src/windows/sid.rs` +- `third_party/tao/.changes/readme.md` + +Each local file was the exact LF-normalized form of the corresponding file in +both the approved external vendor directory and archive; the external evidence +matched byte-for-byte and retained CRLF. The repository's root `.gitattributes` +rule (`* text=auto`) had normalized the copied source to LF, so this was a local +copied-source representation mismatch, not a content or manifest rewrite. The +follow-up preserves the approved external bytes and adds exact `-text` +attributes for only these 12 paths. The verifier remains fail-closed and +unchanged. No vendor archive/tree digest or count changed, because the external + evidence did not change; no manifest rewrite or ownership record changed. + +The 12-file line-ending provenance repair is byte-for-byte matched by the +approved external vendor directory and archive. After the repair, the current +worktree and index `git diff --check` checks are clean. A full commit-range +`git diff --check` still reports inherited/copied third-party whitespace from +earlier migration commits, including these intentional CRLF vendor files and +the inherited protobuf whitespace; it is not accurate to report that only the +protobuf whitespace remains. + +### Final migration acceptance (future phases) + +1. Active Rust, Flutter, and workflow inputs are owned/tracked or explicitly + documented as accepted external inputs. +2. No RustDesk upstream submodule or `rustdesk-org` network dependency remains + in the active build path, subject to documented third-party exceptions. +3. Locked metadata and the accepted build matrix pass from the owned inputs. +4. Offline/reproducibility proof is recorded; no proof is implied by local + source vendoring alone. + +## Scope distinction + +Local migration means changing this worktree's tracked source, manifests, +vendor inputs, and validation records. External fork publication means creating +or updating a remote fork/repository, branches, commits, pushes, PRs, releases, +or other public artifacts. The latter is explicitly out of scope here and +requires separate approval. diff --git a/plans/upstream-independent-build/todo.md b/plans/upstream-independent-build/todo.md new file mode 100644 index 00000000000..15881dc83b9 --- /dev/null +++ b/plans/upstream-independent-build/todo.md @@ -0,0 +1,153 @@ +# Upstream-Independent Build Migration TODO + +## Phase 0 — Baseline + +- [x] Record worktree, base, baseline commit, and clean status in `plan.md`. +- [x] Record the established inventory: `a920d009` gitlink, 31 active Cargo + git dependencies, Flutter git packages, workflow third-party sources, and + no final offline proof. +- [x] Scan submodule metadata and confirm `libs/hbb_common` is the sole + submodule. + +## Phase 1 — Local `hbb_common` source ownership + +- [x] Extract the clean checked-out `a920d009` source into the parent as + ordinary tracked files, excluding private `.git` metadata. +- [x] Remove the `libs/hbb_common` gitlink and the sole-entry `.gitmodules`. +- [x] Confirm the existing Cargo path workspace member/dependency declarations + are unchanged and still resolve `hbb_common`. +- [x] Run `git diff --check`, submodule status/scan, and + `cargo metadata --locked --no-deps --format-version 1` when feasible. +- [x] Review the diff for Phase 1 scope only; do not commit or push. The + staged plan/source replacement is limited to `.gitmodules`, + `libs/hbb_common/**`, and these canonical plan files. + +### Phase 1 verification note + +At the Phase 1 snapshot, full `git diff --check` reported inherited trailing +whitespace in the copied `a920d009` protobuf files. The source was left +byte-for-byte unchanged as required. Next gate: Phase 2 review before any Cargo +git dependency URL or vendor-source changes. + +## Phase 2 — Cargo sources + +- [x] Record the fail-closed relative `vendor/` Cargo source replacement with + `net.offline = true` without copying the large vendor tree. +- [x] Add the read-only Cargo.lock/vendor config verification helper. +- [x] Record local vendor provenance and archive SHA-256. +- [x] Copy 44 exact package roots formerly supplied by git sources into tracked + `third_party/`; do not copy the full registry vendor tree. +- [x] Convert active root/library and required copied nested manifests to + relative path dependencies without changing versions/features. +- [x] Confirm zero active git sources in root and nested Cargo lockfiles while + retaining locked registry versions/features. +- [x] Run normal and external-vendor-override offline locked metadata checks; + each reports 9 workspace members and 1,017 packages. +- [x] Add and run the active manifest/lock git-source scan; confirm no full + `vendor/` tree. +- [x] Confirm current worktree and index `git diff --check` checks are clean; + record that a full commit-range check reports inherited/copied third-party + whitespace, including the inherited protobuf whitespace and the 12 intentional + CRLF vendor files covered by `.gitattributes -text`. +- [ ] Obtain approved ownership/publication or immutable storage for the full + registry vendor tree and place it at relative `vendor/`. +- [ ] Complete registry-vendor verification and the accepted clean offline + build matrix; registry independence is not claimed by Phase 2. +- [x] Migrate Flutter git packages and workflow third-party inputs in Phase 3. +- [ ] Stop for branch provenance, commit, push, PR, and publication gates. + +## Phase 3 — Flutter/workflows + +- [x] Inventory the eight Flutter git package inputs and the TopMostWindow + workflow source. +- [x] Copy exact pinned source trees into `third_party/flutter/` and + `third_party/windows/`, excluding only `.git` metadata; 967 current tree + files copied across 9 source roots, all retained as Flutter/TopMost integrity + inputs, including the six `window_size` example generated files. +- [x] Switch active Flutter manifests/lock entries to local `path` sources + while preserving versions and package path semantics. +- [x] Switch the reusable TopMostWindow workflow to the tracked source and + preserve its MSBuild project/output behavior and pin validation. +- [x] Add and run `scripts/check-flutter-source-ownership.py` over active + manifests, locks, workflows, copied third-party manifests, and nested + `.gitmodules`; document active owned inputs and non-build example exceptions + in `third_party/source-ownership.yaml`. +- [x] Pin active root workflows' `actions/checkout` to the approved immutable + SHA `3d3c42e5aac5ba805825da76410c181273ba90b1`, including TopMostWindow. +- [x] Remove the accidental `third_party/nokhwa/examples/.DS_Store` artifact. +- [x] Record exact refs, tree-derived file counts, SHA-256 digests, source + comparisons, and limitations in `plan.md`; no Flutter SDK/engine/toolchain, + full Cargo registry vendor tree, or generated build archive was copied. + Non-owned external build inputs remain unproven/absent. The active + `hwcodec/externals` tree was copied and retained as owned build input. The + pre-cleanup hwcodec inventory was 440 files; four untracked per-user + `.vcxproj.user` outputs were removed, leaving 436 current integrity files + (9,010,415 bytes; header, C++, project metadata, and required binary/library + inputs). The current externals-only inventory is 360 files and 8,382,509 + bytes; the old 361-file figure included one removed per-user output. +- [x] Run YAML/lock consistency checks, ownership integrity checks, Cargo + metadata/source checks, and current worktree/index `git diff --check` checks; + they are clean. The full commit-range check reports inherited/copied + third-party whitespace, including the inherited Phase 1 protobuf whitespace + and the 12 intentional CRLF vendor files covered by `.gitattributes -text`. +- [x] Record the two exact source-preserved informational comment URLs in + `window_controller.dart` (lines 40 and 102) as path/line/content/URL entries + with reason `source-preserved documentation only`; executable code, + manifests, workflows, and `.gitmodules` remain forbidden-URL scanned. +- [ ] Defer `flutter pub get` and Flutter build validation to the repository's + GitHub Actions/F-Droid build workflows and their toolchains; local + Flutter/Dart tooling is not required for this migration. No real GitHub + Actions run has been performed, and the accepted build matrix remains + unverified. +- [ ] Stop for review before commit, push, PR, fork, release, or publication. + +### Final policy-fix review + +- [x] Enumerate and structurally scan both `.cargo/config.toml` and + `.cargo/config.vendor.toml`; scope nested `.gitmodules` discovery to declared + owned roots/repository metadata while excluding generated `target/` output. +- [x] Leave `flutter/android/flutter_hbb_android.iml` unchanged after verifying + it is pre-existing tracked IDE metadata (`6de0fa781`, 2022), not a migration + artifact. +- [x] Remove `.rc`/`.map` from binary suffix exclusions; UTF-8 scan text inputs + and inspect the copied Sciter `SAr` resource archive by content signature. +- [x] Add exact path/line/content/URL allowlist records for the two preserved + `rustdesk-org` comment references in `window_controller.dart` (lines 40 and + 102), with reason `source-preserved documentation only`. +- [x] Keep all other forbidden URLs rejected in owned code, manifests, + workflows, and `.gitmodules`; verify the allowlist is not broadened by + `.gitignore` and that symlink/link integrity remains fail-closed. +- [x] Re-run ownership with the TopMost pin, both external vendor verifiers, + Cargo source/metadata checks, YAML/workflow checks, the negative forbidden + URL test, link-integrity tests, and current worktree/index `git diff --check` + checks. The full commit-range check reports inherited/copied third-party + whitespace, including the inherited protobuf whitespace and the 12 intentional + CRLF vendor files covered by `.gitattributes -text`. Exact results and + unavailable blockers are recorded in `plan.md` above. +- [x] Preserve the 12 approved vendor files' CRLF bytes with exact `-text` + attributes; both external vendor inputs match those bytes byte-for-byte. + +### Phase 3 review-fix limitations + +- The copied `desktop_multi_window/example` remains exact source-preserved + non-build material, so its upstream Git dependency is excluded from the + active ownership scan rather than rewritten. +- `third_party/hwcodec` is an active owned Cargo input: `build.rs` consumes + `externals/`. Its copied `externals` contents remain. Nested `kcp-sys`, tao, + and webm `.gitmodules` declarations are explicitly classified as active or + inert with source-presence validation; tao and webm absent targets are + documented inert metadata, not silently ignored. +- Integrity hashing includes all copied `window_size` source, including its six + example generated files; changing root `.gitignore` cannot omit a source + file. The documented-reference allowlist is separate from integrity and + cannot be broadened by `.gitignore`. Current Flutter/TopMost integrity total + is 967 files; current cleaned hwcodec integrity total is 436 + (pre-cleanup: 440). There are 10 integrity roots, not 11. + +## Phase 4 — Proof (not executed) + +- [ ] Define the accepted platform/build matrix and required caches/vendor + inputs. +- [ ] Run network-isolated locked metadata/build checks. +- [ ] Document residual external dependencies and do not claim final offline + independence until acceptance criteria pass. diff --git a/plans/upstream-independent-build/vendor-provenance.json b/plans/upstream-independent-build/vendor-provenance.json new file mode 100644 index 00000000000..492d8b36ec5 --- /dev/null +++ b/plans/upstream-independent-build/vendor-provenance.json @@ -0,0 +1,59 @@ +{ + "cargo_lock_sha256": "5edcd602b2d5392d1b142b375c1240566230eef7cce88090b0bbf34c1edf8228", + "cargo_toml_sha256": "04c6faa5f7c1688e267e77933ce9a31aefc8cd032ce97781c408d33b2317ec62", + "copied_package_root_count": 44, + "copied_root_contract": { + "roots": [ + "android-wakelock", "arboard", "cacao", "cidre", "cidre-macros", + "clipboard-master", "confy", "core-foundation-0.9.3", + "core-foundation-sys-0.8.6", "core-graphics-0.23.1", + "core-graphics-types-0.1.2", "cpal", "default_net", "evdev", + "filedescriptor", "hwcodec", "impersonate_system", "kcp-sys", + "keepawake", "machine-uid", "magnum-opus", "nokhwa", + "nokhwa-bindings-linux", "nokhwa-bindings-macos", + "nokhwa-bindings-windows", "nokhwa-core", "pam", "pam-sys", + "parity-tokio-ipc", "portable-pty", "rdev", "rust-pulsectl", + "sciter-rs", "sysinfo", "tao", "tao-macros", "tfc", "tokio-socks", + "tray-icon", "wallpaper", "webm", "webm-sys", "x11-2.19.0", + "x11-clipboard-0.8.1" + ], + "manifest_rewrites": [ + "cacao", "core-foundation-0.9.3", "core-graphics-0.23.1", + "core-graphics-types-0.1.2", "cpal", "hwcodec", "nokhwa", "pam", + "tao", "tfc", "webm", "x11-2.19.0" + ], + "allowed_omissions": { + "hwcodec": [ + ".gitmodules", + "dev/vs/AMFTest/AMFTest.vcxproj.user", + "dev/vs/MFXTest/MFXTest.vcxproj.user", + "dev/vs/ShaderCompileTool/ShaderCompileTool.vcxproj.user", + "externals/MediaSDK_22.5.4/api/mfx_dispatch/windows/libmfx_vs2015.vcxproj.user" + ], + "nokhwa": [ + ".run/Clippy All.run.xml", + ".run/Clippy Main Apple.run.xml", + ".run/Clippy Main Windows.run.xml", + ".run/JSCam.run.xml", + "examples/.DS_Store" + ] + } + }, + "active_git_source_count": 0, + "vendor_archive": { + "path": "/home/bash/projects/DeskForge/offline-kit/artifacts/vendor-1.4.8.tar.gz", + "path_scope": "local evidence path; not portable provenance", + "sha256": "bec49649ff3c0a49fc4cf2f5ff2aff3a459f2f6fb45039cbe8b89a9995d8a4b6", + "file_count": 59831, + "package_count": 1049, + "member_count": 74239 + }, + "vendor_tree": { + "path": "/home/bash/projects/DeskForge/offline-kit/artifacts/rustdesk-src/vendor", + "path_scope": "local evidence path; not portable provenance", + "file_count": 59831, + "package_count": 1049, + "tree_sha256": "7a9fa2515c109273a0ff8643a7140eb19f993fd134aee0fda3417c4c8a7a11cf" + }, + "status": "44 copied package roots locally verified; zero active git sources; registry vendor tree/archive ownership remains outstanding" +} diff --git a/scripts/check-cargo-git-sources.py b/scripts/check-cargo-git-sources.py new file mode 100644 index 00000000000..36aea17aa95 --- /dev/null +++ b/scripts/check-cargo-git-sources.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Fail if active Cargo manifests or lockfiles retain git sources.""" + +from __future__ import annotations + +from pathlib import Path +import re +import sys +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MANIFESTS = sorted(ROOT.glob("**/Cargo.toml")) +LOCKS = sorted(ROOT.glob("**/Cargo.lock")) + + +def cargo_configs() -> list[Path]: + """Discover Cargo config files instead of treating a glob as a filename.""" + config_root = ROOT / ".cargo" + if not config_root.is_dir(): + return [] + return sorted( + path + for path in config_root.rglob("*") + if path.is_file() + and ( + path.name in {"config", "config.toml"} + or (path.name.startswith("config.") and path.name.endswith(".toml")) + ) + ) + + +CONFIGS = cargo_configs() +DEPENDENCY_TABLES = {"dependencies", "dev-dependencies", "build-dependencies"} +SOURCE_TABLES = DEPENDENCY_TABLES | {"workspace.dependencies", "workspace.dev-dependencies", "workspace.build-dependencies"} +GIT_SOURCE = re.compile(r"^git(?:\+|$)") +URL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") +CARGO_SOURCE_CONFIG_KEYS = {"replace-with", "registry", "local-registry", "directory", "git"} +SAFE_SOURCE_KEYS = { + "patch": {"crates-io"}, + "replace": {"crates-io"}, + "source": {"crates-io", "vendored-sources"}, +} +SOURCE_BEARING_KEYS = { + "branch", + "default-features", + "directory", + "features", + "git", + "local-registry", + "optional", + "path", + "package", + "registry", + "replace-with", + "rev", + "source", + "tag", + "version", +} + + +def toml_parser() -> Any: + try: + import tomllib + except ModuleNotFoundError as error: + raise RuntimeError("Python 3.11+ tomllib is required to parse Cargo metadata") from error + return tomllib + + +def read_toml(path: Path) -> dict[str, Any]: + try: + display_path = path.relative_to(ROOT) + except ValueError: + display_path = path + try: + text = path.read_bytes().decode("utf-8") + parsed = toml_parser().loads(text) + except (OSError, UnicodeDecodeError, ValueError, RuntimeError) as error: + raise RuntimeError(f"cannot parse UTF-8 TOML {display_path}: {error}") from error + if not isinstance(parsed, dict): + raise RuntimeError(f"TOML document is not a table: {display_path}") + return parsed + + +def manifest_git_sources(data: dict[str, Any]) -> list[str]: + failures: list[str] = [] + + def source_spec(value: Any, location: str) -> None: + if isinstance(value, str): + if GIT_SOURCE.match(value): + failures.append(f"{location}: contains git source {value!r}") + return + if not isinstance(value, dict): + failures.append(f"{location}: unsupported Cargo source specification") + return + if "git" in value: + failures.append(f"{location}: contains git = {value['git']!r}") + if "source" in value and isinstance(value["source"], str) and GIT_SOURCE.match(value["source"]): + failures.append(f"{location}: contains git source = {value['source']!r}") + source_keys = {"git", "branch", "rev", "tag", "path", "version", "registry", "source", "optional", "default-features", "features", "package"} + unknown = sorted(key for key in value if key not in source_keys) + if unknown: + failures.append(f"{location}: unsupported source-bearing keys: {unknown}") + + def dependency_table(value: Any, location: str) -> None: + if not isinstance(value, dict): + failures.append(f"{location}: dependency table is not a TOML table") + return + for dependency, specification in value.items(): + source_spec(specification, f"{location}.{dependency}") + + def cargo_source_config(value: Any, location: str) -> None: + if not isinstance(value, dict): + failures.append(f"{location}: Cargo source configuration is not a TOML table") + return + for key, source in value.items(): + if key not in CARGO_SOURCE_CONFIG_KEYS: + failures.append(f"{location}.{key}: unsupported source-bearing configuration key") + elif not isinstance(source, str): + failures.append(f"{location}.{key}: Cargo source configuration value is not a string") + elif key == "git": + failures.append(f"{location}: contains git source = {source!r}") + + def has_source_bearing_value(value: Any) -> bool: + """Identify source-bearing descendants under an arbitrary TOML table.""" + if isinstance(value, dict): + if any(key in SOURCE_BEARING_KEYS for key in value): + return True + return any(has_source_bearing_value(child) for child in value.values()) + if isinstance(value, list): + return any(has_source_bearing_value(child) for child in value) + return False + + def validate_source_key(table_kind: str, source_name: Any, location: str) -> None: + """Reject URL source keys unless the table has an explicit safe allowlist entry.""" + if not isinstance(source_name, str): + failures.append(f"{location}: Cargo source key is not a string") + return + if URL_KEY.match(source_name) and source_name not in SAFE_SOURCE_KEYS.get(table_kind, set()): + failures.append( + f"{location}: URL-keyed Cargo {table_kind} source is not allowlisted: {source_name!r}" + ) + + def source_table(value: Any, location: str, table_kind: str) -> None: + if not isinstance(value, dict): + failures.append(f"{location}: source-bearing table is not a TOML table") + return + for source_name, source_table_value in value.items(): + source_location = f"{location}.{source_name}" + validate_source_key(table_kind, source_name, source_location) + if not isinstance(source_table_value, dict): + failures.append(f"{source_location}: source-bearing table is not a TOML table") + continue + if table_kind == "source": + cargo_source_config(source_table_value, source_location) + continue + if any(key in source_table_value for key in ("git", "branch", "rev", "tag", "path", "source")): + source_spec(source_table_value, source_location) + continue + for package, specification in source_table_value.items(): + source_spec(specification, f"{source_location}.{package}") + + def visit(value: Any, location: str) -> None: + if isinstance(value, list): + for index, child in enumerate(value): + visit(child, f"{location}[{index}]") + return + if not isinstance(value, dict): + return + for key, child in value.items(): + child_location = f"{location}.{key}" if location else str(key) + if key in DEPENDENCY_TABLES: + dependency_table(child, child_location) + elif location == "workspace" and key in {"dependencies", "dev-dependencies", "build-dependencies"}: + dependency_table(child, child_location) + elif key in {"patch", "replace"}: + source_table(child, child_location, key) + elif key == "source" and (location == "" or location.startswith(".cargo")): + source_table(child, child_location, "source") + elif ( + isinstance(child, (dict, list)) + and URL_KEY.match(key) + and has_source_bearing_value(child) + ): + validate_source_key("nested", key, child_location) + elif isinstance(child, dict) and any(name in child for name in ("git", "branch", "rev", "tag", "source")): + failures.append(f"{child_location}: unsupported source-bearing Cargo structure") + elif isinstance(child, dict): + visit(child, child_location) + + visit(data, "") + return failures + + +def lock_git_sources(data: dict[str, Any]) -> list[str]: + failures: list[str] = [] + packages = data.get("package", []) + if not isinstance(packages, list): + return ["package: Cargo.lock package table is not an array"] + for index, package in enumerate(packages): + if not isinstance(package, dict): + failures.append(f"package[{index}]: package entry is not a TOML table") + continue + source = package.get("source") + if isinstance(source, str) and source.startswith("git+"): + failures.append(f"package[{index}].source: {source!r}") + elif source is not None and not isinstance(source, str): + failures.append(f"package[{index}].source: source is not a string") + return failures + + +def main() -> int: + failures: list[str] = [] + for manifest in [*MANIFESTS, *CONFIGS]: + try: + findings = manifest_git_sources(read_toml(manifest)) + except RuntimeError as error: + failures.append(str(error)) + continue + failures.extend(f"{manifest.relative_to(ROOT)}: {finding}" for finding in findings) + for lock in LOCKS: + try: + findings = lock_git_sources(read_toml(lock)) + except RuntimeError as error: + failures.append(str(error)) + continue + failures.extend(f"{lock.relative_to(ROOT)}: {finding}" for finding in findings) + if (ROOT / "vendor").exists(): + failures.append("vendor/: full registry vendor tree must remain outside this migration") + if failures: + print("active Cargo git-source check failed:\n" + "\n".join(failures), file=sys.stderr) + return 1 + print(f"checked {len(MANIFESTS)} Cargo.toml files, {len(CONFIGS)} Cargo config files, and {len(LOCKS)} Cargo.lock files: no git sources") + print("confirmed no full vendor/ tree is present") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RuntimeError as error: + print(f"active Cargo git-source check failed: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/scripts/check-flutter-source-ownership.py b/scripts/check-flutter-source-ownership.py new file mode 100755 index 00000000000..e7c2be883a4 --- /dev/null +++ b/scripts/check-flutter-source-ownership.py @@ -0,0 +1,849 @@ +#!/usr/bin/env python3 +"""Validate owned Flutter/workflow source metadata and immutable tree digests.""" + +from __future__ import annotations + +from pathlib import Path +import argparse +import ast +import fnmatch +import hashlib +import os +import re +import stat +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +FORBIDDEN = ("rustdesk-org", "https://github.com/rustdesk-org/") +PUBSPEC = ROOT / "flutter/pubspec.yaml" +LOCK = ROOT / "flutter/pubspec.lock" +WORKFLOWS = sorted((ROOT / ".github/workflows").glob("*.y*ml")) +OWNERSHIP = ROOT / "third_party/source-ownership.yaml" +SUBMODULE_SECTION = re.compile(r'^\[submodule "([^"]+)"\]$') +METADATA_NAMES = {".git", ".gitmodules", ".gitignore"} +GENERATED_ARTIFACT_DIRS = { + ".dart_tool", + ".flutter-plugins", + ".gradle", + ".generated", + ".git", + ".run", + "Pods", + "build", + "ephemeral", + "target", +} +REFERENCE_REASON = "source-preserved documentation only" +REFERENCE_FIELDS = {"path", "line", "content", "url", "reason"} +CHECKOUT_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*actions/checkout@([^\s#]+)") +FULL_SHA_RE = re.compile(r"[0-9a-fA-F]{40}") + + +def read_utf8(path: Path) -> str: + """Read policy and source metadata independently of the host locale.""" + return path.read_text(encoding="utf-8") + + +def strip_yaml_comment(text: str) -> str: + """Remove YAML comments without treating quoted URL fragments as comments.""" + quote: str | None = None + escaped = False + for index, character in enumerate(text): + if quote == '"' and escaped: + escaped = False + continue + if quote == '"' and character == "\\": + escaped = True + continue + if character in {"'", '"'}: + if quote is None: + quote = character + elif quote == character: + quote = None + elif character == "#" and quote is None: + return text[:index].rstrip() + return text.rstrip() + + +def is_link_like(path: Path) -> bool: + """Reject symlinks and Windows reparse points without following them.""" + metadata = path.lstat() + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) & reparse_flag + ) + + +def relative_path(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def parse_scalar(value: str) -> object: + value = value.strip() + if not value: + return None + if value == "[]": + return [] + if value in {"true", "false"}: + return value == "true" + if re.fullmatch(r"-?[0-9]+", value): + return int(value) + if value[:1] in {"'", '"'}: + try: + return ast.literal_eval(value) + except (SyntaxError, ValueError) as error: + raise ValueError(f"invalid quoted YAML scalar: {value}") from error + return value + + +def load_policy(path: Path) -> dict: + """Parse the small, indentation-based YAML subset used by source policy.""" + lines: list[tuple[int, str]] = [] + for raw in read_utf8(path).splitlines(): + text = raw.strip() + if not text or text.startswith("#"): + continue + text = strip_yaml_comment(text) + lines.append((len(raw) - len(raw.lstrip()), text)) + + def parse_block(index: int, indent: int) -> tuple[object, int]: + if index >= len(lines) or lines[index][0] != indent: + raise ValueError(f"invalid YAML indentation near line {index + 1}") + sequence = lines[index][1].startswith("-") + result: object = [] if sequence else {} + while index < len(lines) and lines[index][0] == indent: + text = lines[index][1] + if sequence: + if not text.startswith("-") or (len(text) > 1 and text[1] != " "): + raise ValueError(f"mixed YAML sequence/mapping near line {index + 1}") + item_text = text[1:].strip() + if not isinstance(result, list): + raise AssertionError + if ":" not in item_text: + result.append(parse_scalar(item_text)) + index += 1 + continue + key, value = item_text.split(":", 1) + item: dict[str, object] = {key.strip(): parse_scalar(value)} + index += 1 + while index < len(lines) and lines[index][0] > indent: + child_indent, child_text = lines[index] + if child_indent != indent + 2 or ":" not in child_text: + raise ValueError(f"invalid YAML mapping near line {index + 1}") + child_key, child_value = child_text.split(":", 1) + if child_value.strip(): + item[child_key.strip()] = parse_scalar(child_value) + index += 1 + elif index + 1 < len(lines) and lines[index + 1][0] > child_indent: + item[child_key.strip()], index = parse_block(index + 1, lines[index + 1][0]) + else: + item[child_key.strip()] = None + index += 1 + result.append(item) + else: + if not isinstance(result, dict) or ":" not in text: + raise ValueError(f"invalid YAML mapping near line {index + 1}") + key, value = text.split(":", 1) + if value.strip(): + result[key.strip()] = parse_scalar(value) + index += 1 + elif index + 1 < len(lines) and lines[index + 1][0] > indent: + result[key.strip()], index = parse_block(index + 1, lines[index + 1][0]) + else: + result[key.strip()] = None + index += 1 + return result, index + + parsed, end = parse_block(0, lines[0][0]) + if end != len(lines) or not isinstance(parsed, dict): + raise ValueError("ownership manifest must be a YAML mapping") + return parsed + + +def is_reparse_point(path: Path) -> bool: + metadata = path.lstat() + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return bool(getattr(metadata, "st_file_attributes", 0) & reparse_flag) + + +def is_symlink(path: Path) -> bool: + return stat.S_ISLNK(path.lstat().st_mode) + + +def allowed_symlink_records(data: dict) -> dict[Path, str]: + records: dict[Path, str] = {} + entries = data.get("scan_policy", {}).get("allowed_symlinks", []) + if not isinstance(entries, dict): + raise ValueError("scan_policy.allowed_symlinks must be a mapping") + for path_text, target in entries.items(): + if not isinstance(path_text, str) or not isinstance(target, str): + raise ValueError("allowed_symlinks entries need path and target strings") + path = ROOT / path_text + key = path + if key in records: + raise ValueError(f"duplicate allowed symlink: {path_text}") + records[key] = target + return records + + +def allowed_policy_lines(records: dict[tuple[str, int], tuple[str, str]]) -> set[str]: + """Return only the exact YAML scalar lines needed to describe exceptions.""" + return { + f"content: {content!r}" if "'" not in content else f'content: "{content}"' + for content, _ in records.values() + } | {f"url: {url!r}" for _, url in records.values()} + + +def validate_symlink(path: Path, root: Path, allowed: dict[Path, str]) -> tuple[str, str]: + """Validate one declared link and return its literal target and content SHA.""" + if is_reparse_point(path) and not is_symlink(path): + raise ValueError(f"owned root contains a Windows reparse point: {relative_path(path)}") + if not is_symlink(path): + raise ValueError(f"integrity link is not a symlink: {relative_path(path)}") + target_text = os.readlink(path) + if allowed.get(path) != target_text: + raise ValueError(f"owned root contains an undeclared or mismatched symlink: {relative_path(path)}") + target = Path(target_text) + if target.is_absolute(): + raise ValueError(f"symlink target is absolute: {relative_path(path)}") + lexical_target = path.parent + for part in target.parts: + if part == ".": + continue + if part == "..": + lexical_target = lexical_target.parent + continue + lexical_target /= part + try: + lexical_metadata = lexical_target.lstat() + except OSError as error: + raise ValueError(f"symlink target is broken: {relative_path(path)}") from error + if is_link_like(lexical_target): + raise ValueError(f"symlink target chains through a link: {relative_path(path)}") + resolved_target = lexical_target.resolve(strict=False) + try: + resolved_target.relative_to(root.resolve()) + except ValueError as error: + raise ValueError(f"symlink target escapes owned root: {relative_path(path)}") from error + target_metadata = resolved_target.lstat() + if is_link_like(resolved_target) or not stat.S_ISREG(target_metadata.st_mode): + raise ValueError(f"symlink target is not a regular file: {relative_path(path)}") + return target_text, hashlib.sha256(resolved_target.read_bytes()).hexdigest() + + +def owned_entries( + root: Path, + exclusions: set[str], + allowed: dict[Path, str], + pruned_dirs: set[str] | None = None, +) -> list[tuple[Path, str, str | None]]: + """Return regular files and policy-approved links, rejecting all other links.""" + if is_link_like(root) or not stat.S_ISDIR(root.lstat().st_mode): + raise ValueError(f"owned root is not a real directory: {relative_path(root)}") + found: list[tuple[Path, str, str | None]] = [] + pending = [root] + pruned_dirs = pruned_dirs or set() + root_real = root.resolve() + while pending: + current = pending.pop() + try: + children = sorted(current.iterdir(), key=lambda path: path.name) + except OSError as error: + raise ValueError(f"cannot inspect owned root entry {relative_path(current)}: {error}") from error + for path in children: + try: + metadata = path.lstat() + except OSError as error: + raise ValueError(f"cannot inspect owned root entry {relative_path(path)}: {error}") from error + if is_link_like(path): + if not is_symlink(path): + raise ValueError(f"owned root contains a Windows reparse point: {relative_path(path)}") + target_text, target_sha = validate_symlink(path, root, allowed) + found.append((path, "link", f"{target_text}\0{target_sha}")) + continue + try: + path.resolve().relative_to(root_real) + except ValueError as error: + raise ValueError(f"owned root entry escapes its root: {relative_path(path)}") from error + if stat.S_ISDIR(metadata.st_mode): + if path.name in pruned_dirs: + continue + pending.append(path) + elif stat.S_ISREG(metadata.st_mode): + relative = path.relative_to(root).as_posix() + if not any(fnmatch.fnmatch(relative, pattern) for pattern in exclusions): + found.append((path, "file", None)) + else: + raise ValueError(f"owned root contains a non-regular entry: {relative_path(path)}") + return sorted(found, key=lambda entry: entry[0].relative_to(root).as_posix()) + + +def file_digest(path: Path) -> str: + metadata = path.lstat() + if is_link_like(path) or not stat.S_ISREG(metadata.st_mode): + raise ValueError(f"integrity entry is not a regular file: {relative_path(path)}") + content = path.read_bytes() + return hashlib.sha256(content).hexdigest() + + +def tree_integrity(root: Path, exclusions: set[str], allowed: dict[tuple[Path, str], str]) -> tuple[int, int, str]: + records: list[bytes] = [] + byte_count = 0 + for path, kind, link_record in owned_entries(root, exclusions, allowed): + rel = path.relative_to(root).as_posix() + if kind == "file": + digest = file_digest(path) + byte_count += path.stat().st_size + records.append(f"{rel}\0file\0{digest}\n".encode("utf-8")) + else: + assert link_record is not None + records.append(f"{rel}\0link\0{link_record}\n".encode("utf-8")) + return len(records), byte_count, hashlib.sha256(b"".join(records)).hexdigest() + + +def check_integrity(data: dict, failures: list[str]) -> list[tuple[str, tuple[int, int, str]]]: + entries = data["scan_policy"].get("integrity_roots") + if not isinstance(entries, list) or not entries: + failures.append("scan_policy.integrity_roots must be a non-empty list") + return [] + roots: list[Path] = [] + allowed = allowed_symlink_records(data) + exclusions = integrity_exclusions(data, failures) + declared_root_paths = { + ROOT / "third_party" / entry["path"] + for section in (data.get("flutter", []), data.get("windows", [])) + for entry in section + if isinstance(entry, dict) and isinstance(entry.get("path"), str) + } + declared_root_paths.add(ROOT / "third_party" / "hwcodec") + for path in allowed: + if not any(path.is_relative_to(root) for root in declared_root_paths): + failures.append(f"allowed symlink is outside a declared owned root: {relative_path(path)}") + actuals: list[tuple[str, tuple[int, int, str]]] = [] + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + failures.append("integrity_roots entries need a path") + continue + rel = entry["path"] + root = ROOT / "third_party" / rel + if not root.is_dir(): + failures.append(f"integrity root does not exist: third_party/{rel}") + continue + expected = (entry.get("file_count"), entry.get("byte_count"), entry.get("tree_sha256")) + if not isinstance(expected[0], int) or not isinstance(expected[1], int) or not isinstance(expected[2], str): + failures.append(f"integrity root has incomplete metadata: {rel}") + continue + root_exclusions = { + path[len(rel) + 1 :] + for path in exclusions + if path.startswith(rel + "/") + } + try: + actual = tree_integrity(root, root_exclusions, allowed) + except (OSError, ValueError) as error: + failures.append(f"integrity traversal failed for third_party/{rel}: {error}") + roots.append(root) + continue + actuals.append((rel, actual)) + if actual != expected: + failures.append( + f"integrity mismatch for third_party/{rel}: expected files={expected[0]} bytes={expected[1]} " + f"tree_sha256={expected[2]}, got files={actual[0]} bytes={actual[1]} " + f"tree_sha256={actual[2]}" + ) + roots.append(root) + + declared = { + ROOT / "third_party" / entry["path"] + for section in (data.get("flutter", []), data.get("windows", [])) + for entry in section + if isinstance(entry, dict) and isinstance(entry.get("path"), str) + } + declared.add(ROOT / "third_party" / "hwcodec") + expected_roots = { + ROOT / "third_party" / entry["path"] + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("path"), str) + } + if expected_roots != declared: + failures.append("integrity_roots must cover exactly all Flutter, TopMost, and hwcodec owned roots") + return actuals + + +def parse_gitmodules(path: Path) -> list[dict[str, str]]: + declarations: list[dict[str, str]] = [] + current: dict[str, str] | None = None + for line in read_utf8(path).splitlines(): + section = SUBMODULE_SECTION.match(line.strip()) + if section: + if current is not None: + declarations.append(current) + current = {"name": section.group(1)} + elif current is not None and "=" in line: + key, value = line.split("=", 1) + current[key.strip()] = value.strip() + if current is not None: + declarations.append(current) + return declarations + + +def discover_nested_gitmodules(owned_roots: list[Path], failures: list[str]) -> list[Path]: + """Find metadata only inside declared roots, pruning generated artifacts.""" + discovered: set[Path] = set() + pending = sorted(set(owned_roots), key=lambda path: path.as_posix(), reverse=True) + while pending: + current = pending.pop() + if current.name in GENERATED_ARTIFACT_DIRS: + continue + try: + if is_link_like(current): + continue + children = sorted(current.iterdir(), key=lambda path: path.name) + except OSError as error: + failures.append(f"cannot inspect owned metadata root {relative_path(current)}: {error}") + continue + for path in children: + if path.name in GENERATED_ARTIFACT_DIRS and path.is_dir(): + continue + try: + link_like = is_link_like(path) + except OSError as error: + failures.append(f"cannot inspect owned metadata entry {relative_path(path)}: {error}") + continue + if link_like: + continue + if path.name == ".gitmodules" and path.is_file(): + discovered.add(path) + elif path.is_dir(): + pending.append(path) + return sorted(discovered) + + +def repository_gitmodules(paths: list[Path]) -> list[Path]: + """Return the root metadata and deterministic owned nested metadata.""" + root_metadata = ROOT / ".gitmodules" + return ([root_metadata] if root_metadata.is_file() else []) + paths + + +def check_nested_gitmodules(data: dict, failures: list[str], paths: list[Path]) -> None: + entries = data["scan_policy"].get("nested_gitmodules") + if not isinstance(entries, list): + failures.append("scan_policy.nested_gitmodules must be a list") + return + by_path = { + entry.get("path"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("path"), str) + } + discovered = {relative_path(path) for path in paths} + if set(by_path) != discovered: + failures.append(f"nested .gitmodules classification mismatch: {sorted(discovered ^ set(by_path))}") + for path in paths: + entry = by_path.get(relative_path(path)) + if entry is None: + continue + actual = parse_gitmodules(path) + expected = entry.get("declarations") + if not isinstance(expected, list) or len(expected) != len(actual): + failures.append(f"{relative_path(path)}: declaration classification does not match file") + continue + for record, found in zip(expected, actual): + if not isinstance(record, dict): + failures.append(f"{relative_path(path)}: declaration must be a mapping") + continue + target_path = path.parent / found.get("path", "") + try: + target_link_like = is_link_like(target_path) + except FileNotFoundError: + target_link_like = False + except OSError as error: + failures.append(f"{relative_path(path)}: cannot inspect submodule target {found.get('path')}: {error}") + target_link_like = True + target = target_path.resolve() + if path.parent.resolve() not in target.parents: + failures.append(f"{relative_path(path)}: submodule target escapes its repository: {found.get('path')}") + continue + source_files = [] + if target_link_like: + failures.append(f"{relative_path(path)}: submodule target is a symlink/reparse point: {found.get('path')}") + elif target_path.is_dir(): + target_root = target_path.resolve() + for candidate in target_path.rglob("*"): + try: + if is_link_like(candidate): + failures.append(f"{relative_path(path)}: submodule source contains a symlink/reparse point: {candidate}") + continue + candidate.resolve().relative_to(target_root) + except OSError as error: + failures.append(f"{relative_path(path)}: cannot inspect submodule source {candidate}: {error}") + continue + except ValueError: + failures.append(f"{relative_path(path)}: submodule source escapes target: {candidate}") + continue + if ".git" in candidate.parts: + failures.append(f"{relative_path(path)}: nested .git metadata under {found.get('path')}") + mode = candidate.stat().st_mode + if stat.S_ISREG(mode): + if candidate.name not in METADATA_NAMES: + source_files.append(candidate) + elif not stat.S_ISDIR(mode): + failures.append(f"{relative_path(path)}: submodule source is not a regular file or directory: {candidate}") + present = target_path.is_dir() and bool(source_files) and not target_link_like + gitlink_lines = subprocess.run( + ["git", "ls-files", "--stage", "--", relative_path(target)], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ).stdout.splitlines() + if any(line.split()[0] == "160000" for line in gitlink_lines if line.split()): + failures.append(f"{relative_path(path)}: gitlink mode is forbidden for {found.get('path')}") + for key in ("name", "path", "url"): + if record.get(key) != found.get(key): + failures.append(f"{relative_path(path)}: classified {key} does not match") + if record.get("source_present") is not present: + failures.append(f"{relative_path(path)}: source_present is wrong for {found.get('path')}") + active = record.get("active") + if not isinstance(active, bool): + failures.append(f"{relative_path(path)}: active classification must be boolean") + elif active and not present: + failures.append(f"{relative_path(path)}: active submodule target is absent: {found.get('path')}") + elif active and not source_files: + failures.append(f"{relative_path(path)}: active target has no source files: {found.get('path')}") + elif not active and present: + failures.append(f"{relative_path(path)}: inert submodule target must be absent: {found.get('path')}") + for found in actual: + if any(token in found.get("url", "") for token in FORBIDDEN): + failures.append(f"{relative_path(path)}: forbidden URL in nested submodule: {found.get('url')}") + + +def ownership_policy() -> tuple[dict, set[Path], list[Path]]: + data = load_policy(OWNERSHIP) + if not isinstance(data, dict) or not isinstance(data.get("scan_policy"), dict): + raise ValueError("ownership manifest must define a scan_policy mapping") + policy = data["scan_policy"] + + excluded: set[Path] = set() + for entry in policy.get("excluded_manifests", []): + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise ValueError("scan_policy.excluded_manifests entries need a path") + path = ROOT / entry["path"] + if not path.is_file(): + raise ValueError(f"excluded manifest does not exist: {entry['path']}") + excluded.add(path) + + active: list[Path] = [] + for entry in policy.get("active_owned_inputs", []): + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise ValueError("scan_policy.active_owned_inputs entries need a path") + path = ROOT / entry["path"] + if not path.exists(): + raise ValueError(f"active owned input does not exist: {entry['path']}") + reason = entry.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"active owned input needs a reason: {entry['path']}") + active.append(path) + return data, excluded, active + + +def yaml_files(excluded: set[Path]) -> list[Path]: + files = [PUBSPEC, LOCK, OWNERSHIP, *WORKFLOWS] + files.extend(sorted((ROOT / "third_party").glob("**/pubspec.y*ml"))) + files.extend(sorted((ROOT / "third_party").glob("**/pubspec.lock"))) + return list(dict.fromkeys(path for path in files if path.is_file() and path not in excluded)) + + +def active_files(active_roots: list[Path]) -> list[Path]: + files: list[Path] = [] + for root in active_roots: + files.extend(path for path, _, _ in owned_entries(root, set(), {})) + return list(dict.fromkeys(files)) + + +def declared_owned_roots(data: dict) -> list[Path]: + roots: list[Path] = [] + for section in (data.get("flutter", []), data.get("windows", [])): + if not isinstance(section, list): + continue + for entry in section: + if isinstance(entry, dict) and isinstance(entry.get("path"), str): + roots.append(ROOT / "third_party" / entry["path"]) + active_inputs = data.get("scan_policy", {}).get("active_owned_inputs", []) + if isinstance(active_inputs, list): + for entry in active_inputs: + if isinstance(entry, dict) and isinstance(entry.get("path"), str): + roots.append(ROOT / entry["path"]) + return list(dict.fromkeys(roots)) + + +def cargo_owned_roots() -> list[Path]: + """Return every copied Cargo path root without consulting .gitignore.""" + return sorted(path.parent for path in (ROOT / "third_party").glob("*/Cargo.toml") if path.is_file()) + + +def integrity_exclusions(data: dict, failures: list[str]) -> set[str]: + exclusions = data.get("scan_policy", {}).get("integrity_exclusions") + if not isinstance(exclusions, list) or not all(isinstance(pattern, str) for pattern in exclusions): + failures.append("scan_policy.integrity_exclusions must be a list of exact paths") + return set() + configured = set(exclusions) + if configured: + failures.append("integrity_exclusions must be empty; copied source is retained in integrity roots") + for path_text in configured: + path = ROOT / "third_party" / path_text + if path_text != path_text.strip() or any(token in path_text for token in "*?[]"): + failures.append(f"integrity exclusion is broadened: {path_text}") + try: + path.relative_to(ROOT / "third_party" / "flutter/window_size") + except ValueError: + failures.append(f"integrity exclusion is outside window_size: {path_text}") + if path.exists(): + try: + metadata = path.lstat() + text = read_utf8(path) + except (OSError, UnicodeDecodeError) as error: + failures.append(f"integrity exclusion cannot inspect {path_text}: {error}") + continue + if is_link_like(path) or not stat.S_ISREG(metadata.st_mode): + failures.append(f"integrity exclusion is not a regular generated file: {path_text}") + if metadata.st_mode & 0o111: + failures.append(f"integrity exclusion is executable: {path_text}") + if "Generated file" not in text and "Generated file." not in text: + failures.append(f"integrity exclusion is not marked generated: {path_text}") + try: + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", "third_party/" + path_text], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ).returncode == 0 + except OSError as error: + failures.append(f"cannot verify clean-checkout status for {path_text}: {error}") + tracked = True + if tracked: + failures.append(f"integrity exclusion is tracked source input, not clean-checkout-generated: {path_text}") + return configured + + +def check_workflow_checkout_pins(data: dict, failures: list[str]) -> None: + """Require the approved immutable checkout ref in active root workflows only.""" + scan_policy = data.get("scan_policy", {}) + approved_ref = scan_policy.get("active_root_workflow_checkout_sha") + if not isinstance(approved_ref, str) or FULL_SHA_RE.fullmatch(approved_ref) is None: + failures.append( + "scan_policy.active_root_workflow_checkout_sha must be an exact 40-character SHA" + ) + return + for workflow in WORKFLOWS: + for line_number, line in enumerate(read_utf8(workflow).splitlines(), 1): + match = CHECKOUT_RE.match(line) + if match is None: + continue + ref = match.group(1) + if FULL_SHA_RE.fullmatch(ref) is None: + failures.append( + f"{relative_path(workflow)}:{line_number}: actions/checkout must use an exact 40-character SHA" + ) + elif ref != approved_ref: + failures.append( + f"{relative_path(workflow)}:{line_number}: actions/checkout must use the " + f"approved SHA {approved_ref}, got {ref}" + ) + + +def allowed_reference_records(data: dict, declared_roots: list[Path], failures: list[str]) -> dict[tuple[str, int], tuple[str, str]]: + """Validate and index exact comment-only URL exceptions from policy.""" + entries = data.get("scan_policy", {}).get("allowed_reference_urls") + if not isinstance(entries, list): + failures.append("scan_policy.allowed_reference_urls must be a list") + return {} + records: dict[tuple[str, int], tuple[str, str]] = {} + for index, entry in enumerate(entries): + prefix = f"scan_policy.allowed_reference_urls[{index}]" + if not isinstance(entry, dict) or set(entry) != REFERENCE_FIELDS: + failures.append(f"{prefix} must contain exactly path, line, content, url, and reason") + continue + path_text = entry["path"] + line_number = entry["line"] + content = entry["content"] + url = entry["url"] + reason = entry["reason"] + if ( + not isinstance(path_text, str) + or not path_text + or "\\" in path_text + or Path(path_text).is_absolute() + or not isinstance(line_number, int) + or isinstance(line_number, bool) + or line_number < 1 + or not isinstance(content, str) + or not isinstance(url, str) + or not isinstance(reason, str) + ): + failures.append(f"{prefix} has invalid field types or path/line") + continue + path = ROOT / path_text + if not any(path.is_relative_to(root) for root in declared_roots): + failures.append(f"{prefix}.path is outside a declared owned root: {path_text}") + continue + try: + actual_content = read_utf8(path).splitlines() + link_like = is_link_like(path) + except (OSError, ValueError) as error: + failures.append(f"{prefix} cannot inspect {path_text}: {error}") + continue + if link_like or not path.is_file(): + failures.append(f"{prefix}.path must name a regular owned source file: {path_text}") + continue + if line_number > len(actual_content) or actual_content[line_number - 1] != content: + failures.append(f"{prefix} does not match the exact current line in {path_text}:{line_number}") + continue + if not content.lstrip().startswith("//"): + failures.append(f"{prefix} must identify a comment-only source line") + continue + if not any(token in url for token in FORBIDDEN) or content.count(url) != 1: + failures.append(f"{prefix} must bind exactly one forbidden-organization URL") + continue + if any(token in content.replace(url, "") for token in FORBIDDEN): + failures.append(f"{prefix} contains an additional forbidden-organization URL") + continue + if reason != REFERENCE_REASON: + failures.append(f"{prefix}.reason must be exactly {REFERENCE_REASON!r}") + continue + key = (path_text, line_number) + if key in records: + failures.append(f"{prefix} duplicates an exact path/line allowlist record") + continue + records[key] = (content, url) + return records + + +def scan_owned_source( + path: Path, + failures: list[str], + binary_suffixes: set[str], + allowed_references: dict[tuple[str, int], tuple[str, str]], +) -> None: + """Scan UTF-8 text, skipping only explicitly policy-listed binary suffixes.""" + content = path.read_bytes() + is_metadata = ( + path.name in {".gitmodules", "pubspec.yaml", "pubspec.yml", "pubspec.lock"} + or path.name in {"Cargo.toml", "Cargo.lock"} + or ".github" in path.parts and "workflows" in path.parts + ) + if path.suffix.lower() in binary_suffixes and not is_metadata: + return + # Sciter's archived.rc is a binary resource archive despite the source-like + # suffix. It is not a text source file, but still inspect its raw bytes for + # forbidden URLs before accepting the known binary format. + if path.suffix.lower() == ".rc" and content.startswith(b"SAr\0"): + for token in FORBIDDEN: + if token.encode("ascii") in content: + failures.append(f"{relative_path(path)}: forbidden URL in binary resource") + return + try: + text = content.decode("utf-8") + except UnicodeDecodeError as error: + failures.append(f"{relative_path(path)}: invalid UTF-8 source metadata/text: {error}") + return + for line_number, line in enumerate(text.splitlines(), 1): + if any(token in line for token in FORBIDDEN): + reference = allowed_references.get((relative_path(path), line_number)) + if reference is not None and reference[0] == line and not any( + token in line.replace(reference[1], "") for token in FORBIDDEN + ): + continue + failures.append(f"{relative_path(path)}:{line_number}: {line.strip()}") + + +def require_window_pin(data: dict, name: str, expected_ref: str) -> None: + windows = data.get("windows") + if not isinstance(windows, list): + raise ValueError("ownership manifest must define a windows list") + matches = [entry for entry in windows if isinstance(entry, dict) and entry.get("name") == name] + if len(matches) != 1: + raise ValueError(f"expected exactly one windows ownership record named {name!r}") + entry = matches[0] + if entry.get("path") != "windows/RustDeskTempTopMostWindow": + raise ValueError(f"{name} ownership record has unexpected path: {entry.get('path')!r}") + if entry.get("ref") != expected_ref: + raise ValueError(f"{name} ownership record ref is {entry.get('ref')!r}, expected {expected_ref!r}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--require-window-pin", nargs=2, metavar=("NAME", "SHA")) + args = parser.parse_args() + failures: list[str] = [] + try: + data, excluded, active_roots = ownership_policy() + if args.require_window_pin: + require_window_pin(data, *args.require_window_pin) + except (OSError, ValueError) as error: + print(f"Flutter/workflow source ownership check failed: {error}", file=sys.stderr) + return 1 + + owned_roots = declared_owned_roots(data) + owned_roots.extend(path for path in cargo_owned_roots() if path not in owned_roots) + nested_gitmodules = discover_nested_gitmodules(owned_roots, failures) + integrity_results = check_integrity(data, failures) + check_nested_gitmodules(data, failures, nested_gitmodules) + check_workflow_checkout_pins(data, failures) + allowed_references = allowed_reference_records(data, owned_roots, failures) + allowed_policy = allowed_policy_lines(allowed_references) + + manifests = yaml_files(excluded) + for path in manifests: + for line_number, line in enumerate(read_utf8(path).splitlines(), 1): + if any(token in line for token in FORBIDDEN): + if path == OWNERSHIP and line.strip() in allowed_policy: + continue + failures.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}") + + active_exclusions = set(data["scan_policy"].get("integrity_exclusions", [])) + configured_suffixes = data["scan_policy"].get("binary_suffixes") + if not isinstance(configured_suffixes, list) or not all(isinstance(suffix, str) and suffix.startswith(".") for suffix in configured_suffixes): + failures.append("scan_policy.binary_suffixes must be a list of dot-prefixed suffixes") + binary_suffixes: set[str] = set() + else: + binary_suffixes = {suffix.lower() for suffix in configured_suffixes} + allowed = allowed_symlink_records(data) + try: + active_source_files = list(dict.fromkeys( + path + for root in owned_roots + for path, _, _ in owned_entries(root, active_exclusions, allowed, GENERATED_ARTIFACT_DIRS) + )) + except (OSError, ValueError) as error: + failures.append(f"owned source traversal failed: {error}") + active_source_files = [] + for path in active_source_files: + scan_owned_source(path, failures, binary_suffixes, allowed_references) + for path in repository_gitmodules(nested_gitmodules): + for line_number, line in enumerate(read_utf8(path).splitlines(), 1): + if any(token in line for token in FORBIDDEN): + failures.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}") + + if failures: + print("Flutter/workflow source ownership check failed:\n" + "\n".join(failures), file=sys.stderr) + return 1 + excluded_names = ", ".join(sorted(path.relative_to(ROOT).as_posix() for path in excluded)) or "none" + active_names = ", ".join(path.relative_to(ROOT).as_posix() for path in active_roots) or "none" + print( + f"checked {len(manifests)} active Flutter/workflow/third-party manifests and " + f"{len(nested_gitmodules)} nested .gitmodules plus {len(active_source_files)} files under " + f"owned source roots ({active_names}): no forbidden source URLs; " + f"excluded non-build manifests: {excluded_names}" + ) + for path, (file_count, byte_count, digest) in integrity_results: + print(f"integrity {path}: files={file_count} bytes={byte_count} tree_sha256={digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-check-flutter-source-ownership.py b/scripts/test-check-flutter-source-ownership.py new file mode 100644 index 00000000000..dbb61b2ccc0 --- /dev/null +++ b/scripts/test-check-flutter-source-ownership.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Focused tests for active-root workflow checkout pin enforcement.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("check-flutter-source-ownership.py").resolve() +APPROVED_SHA = "3d3c42e5aac5ba805825da76410c181273ba90b1" + + +def load_scanner(): + spec = importlib.util.spec_from_file_location("ownership_scanner", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load scanner: {SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CheckoutPinTests(unittest.TestCase): + def test_current_root_workflows_use_approved_sha(self) -> None: + scanner = load_scanner() + failures: list[str] = [] + scanner.check_workflow_checkout_pins( + {"scan_policy": {"active_root_workflow_checkout_sha": APPROVED_SHA}}, + failures, + ) + self.assertEqual(failures, []) + self.assertTrue(scanner.WORKFLOWS) + self.assertTrue( + all(path.parent == scanner.ROOT / ".github" / "workflows" for path in scanner.WORKFLOWS) + ) + + def test_mismatch_and_tag_fail_but_copied_workflow_is_ignored(self) -> None: + scanner = load_scanner() + original_root = scanner.ROOT + original_workflows = scanner.WORKFLOWS + policy = {"scan_policy": {"active_root_workflow_checkout_sha": APPROVED_SHA}} + try: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + active = root / ".github" / "workflows" / "active.yml" + inactive = root / "third_party" / "copied" / ".github" / "workflows" / "inactive.yml" + active.parent.mkdir(parents=True) + inactive.parent.mkdir(parents=True) + inactive.write_text("uses: actions/checkout@v4\n", encoding="utf-8") + scanner.ROOT = root + scanner.WORKFLOWS = [active] + + active.write_text("uses: actions/checkout@" + "a" * 40 + "\n", encoding="utf-8") + failures: list[str] = [] + scanner.check_workflow_checkout_pins(policy, failures) + self.assertEqual(len(failures), 1) + self.assertIn(APPROVED_SHA, failures[0]) + + active.write_text("uses: actions/checkout@v7\n", encoding="utf-8") + failures = [] + scanner.check_workflow_checkout_pins(policy, failures) + self.assertEqual(len(failures), 1) + self.assertIn("exact 40-character SHA", failures[0]) + finally: + scanner.ROOT = original_root + scanner.WORKFLOWS = original_workflows + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-cargo-vendor.py b/scripts/verify-cargo-vendor.py new file mode 100755 index 00000000000..2697ec4c0a7 --- /dev/null +++ b/scripts/verify-cargo-vendor.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +"""Verify a Cargo vendor directory or archive without network or file writes. + +Directory mode is bound to the recorded ``vendor_tree.tree_sha256``. The digest +is the SHA-256 of sorted records in the form +``vendor/\\0file\\0SHA256(file-bytes)\\n``. This is deliberately the +same file-only digest for a directory and an archive after unpacking, so a +directory cannot be accepted from package counts or mutable Cargo checksums +alone. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import stat +import sys +import tarfile +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_PATH = ROOT / ".cargo" / "config.vendor.toml" +LOCK_PATH = ROOT / "Cargo.lock" +VENDORED_SOURCE = "vendored-sources" +THIRD_PARTY = ROOT / "third_party" +PROVENANCE_PATH = ROOT / "plans" / "upstream-independent-build" / "vendor-provenance.json" +COPIED_PACKAGE_ROOT_COUNT = 44 +SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") + + +class VerificationError(Exception): + """A contract validation failure.""" + + +class VendorFiles: + """Read-only view over a vendor directory or a tar archive.""" + + def __init__(self, path: Path) -> None: + self.path = path + self.archive: tarfile.TarFile | None = None + self.archive_contents: dict[str, bytes] = {} + self.files: set[str] = set() + self.package_roots: set[str] = set() + self.member_count = 0 + if not path.exists() and not path.is_symlink(): + raise VerificationError(f"vendor source does not exist: {path}") + if self._is_link_like(path): + raise VerificationError(f"vendor source root is a symlink/reparse point: {path}") + if path.is_dir(): + self._read_directory() + elif path.is_file(): + try: + self.archive = tarfile.open(path, "r:*") + except (tarfile.TarError, OSError, UnicodeError) as error: + raise VerificationError(f"cannot read vendor archive {path}: {error}") from error + self._read_archive() + else: + raise VerificationError(f"vendor source does not exist: {path}") + + @staticmethod + def _is_link_like(path: Path) -> bool: + metadata = path.lstat() + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) & reparse_flag + ) + + @staticmethod + def _safe_parts(name: str) -> tuple[str, ...]: + if not isinstance(name, str): + raise VerificationError(f"archive member name is not text: {name!r}") + try: + encoded = name.encode("utf-8") + if encoded.decode("utf-8") != name: + raise VerificationError(f"archive member name is not UTF-8 round-tripping: {name!r}") + except UnicodeError as error: + raise VerificationError(f"archive member name is not valid UTF-8: {name!r}") from error + if not name or "\x00" in name or "\\" in name: + raise VerificationError(f"unsafe path in vendor archive: {name!r}") + pure = PurePosixPath(name) + if pure.is_absolute() or any(part in {".", ".."} for part in pure.parts): + raise VerificationError(f"unsafe path in vendor archive: {name}") + return pure.parts + + def _add_member(self, name: str, *, is_file: bool) -> str: + parts = self._safe_parts(name) + if parts == ("vendor",): + if is_file: + raise VerificationError(f"vendor root is not a file: {name}") + return "/".join(parts) + if len(parts) < 2 or parts[0] != "vendor" or not parts[1]: + raise VerificationError(f"archive must contain only vendor/ paths: {name}") + relative = "/".join(parts) + if relative in self.files: + raise VerificationError(f"vendor archive contains duplicate member: {name}") + self.package_roots.add(parts[1]) + if is_file: + self.files.add(relative) + return relative + + def _read_directory(self) -> None: + pending = [self.path] + root = self.path.resolve() + while pending: + current = pending.pop() + for child in sorted(current.iterdir()): + if self._is_link_like(child): + raise VerificationError(f"vendor source contains unsupported link/reparse point: {child}") + child_stat = child.lstat() + if current == self.path and not stat.S_ISDIR(child_stat.st_mode): + raise VerificationError(f"vendor source has unexpected top-level entry: {child.name}") + try: + child.resolve().relative_to(root) + except ValueError as error: + raise VerificationError(f"vendor source entry escapes root: {child}") from error + if current == self.path: + self.package_roots.add(child.name) + if stat.S_ISDIR(child_stat.st_mode): + pending.append(child) + elif stat.S_ISREG(child_stat.st_mode): + relative = child.relative_to(self.path.parent).as_posix() + self._safe_parts(relative) + self.files.add(relative) + else: + raise VerificationError(f"vendor source contains unsupported entry: {child}") + self.member_count = len(self.files) + sum( + 1 + for path in self.path.rglob("*") + if path.is_dir() and not self._is_link_like(path) + ) + + def _read_archive(self) -> None: + assert self.archive is not None + try: + for member in self.archive: + self.member_count += 1 + self._safe_parts(member.name) + if member.isdir(): + self._add_member(member.name.rstrip("/"), is_file=False) + elif member.isfile(): + relative = self._add_member(member.name, is_file=True) + extracted = self.archive.extractfile(member) + if extracted is None: + raise VerificationError(f"vendor archive member is not readable: {member.name}") + self.archive_contents[relative] = extracted.read() + elif member.issym() or member.islnk(): + raise VerificationError(f"archive contains unsupported link: {member.name}") + else: + raise VerificationError(f"archive contains unsupported member: {member.name}") + except VerificationError: + raise + except (OSError, UnicodeError, tarfile.TarError) as error: + raise VerificationError(f"cannot safely read vendor archive member metadata: {error}") from error + + def has(self, relative: str) -> bool: + if self.archive is not None: + return relative in self.files + return (self.path.parent / relative).is_file() + + def read(self, relative: str) -> bytes: + if self.archive is not None: + try: + return self.archive_contents[relative] + except KeyError as error: + raise VerificationError(f"vendor archive member is not a file: {relative}") from error + candidate = (self.path.parent / relative).resolve() + if self.path.parent.resolve() not in candidate.parents: + raise VerificationError(f"unsafe vendor path: {relative}") + return candidate.read_bytes() + + def package_files(self, root: str) -> set[str]: + prefix = f"vendor/{root}/" + return { + relative[len(prefix) :] + for relative in self.files + if relative.startswith(prefix) and relative != prefix + ".cargo-checksum.json" + } + + def tree_sha256(self) -> str: + """Return the deterministic file tree digest shared by dir/archive mode.""" + records = [] + for relative in sorted(self.files): + digest = hashlib.sha256(self.read(relative)).hexdigest() + records.append(f"{relative}\0file\0{digest}\n".encode("utf-8")) + return hashlib.sha256(b"".join(records)).hexdigest() + + def close(self) -> None: + if self.archive is not None: + self.archive.close() + + +def fail(message: str) -> None: + raise VerificationError(message) + + +def package_path(vendor: VendorFiles, name: str, version: str) -> str: + candidates = [name, f"{name}-{version}"] + for candidate in candidates: + if candidate not in vendor.package_roots: + continue + manifest = vendor.read(f"vendor/{candidate}/Cargo.toml") + try: + import tomllib + + package = tomllib.loads(manifest.decode("utf-8")).get("package", {}) + except (UnicodeDecodeError, ValueError) as error: + fail(f"invalid Cargo.toml for vendor package {candidate}: {error}") + if package.get("name") == name and package.get("version") == version: + return candidate + fail(f"missing vendor package directory for {name} {version}") + + +def package_metadata(vendor: VendorFiles, root: str) -> tuple[dict[str, Any], dict[str, Any]]: + prefix = f"vendor/{root}/" + cargo_toml = prefix + "Cargo.toml" + checksum = prefix + ".cargo-checksum.json" + if not vendor.has(cargo_toml): + fail(f"vendor package {root} is missing Cargo.toml") + if not vendor.has(checksum): + fail(f"vendor package {root} is missing .cargo-checksum.json") + try: + import tomllib + + manifest = tomllib.loads(vendor.read(cargo_toml).decode("utf-8")) + checksum_data = json.loads(vendor.read(checksum).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: + fail(f"invalid metadata for vendor package {root}: {error}") + if not isinstance(checksum_data, dict) or not isinstance(checksum_data.get("files"), dict): + fail(f"checksum metadata for {root} must contain a files object") + return manifest, checksum_data + + +def verify_package_files(vendor: VendorFiles, root: str, checksum_data: dict[str, Any]) -> None: + files = checksum_data.get("files") + if not isinstance(files, dict): + fail(f"checksum metadata for {root} must contain a files object") + actual = vendor.package_files(root) + expected: set[str] = set() + for relative, digest in files.items(): + if not isinstance(relative, str) or not relative or "\\" in relative: + fail(f"checksum metadata for {root} contains an unsafe file name: {relative!r}") + parts = PurePosixPath(relative).parts + if PurePosixPath(relative).is_absolute() or ".." in parts or parts == (".",): + fail(f"checksum metadata for {root} contains an unsafe file name: {relative!r}") + if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: + fail(f"checksum metadata for {root} contains an invalid SHA-256 for {relative}") + expected.add(relative) + if relative not in actual: + fail(f"vendor package {root} is missing checksum-listed file: {relative}") + actual_digest = hashlib.sha256(vendor.read(f"vendor/{root}/{relative}")).hexdigest() + if actual_digest != digest: + fail(f"vendor package {root} checksum mismatch for {relative}") + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing or extra: + fail(f"vendor package {root} checksum file inventory mismatch; missing={missing[:10]}, extra={extra[:10]}") + + +def copied_packages(lock: dict[str, Any]) -> list[tuple[dict[str, Any], str]]: + """Return the tracked package roots copied from the former git sources.""" + path_packages = { + (package["name"], package["version"]): package + for package in lock.get("package", []) + if "source" not in package + } + copied: list[tuple[dict[str, Any], str]] = [] + if not THIRD_PARTY.is_dir(): + fail(f"copied package root directory is missing: {THIRD_PARTY}") + for manifest_path in sorted(THIRD_PARTY.glob("*/Cargo.toml")): + if VendorFiles._is_link_like(manifest_path.parent): + fail(f"copied package root is a symlink/reparse point: {manifest_path.parent}") + try: + import tomllib + + metadata = tomllib.loads(manifest_path.read_text(encoding="utf-8")).get("package", {}) + except (OSError, ValueError) as error: + fail(f"cannot parse copied package manifest {manifest_path}: {error}") + identity = (metadata.get("name"), metadata.get("version")) + package = path_packages.get(identity) + if package is None: + fail(f"copied package {manifest_path.parent.name} is not a path package in Cargo.lock") + copied.append((package, manifest_path.parent.name)) + return copied + + +def copied_root_contract( + provenance: dict[str, Any], copied: list[tuple[dict[str, Any], str]] +) -> tuple[dict[str, list[str]], set[str]]: + """Load the explicit local 44-root contract and its documented omissions.""" + contract = provenance.get("copied_root_contract") + if not isinstance(contract, dict): + fail("vendor provenance has no copied_root_contract") + roots = contract.get("roots") + if not isinstance(roots, list) or not all(isinstance(root, str) and root for root in roots): + fail("copied_root_contract.roots must be a list of root names") + if len(roots) != COPIED_PACKAGE_ROOT_COUNT or len(set(roots)) != len(roots): + fail(f"copied_root_contract must contain exactly {COPIED_PACKAGE_ROOT_COUNT} unique roots") + actual = {root for _, root in copied} + if set(roots) != actual: + fail(f"copied-root contract mismatch; missing={sorted(set(roots) - actual)}, extra={sorted(actual - set(roots))}") + omissions = contract.get("allowed_omissions", {}) + if not isinstance(omissions, dict): + fail("copied_root_contract.allowed_omissions must be a mapping") + normalized: dict[str, list[str]] = {} + for root, paths in omissions.items(): + if root not in actual or not isinstance(paths, list) or not all(isinstance(path, str) for path in paths): + fail(f"invalid copied-root omission contract for {root!r}") + normalized[root] = paths + rewrites = contract.get("manifest_rewrites", []) + if not isinstance(rewrites, list) or not all(isinstance(root, str) for root in rewrites): + fail("copied_root_contract.manifest_rewrites must be a list of root names") + if not set(rewrites) <= actual: + fail(f"manifest rewrite contract names unknown roots: {sorted(set(rewrites) - actual)}") + return normalized, set(rewrites) + + +def verify_copied_root( + vendor: VendorFiles, + copied_root: str, + external_root: str, + omissions: list[str], + manifest_rewritten: bool, +) -> None: + """Compare a copied path package with its external vendor package. + + Cargo.toml is the one intentionally rewritten file: path-owned family + members cannot retain their upstream git/workspace relationships. Every + other file must have identical bytes and inventory, except for the exact + cleanup paths recorded in vendor provenance. + """ + local_root = THIRD_PARTY / copied_root + if VendorFiles._is_link_like(local_root): + fail(f"copied package root is a symlink/reparse point: {local_root}") + local_files: set[str] = set() + for path in local_root.rglob("*"): + if VendorFiles._is_link_like(path): + fail(f"copied package {copied_root} contains a symlink/reparse point: {path}") + if path.is_file(): + local_files.add(path.relative_to(local_root).as_posix()) + local_files.discard(".cargo-checksum.json") + external_files = vendor.package_files(external_root) + omission_set = set(omissions) + if len(omission_set) != len(omissions): + fail(f"copied-root omission contract repeats a path for {copied_root}") + unknown_omissions = sorted(omission_set - external_files) + if unknown_omissions: + fail(f"copied-root omission contract names absent external files for {copied_root}: {unknown_omissions}") + expected = external_files - omission_set + missing = sorted(expected - local_files) + extra = sorted(local_files - expected) + if missing or extra: + fail(f"copied package {copied_root} file inventory mismatch; missing={missing[:10]}, extra={extra[:10]}") + external_manifest = vendor.read(f"vendor/{external_root}/Cargo.toml") + local_manifest = (local_root / "Cargo.toml").read_bytes() + if local_manifest != external_manifest and not manifest_rewritten: + fail(f"copied package {copied_root} Cargo.toml differs without a contract entry") + if local_manifest == external_manifest and manifest_rewritten: + fail(f"copied package {copied_root} is listed as rewritten but Cargo.toml is unchanged") + external_checksum = f"vendor/{external_root}/.cargo-checksum.json" + checksum_data: dict[str, Any] | None = None + if vendor.has(external_checksum): + try: + loaded = json.loads(vendor.read(external_checksum).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"invalid checksum metadata for external package {external_root}: {error}") + if not isinstance(loaded, dict) or not isinstance(loaded.get("files"), dict): + fail(f"checksum metadata for external package {external_root} must contain a files object") + checksum_data = loaded + for relative in sorted(expected): + if relative == "Cargo.toml": + continue + local = (local_root / relative).read_bytes() + external = vendor.read(f"vendor/{external_root}/{relative}") + if local != external: + fail(f"copied package {copied_root} content mismatch for {relative}") + if checksum_data is not None: + expected_digest = checksum_data["files"].get(relative) + if not isinstance(expected_digest, str) or SHA256.fullmatch(expected_digest) is None: + fail(f"external checksum metadata for {external_root} has no valid SHA-256 for {relative}") + if hashlib.sha256(local).hexdigest() != expected_digest: + fail(f"copied package {copied_root} checksum mismatch for {relative}") + + +def read_toml(path: Path) -> dict[str, Any]: + try: + import tomllib + + return tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, ValueError) as error: + fail(f"cannot parse {path}: {error}") + + +def load_lock() -> dict[str, Any]: + return read_toml(LOCK_PATH) + + +def load_provenance() -> dict[str, Any]: + try: + data = json.loads(PROVENANCE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot parse vendor provenance {PROVENANCE_PATH}: {error}") + if not isinstance(data, dict): + fail("vendor provenance must be a JSON object") + return data + + +def verify_provenance(lock: dict[str, Any], vendor: VendorFiles) -> None: + provenance = load_provenance() + if hashlib.sha256(LOCK_PATH.read_bytes()).hexdigest() != provenance.get("cargo_lock_sha256"): + fail("Cargo.lock SHA-256 does not match vendor provenance") + if hashlib.sha256((ROOT / "Cargo.toml").read_bytes()).hexdigest() != provenance.get("cargo_toml_sha256"): + fail("Cargo.toml SHA-256 does not match vendor provenance") + copied = copied_packages(lock) + copied_count = len(copied) + if copied_count != provenance.get("copied_package_root_count"): + fail("copied package root count does not match vendor provenance") + active_git_count = sum( + 1 for package in lock.get("package", []) if package.get("source", "").startswith("git+") + ) + if active_git_count != provenance.get("active_git_source_count"): + fail("active git source count does not match vendor provenance") + omissions, manifest_rewrites = copied_root_contract(provenance, copied) + for package, copied_root in copied: + external_root = package_path(vendor, package["name"], package["version"]) + verify_copied_root( + vendor, + copied_root, + external_root, + omissions.get(copied_root, []), + copied_root in manifest_rewrites, + ) + if vendor.archive is not None: + expected = provenance.get("vendor_archive") + if not isinstance(expected, dict): + fail("vendor provenance has no vendor_archive record") + archive_sha = hashlib.sha256(vendor.path.read_bytes()).hexdigest() + if archive_sha != expected.get("sha256"): + fail("vendor archive SHA-256 does not match vendor provenance") + for key, actual in (("file_count", len(vendor.files)), ("package_count", len(vendor.package_roots)), ("member_count", vendor.member_count)): + if actual != expected.get(key): + fail(f"vendor archive {key} does not match provenance: expected {expected.get(key)}, got {actual}") + else: + expected = provenance.get("vendor_tree") + if not isinstance(expected, dict): + fail("vendor provenance has no vendor_tree record") + tree_sha = expected.get("tree_sha256") + if not isinstance(tree_sha, str) or SHA256.fullmatch(tree_sha) is None: + fail("vendor tree provenance has no valid tree_sha256 record") + actual_tree_sha = vendor.tree_sha256() + if actual_tree_sha != tree_sha: + fail(f"vendor tree tree_sha256 does not match provenance: expected {tree_sha}, got {actual_tree_sha}") + for key, actual in (("file_count", len(vendor.files)), ("package_count", len(vendor.package_roots))): + if actual != expected.get(key): + fail(f"vendor tree {key} does not match provenance: expected {expected.get(key)}, got {actual}") + + +def verify_config(lock: dict[str, Any]) -> None: + config = read_toml(CONFIG_PATH) + source_table = config.get("source") + if not isinstance(source_table, dict): + fail(f"{CONFIG_PATH} has no [source] table") + + locked_sources = { + package["source"] + for package in lock.get("package", []) + if package.get("source") + } + git_sources = sorted(source for source in locked_sources if source.startswith("git+")) + if git_sources: + fail(f"Cargo.lock still contains active git sources: {git_sources[:20]}") + unsupported_sources = sorted( + source for source in locked_sources if not source.startswith("registry+") + ) + if unsupported_sources: + fail(f"Cargo.lock contains unsupported external sources: {unsupported_sources[:20]}") + expected_sources = {"crates-io", VENDORED_SOURCE} + actual_sources = set(source_table) + if actual_sources != expected_sources: + missing = sorted(expected_sources - actual_sources) + extra = sorted(actual_sources - expected_sources) + fail(f"source mapping mismatch; missing={missing}, extra={extra}") + + for source in sorted(expected_sources - {VENDORED_SOURCE}): + definition = source_table[source] + if definition.get("replace-with") != VENDORED_SOURCE: + fail(f"source {source!r} is not replaced by {VENDORED_SOURCE!r}") + + vendor_definition = source_table[VENDORED_SOURCE] + directory = vendor_definition.get("directory") + if not isinstance(directory, str) or not directory or Path(directory).is_absolute(): + fail("vendored-sources.directory must be a non-empty relative path") + if directory != "vendor": + fail(f"vendored-sources.directory must be exactly 'vendor', got {directory!r}") + if "replace-with" in vendor_definition: + fail("vendored-sources must be the terminal source and cannot replace another source") + if config.get("net", {}).get("offline") is not True: + fail("[net].offline must be true") + + +def verify_packages(lock: dict[str, Any], vendor: VendorFiles) -> tuple[int, int]: + registry = [ + package + for package in lock.get("package", []) + if package.get("source", "").startswith("registry+") + ] + expected_roots: set[str] = set() + for package in registry: + name = package["name"] + version = package["version"] + root = package_path(vendor, name, version) + expected_roots.add(root) + manifest, checksum_data = package_metadata(vendor, root) + metadata = manifest.get("package", {}) + if metadata.get("name") != name or metadata.get("version") != version: + fail(f"vendor package {root} metadata does not match lock package {name} {version}") + if checksum_data.get("package") != package.get("checksum"): + fail(f"registry checksum mismatch for {name} {version}") + verify_package_files(vendor, root, checksum_data) + + copied = copied_packages(lock) + if len(copied) != COPIED_PACKAGE_ROOT_COUNT: + fail( + f"expected {COPIED_PACKAGE_ROOT_COUNT} copied package roots in {THIRD_PARTY}, " + f"found {len(copied)}" + ) + for package, root in copied: + expected_roots.add(root) + manifest, checksum_data = package_metadata(vendor, root) + metadata = manifest.get("package", {}) + if metadata.get("name") != package["name"] or metadata.get("version") != package["version"]: + fail(f"copied package {root} metadata does not match lock package {package['name']} {package['version']}") + verify_package_files(vendor, root, checksum_data) + + unexpected = sorted(vendor.package_roots - expected_roots) + if unexpected: + fail(f"vendor contains unknown/extra package directories: {unexpected[:20]}") + return len(registry), len(copied) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("vendor", type=Path, help="vendor directory or .tar/.tar.gz archive") + args = parser.parse_args() + if not CONFIG_PATH.is_file(): + fail(f"expected vendor config is missing: {CONFIG_PATH}") + if not LOCK_PATH.is_file(): + fail(f"Cargo.lock is missing: {LOCK_PATH}") + + lock = load_lock() + verify_config(lock) + vendor = VendorFiles(args.vendor) + try: + verify_provenance(lock, vendor) + registry_count, copied_count = verify_packages(lock, vendor) + finally: + vendor.close() + print( + f"verified {registry_count} Cargo.lock registry packages/checksums and " + f"{copied_count} copied package roots from {args.vendor}" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except VerificationError as error: + print(f"vendor verification failed: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/third_party/android-wakelock/.cargo-checksum.json b/third_party/android-wakelock/.cargo-checksum.json new file mode 100644 index 00000000000..0f25964d362 --- /dev/null +++ b/third_party/android-wakelock/.cargo-checksum.json @@ -0,0 +1 @@ +{"files":{".github/workflows/ci.yml":"700616525ab86f7e39cf87591934698d06817a93db056570c119184ef1959b75","Cargo.toml":"30f7b7744ca9ede171e2b63614c01c49f99d4619f6b6dd2a04d29d93f3b9f514","LICENSE":"bf2c3b4a63d18febeb8fa818728de499fd433e346282ac3f9b26253f0fc0d002","README.md":"083cb47167249102f725d408e65c0a89e2f5daa9a35c22b0122da56d13ffeb84","rust-toolchain.toml":"20fc789ac00f422c8cfdad2b1d14fa58049e24408d6336dfb28d632ca6f665c3","src/lib.rs":"2e8ffde862bbe614f067268705af5ec593ee939fb2b7bed0edab14c0b960a9e2"},"package":null} \ No newline at end of file diff --git a/third_party/android-wakelock/.github/workflows/ci.yml b/third_party/android-wakelock/.github/workflows/ci.yml new file mode 100644 index 00000000000..cad925494de --- /dev/null +++ b/third_party/android-wakelock/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: ci +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: cargo test diff --git a/third_party/android-wakelock/Cargo.toml b/third_party/android-wakelock/Cargo.toml new file mode 100644 index 00000000000..9f3928e93b5 --- /dev/null +++ b/third_party/android-wakelock/Cargo.toml @@ -0,0 +1,37 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +name = "android-wakelock" +version = "0.1.0" +authors = ["Stephen M. Coakley "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Safe and ergonomic Rust bindings to the Android WakeLock API" +readme = "README.md" +keywords = ["android"] +categories = ["api-bindings"] +license = "MIT" +repository = "https://github.com/sagebind/android-wakelock" + +[lib] +name = "android_wakelock" +path = "src/lib.rs" + +[dependencies] +jni = "0.21" +log = "0.4" +ndk-context = "0.1" diff --git a/third_party/android-wakelock/LICENSE b/third_party/android-wakelock/LICENSE new file mode 100644 index 00000000000..7e4a7ae5a53 --- /dev/null +++ b/third_party/android-wakelock/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Stephen M. Coakley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/android-wakelock/README.md b/third_party/android-wakelock/README.md new file mode 100644 index 00000000000..f3a916ddb09 --- /dev/null +++ b/third_party/android-wakelock/README.md @@ -0,0 +1,18 @@ +# Android WakeLock + +[![Crates.io](https://img.shields.io/crates/v/android-wakelock.svg)](https://crates.io/crates/android-wakelock) +[![Documentation](https://docs.rs/android-wakelock/badge.svg)](https://docs.rs/android-wakelock) +![License](https://img.shields.io/badge/license-MIT-blue.svg) + +Safe and ergonomic Rust bindings to the [Android WakeLock API](https://developer.android.com/reference/android/os/PowerManager#newWakeLock(int,%20java.lang.String)). Wake locks allow an app or service to keep an Android device's display or processor awake in order to complete some work. For more information about wake locks, see the official [Android guide](https://developer.android.com/training/scheduling/wakelock). + +## [Documentation] + +Check the [documentation] for up-to-date usage and examples. + +## License + +This library is licensed under the MIT license. See the [LICENSE](LICENSE) file for details. + + +[Documentation]: https://docs.rs/android-wakelock diff --git a/third_party/android-wakelock/rust-toolchain.toml b/third_party/android-wakelock/rust-toolchain.toml new file mode 100644 index 00000000000..cfe2998aa1c --- /dev/null +++ b/third_party/android-wakelock/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +targets = ["armv7-linux-androideabi", "aarch64-linux-android", "i686-linux-android", "x86_64-linux-android"] diff --git a/third_party/android-wakelock/src/lib.rs b/third_party/android-wakelock/src/lib.rs new file mode 100644 index 00000000000..560d32ac20b --- /dev/null +++ b/third_party/android-wakelock/src/lib.rs @@ -0,0 +1,417 @@ +//! Safe and ergonomic Rust bindings to the [Android WakeLock +//! API](https://developer.android.com/reference/android/os/PowerManager#newWakeLock(int,%20java.lang.String)). +//! Wake locks allow an app or service to keep an Android device's display or +//! processor awake in order to complete some work. For more information about +//! wake locks, see the official [Android +//! guide](https://developer.android.com/training/scheduling/wakelock). +//! +//! In short: **device battery life may be significantly affected by the use of +//! this API**. Do not acquire `WakeLock`s unless you really need them, use the +//! minimum levels possible, and be sure to release them as soon as possible. +//! +//! # Platform support +//! +//! This library should work with all Android API levels. It cannot be used on any +//! other operating system, of course. +//! +//! # Creating wake locks +//! +//! The simplest way to create a wake lock is to use the [`partial`] function, +//! which creates a [partial][`Level::Partial`] wake lock configured with +//! reasonable defaults. This is the lowest level of wake lock, and is the most +//! friendly to battery life while still keeping the device awake to perform +//! computation. +//! +//! If you want to create a wake lock with a different level or with different +//! flags, you can use [`WakeLock::builder`] to create a [`Builder`] that +//! provides methods for setting other supported wake lock options. +//! +//! Creating a wake lock from Rust is a somewhat expensive operation, so it is +//! better to create your wake locks up front and reuse them during your app's +//! runtime as needed instead of creating them on-demand. +//! +//! # Acquiring and releasing wake locks +//! +//! Wake locks remain dormant until they are acquired. To acquire a wake lock, +//! call [`acquire`][WakeLock::acquire] on the wake lock. This will return a +//! guard object that will keep the wake lock acquired until it is dropped: +//! +//! ```no_run +//! // Create the wake lock. +//! let wake_lock = android_wakelock::partial("myapp:mytag")?; +//! +//! // Start keeping the device awake. +//! let guard = wake_lock.acquire()?; +//! +//! // Do some work while the device is awake... +//! +//! // Release the wake lock to allow the device to sleep again. +//! drop(guard); +//! +//! # Ok::<(), Box>(()) +//! ``` +//! +//! Multiple threads can share the same wake lock and acquire it concurrently. As +//! long as at least one thread has acquired the wake lock, the device will be +//! kept awake. +//! +//! ```no_run +//! use std::{sync::Arc, thread}; +//! +//! // Create the wake lock. +//! let wake_lock = Arc::new(android_wakelock::partial("myapp:mytag")?); +//! let wake_lock_clone = wake_lock.clone(); +//! +//! // Spawn multiple threads that use the same wake lock to keep the device awake +//! // while they do some work. +//! let worker1 = thread::spawn(move || { +//! // Keep the device awake while this worker runs. +//! let _guard = wake_lock_clone.acquire().unwrap(); +//! +//! // Do some work... +//! }); +//! let worker2 = thread::spawn(move || { +//! // Keep the device awake while this worker runs. +//! let _guard = wake_lock.acquire().unwrap(); +//! +//! // Some more work... +//! }); +//! +//! worker1.join().unwrap(); +//! worker2.join().unwrap(); +//! +//! # Ok::<(), Box>(()) +//! ``` + +#![warn( + future_incompatible, + missing_debug_implementations, + missing_docs, + unreachable_pub, + unused, + clippy::all +)] + +use jni::{ + objects::{GlobalRef, JObject, JValue}, + JavaVM, +}; + +const ACQUIRE_CAUSES_WAKEUP: i32 = 0x10000000; +const ON_AFTER_RELEASE: i32 = 0x20000000; + +/// An error returned by the wake lock API. A variety of errors can occur when +/// calling Android APIs, such as JNI errors, or exceptions actually thrown by the +/// API itself. +pub type Error = Box; + +type Result = std::result::Result; + +/// Create a new partial wake lock with the given tag. +/// +/// This convenience function is equivalent to the following: +/// +/// ```no_run +/// use android_wakelock::{Level, WakeLock}; +/// +/// # let tag = "myapp:mytag"; +/// WakeLock::builder(tag) +/// .level(Level::Partial) +/// .build(); +/// ``` +pub fn partial>(tag: T) -> Result { + WakeLock::builder(tag).build() +} + +/// Possible levels for a wake lock. +#[repr(i32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum Level { + /// Ensures that the CPU is running; the screen and keyboard backlight will + /// be allowed to go off. + /// + /// If the user presses the power button, then the screen will be turned off + /// but the CPU will be kept on until all partial wake locks have been + /// released. + Partial = 0x00000001, + + /// Ensures that the screen and keyboard backlight are on at full + /// brightness. + /// + /// If the user presses the power button, then the wake lock will be + /// implicitly released by the system, causing both the screen and the CPU + /// to be turned off. Contrast with [`Level::Partial`]. + /// + /// # Deprecation + /// + /// **This constant was deprecated in API level 17.** Most applications + /// should use `WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON` instead of + /// this type of wake lock, as it will be correctly managed by the platform + /// as the user moves between applications and doesn't require a special + /// permission. + #[deprecated] + Full = 0x0000001a, + + /// Ensures that the screen is on at full brightness; the keyboard backlight + /// will be allowed to go off. + /// + /// If the user presses the power button, then the wake lock will be + /// implicitly released by the system, causing both the screen and the CPU + /// to be turned off. Contrast with [`Level::Partial`]. + /// + /// # Deprecation + /// + /// **This constant was deprecated in API level 15.** Most applications + /// should use `WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON` instead of + /// this type of wake lock, as it will be correctly managed by the platform + /// as the user moves between applications and doesn't require a special + /// permission. + #[deprecated] + ScreenBright = 0x0000000a, + + /// Wake lock level: Ensures that the screen is on (but may be dimmed); the + /// keyboard backlight will be allowed to go off. + /// + /// If the user presses the power button, then the wake lock will be + /// implicitly released by the system, causing both the screen and the CPU + /// to be turned off. Contrast with [`Level::Partial`]. + /// + /// # Deprecation + /// + /// **This constant was deprecated in API level 17.** Most applications + /// should use `WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON` instead of + /// this type of wake lock, as it will be correctly managed by the platform + /// as the user moves between applications and doesn't require a special + /// permission. + #[deprecated] + ScreenDim = 0x00000006, +} + +/// A builder for configuring and creating a wake lock. +#[derive(Clone, Debug)] +pub struct Builder { + tag: String, + level: Level, + acquire_causes_wakeup: bool, + on_after_release: bool, +} + +impl Builder { + /// Set the wake lock level. + /// + /// Generally [`Level::Partial`] wake locks are preferred, and is the + /// default level if not specified. See [`Level`] for more information about + /// the different available wake lock levels. + pub fn level(mut self, level: Level) -> Self { + self.level = level; + self + } + + /// Turn the screen on when the wake lock is acquired. + /// + /// This flag requires `Manifest.permission.TURN_SCREEN_ON` for apps + /// targeting Android version `Build.VERSION_CODES#UPSIDE_DOWN_CAKE` and + /// higher. + /// + /// Normally wake locks don't actually wake the device, they just cause the + /// screen to remain on once it's already on. This flag will cause the + /// device to wake up when the wake lock is acquired. + /// + /// Android TV playback devices attempt to turn on the HDMI-connected TV via + /// HDMI-CEC on any wake-up, including wake-ups triggered by wake locks. + /// + /// Cannot be used with [`Level::Partial`]. + /// + /// # Deprecation + /// + /// **This option was deprecated in API level 33.** Most applications should + /// use `R.attr.turnScreenOn` or `Activity.setTurnScreenOn(boolean)` + /// instead, as this prevents the previous foreground app from being resumed + /// first when the screen turns on. + #[deprecated] + pub fn acquire_causes_wakeup(mut self, acquire_causes_wakeup: bool) -> Self { + self.acquire_causes_wakeup = acquire_causes_wakeup; + self + } + + /// When this wake lock is released, poke the user activity timer so the + /// screen stays on for a little longer. + /// + /// This will not turn the screen on if it is not already on. + /// + /// Cannot be used with [`Level::Partial`]. + pub fn on_after_release(mut self, on_after_release: bool) -> Self { + self.on_after_release = on_after_release; + self + } + + /// Creates a new wake lock with the specified level and options. + pub fn build(&self) -> Result { + let ctx = ndk_context::android_context(); + let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()) }?; + let mut env = vm.attach_current_thread()?; + + // Fetch the PowerManager system service. + let power_manager_service_id = env.new_string("power")?; + let power_manager = catch_exceptions(&mut env, |env| { + env.call_method( + unsafe { JObject::from_raw(ctx.context().cast()) }, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::from(&power_manager_service_id)], + )? + .l() + })?; + + let name = env.new_string(&self.tag)?; + let mut flags = self.level as i32; + + if self.acquire_causes_wakeup { + flags |= ACQUIRE_CAUSES_WAKEUP; + } + + if self.on_after_release { + flags |= ON_AFTER_RELEASE; + } + + // Create the wake lock. + let result = catch_exceptions(&mut env, |env| { + env.call_method( + &power_manager, + "newWakeLock", + "(ILjava/lang/String;)Landroid/os/PowerManager$WakeLock;", + &[JValue::from(flags), JValue::from(&name)], + ) + })?; + + let wake_lock = env.new_global_ref(result.l()?)?; + + catch_exceptions(&mut env, |env| { + env.call_method(&wake_lock, "acquire", "()V", &[]) + })?; + + log::debug!("acquired wake lock \"{}\"", self.tag); + + drop(env); + + Ok(WakeLock { + wake_lock, + vm, + tag: self.tag.clone(), + }) + } +} + +/// A wake lock is a mechanism to indicate that your application needs to have +/// the device stay on. +/// +/// To obtain a wake lock, you can use [`WakeLock::builder`] to configure and +/// create a wake lock, or you can use [`partial`] to create a partial wake +/// lock configured with reasonable defaults. +/// +/// Any application using a `WakeLock` must request the +/// `android.permission.WAKE_LOCK` permission in an `` element +/// of the application's manifest. +#[derive(Debug)] +pub struct WakeLock { + /// Reference to the underlying Java object. + wake_lock: GlobalRef, + + /// The JVM the object belongs to. + vm: JavaVM, + + /// The tag specified when the wake lock was created. + tag: String, +} + +impl WakeLock { + /// Create a new builder with the given tag for configuring and creating a + /// wake lock. + /// + /// # Tags + /// + /// Your class name (or other tag) for debugging purposes. Recommended + /// naming conventions for tags to make debugging easier: + /// + /// - use a unique prefix delimited by a colon for your app/library (e.g. + /// `gmail:mytag`) to make it easier to understand where the wake locks + /// comes from. This namespace will also avoid collision for tags inside + /// your app coming from different libraries which will make debugging + /// easier. + /// - use constants (e.g. do not include timestamps in the tag) to make it + /// easier for tools to aggregate similar wake locks. When collecting + /// debugging data, the platform only monitors a finite number of tags, + /// using constants will help tools to provide better debugging data. + /// - avoid wrapping the tag or a prefix to avoid collision with wake lock + /// tags from the platform (e.g. `*alarm*`). + /// - never include personally identifiable information for privacy reasons. + pub fn builder>(tag: T) -> Builder { + Builder { + tag: tag.into(), + level: Level::Partial, + acquire_causes_wakeup: false, + on_after_release: false, + } + } + + /// Returns true if the wake lock has outstanding references not yet + /// released. + pub fn is_held(&self) -> Result { + let mut env = self.vm.attach_current_thread()?; + + catch_exceptions(&mut env, |env| { + env.call_method(&self.wake_lock, "isHeld", "()Z", &[])?.z() + }) + } +} + +impl Drop for WakeLock { + fn drop(&mut self) { + match self.vm.attach_current_thread() { + Ok(mut env) => { + if let Err(_e) = catch_exceptions(&mut env, |env| { + env.call_method(&self.wake_lock, "release", "()V", &[])?; + + // log::debug!("released wake lock \"{}\"", self.tag); + + Ok(()) + }) { + // log::error!("release wake lock failed: {e:?}"); + } + } + + Err(_e) => { + // log::error!("get env failed when release wake lock: {e:?}"); + } + } + } +} + +/// Helper for handling Java exceptions thrown when entering Java code that turns +/// thrown exceptions into formatted Rust errors. +#[inline] +fn catch_exceptions<'a, T, F>(env: &mut jni::JNIEnv<'a>, f: F) -> Result +where + F: FnOnce(&mut jni::JNIEnv<'a>) -> jni::errors::Result, +{ + match f(env) { + Ok(value) => Ok(value), + Err(e @ jni::errors::Error::JavaException) => Err({ + if let Ok(exception) = env.exception_occurred() { + let _ = env.exception_clear(); + + env.call_method(exception, "getMessage", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .and_then(|message| { + env.get_string(&message.into()) + .map(|s| s.to_string_lossy().into_owned()) + }) + .map(|message| message.into()) + .unwrap_or_else(|_| e.into()) + } else { + e.into() + } + }), + Err(e) => Err(e.into()), + } +} diff --git a/third_party/arboard/.cargo-checksum.json b/third_party/arboard/.cargo-checksum.json new file mode 100644 index 00000000000..17a028cadaf --- /dev/null +++ b/third_party/arboard/.cargo-checksum.json @@ -0,0 +1 @@ +{"files":{".github/workflows/test.yml":"1872b62a5048af1705427bf586647f1ebbb952ed2de12d058d140e075d38be03","CHANGELOG.md":"fa2ed3e24375dd244b7472ccdc1a168d0961ded5f672b29eab02251e728d42ef","Cargo.lock":"bd3eff005f4561fd33df652acccb844fbb1239078461b180d471fb8999bda1c7","Cargo.toml":"0d721de42102a924ee27852015938a398b08fedfa93f7a033f2ab95d1dbd3ef5","LICENSE-APACHE.txt":"0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594","LICENSE-MIT.txt":"f35468024534e0f4f16bb94cd0e88c87fbd1314f919c2645a07d350fb77b40c0","README.md":"aea6a3de574f7bcc47175d68cb64d8261666a88f26fcac05367572a6fa0bfe6b","examples/daemonize.rs":"be9cd22867387c8f51597c80b7cb46245da789e2ae7783c2a4841a910f08381d","examples/get_formats.rs":"c4a8d6f9b29042ff7a21efaf73637104442236f106e0ae44a872381dc51210fa","examples/get_image.rs":"6117d9af5a3da432c99519b21d6d640f3ae5705cfd3faf85243818b1a38fab38","examples/get_special.rs":"592376cbb7aa21859717ae6b564a0a24fce1a661f3635615e035769c8f02861c","examples/hello_file_urls.rs":"53788c1680f85b83d955ac659ad01a2d1f78ad1f0bfff65b82cb269fc69c3372","examples/hello_formats.rs":"c38c2649a7b6f6b8c47cb34fcebf3863da5f2e71f731dd0ad73ef2a116ac93ed","examples/hello_html.rs":"db191a7b1efe243caf65751b5434abb040f384a8286c612bad3c3d0e97af3c27","examples/hello_world.rs":"35c8d60e6965c64c099bdeb3fe924af17f12600863c9bf1907f38993887c128e","examples/set_image.rs":"1fc5effffb2df2af1a44190cc0b2417d5d0465977175f69b2cda64f498894cb9","examples/set_special.rs":"b3bb0918b194b8721a3c329f3b950f96e2af3209e44f87ba33821161facb2a0f","examples/set_svg.rs":"7500b33bc580c42fa00e25f734b96da5b799cff646467b30638d12fbe50a64ed","rustfmt.toml":"1100d9ad5b0f2e9b5608fb1e22a0598b9cb6bae147188c83be2f5b0b41e83e7a","src/common.rs":"44c598209ed4af79cfbe4068403dce5480c0d33043fd29db277942d37f8b9ddc","src/lib.rs":"6df61f4352e6340c84a6edc5a9ea50d67b1fc6ffbcefa86aaa1f6e0c7a98875c","src/platform/linux/mod.rs":"44e82580c6214a62823d90462db7bb6b0ef931f57b2a7fc26aa8c0e95761d5a1","src/platform/linux/url.rs":"303756b7d3527cc1bd38876ac9890473e0e193b981d93cc3f4e63a87d4fa99e2","src/platform/linux/wayland.rs":"0a2f63923f22b7fd5c098211dd3f343fabb1e1eb3c02b966bca0276998c78535","src/platform/linux/x11.rs":"8152064abd92e69aad6a9d3f538db089ac4cf833928c51c5fd89f8340f28a7e5","src/platform/mod.rs":"f0268d73935e043a86de4b1034891a405a4f2f1a8548e435b02541b403c35ea1","src/platform/osx.rs":"60262e9767dc7d4ec1d2670124b281a18240aeb3fb0431807ccf6692bc0fddd4","src/platform/windows.rs":"3e2d2b5f8e74d11d364411032ece4e24a5a0d2b123498ca158fb4ca3a451d99f","tools/debugger.entitlements":"d0f0a8981f3ce55694fe0f02781fc34ce986eabb4d274d75b8b087a5428d5bc6","tools/run_with_leaks.sh":"1c9a7fb692150d646f4c7bc4936ee9f597b1c86815747305098ddb7378bf7230"},"package":null} \ No newline at end of file diff --git a/third_party/arboard/.github/workflows/test.yml b/third_party/arboard/.github/workflows/test.yml new file mode 100644 index 00000000000..27adbf9b9a6 --- /dev/null +++ b/third_party/arboard/.github/workflows/test.yml @@ -0,0 +1,100 @@ +name: Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + rustfmt: + runs-on: ubuntu-22.04 + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - uses: actions/checkout@v4 + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + needs: rustfmt + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + # Latest stable and MSRV. We only run checks with all features enabled + # for the MSRV build to keep CI fast, since other configurations should also work. + rust_version: [stable, "1.67.1"] + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust_version }} + components: clippy + - uses: actions/checkout@v4 + + - name: Run `cargo clippy` with no features + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features -- -D warnings -D clippy::dbg_macro + + - name: Run `cargo clippy` with `image-data` feature + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features --features image-data -- -D warnings -D clippy::dbg_macro + + - name: Run `cargo clippy` with `wayland-data-control` feature + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features --features wayland-data-control -- -D warnings -D clippy::dbg_macro + + - name: Run `cargo clippy` with all features + run: cargo clippy --verbose --all-features -- -D warnings -D clippy::dbg_macro + + test: + needs: clippy + runs-on: ${{ matrix.os }} + strategy: + matrix: + # No Linux test for now as it just fails due to not having a desktop environment. + os: [macos-latest, windows-latest] + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Checkout + uses: actions/checkout@v4 + - name: Run tests with no features + run: cargo test --no-default-features + - name: Run tests with `image-data` feature + run: cargo test --no-default-features --features image-data + - name: Run tests with `wayland-data-control` feature + run: cargo test --no-default-features --features wayland-data-control + - name: Run tests with all features + run: cargo test --all-features + + miri: + needs: clippy + env: + MIRIFLAGS: -Zmiri-symbolic-alignment-check + runs-on: ${{ matrix.os }} + strategy: + matrix: + # Currently, only Windows has soundness tests. + os: [windows-latest] + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly-2023-10-08 + components: miri + + - name: Checkout + uses: actions/checkout@v4 + + - name: Check soundness + run: cargo miri test windows --features image-data + + semver: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Check semver + uses: obi1kenobi/cargo-semver-checks-action@v2 diff --git a/third_party/arboard/CHANGELOG.md b/third_party/arboard/CHANGELOG.md new file mode 100644 index 00000000000..e61007ee4c8 --- /dev/null +++ b/third_party/arboard/CHANGELOG.md @@ -0,0 +1,163 @@ +# Changelog + +## 3.4.0 on 2024-29-04 + +### Added +- Added a `wait_until` method for Linux, as a superset of the existing `wait` functionality. + This is a helper for letting an application wait without manual timeout handling. + +### Fixed +- Transparency in copied images now behaves better in certain Windows apps. + +### Changed +- Updated `image` to `0.25`. +- Removed direct `thiserror` dependency. +- Fixed Linux documentation links +- Raised MSRV to 1.67.1 +- Reverted timeout behavior of `Clipboard::new()` on platforms using X11. Applications are + encouraged to wrap constructor calls in their own thread/channel timeout mechanisms instead + to make sure the behavior matches each usecase. +- Migrated away from `objc` to the `objc2` ecosystem for the Apple clipboard implementation. + +## 3.3.2 on 2024-12-02 + +### Fixed +- Fixed compilation on Windows when using the `image-data` feature combined with older Rust compilers. + +## 3.3.1 on 2024-12-02 + +### Changed +- Updated Windows clipboard and migrated from `winapi` to `windows-sys`. +- Internally migrated to Rust 2021 edition. +- Significantly improved the crate's error documentation. +- Updated `core-graphics` to `0.23` +- Updated `x11rb` to `0.13` + +## 3.3.0 on 2023-20-11 + +### Added +- Add support for `ExcludeClipboardContentFromMonitorProcessing` on Windows platforms. + +### Changed +- Improved timeout error messaging. +- Update `wl-clipboard-rs` to `0.8`. +- Update `x11rb` to `0.12`. +- `arboard`'s MSRV is now 1.61. + +## 3.2.1 on 2023-29-08 + +### Fixed +- Removed all leaks from the macOS clipboard code. Previously, both the `get` and `set` methods leaked data. +- Fixed documentation examples so that they compile on Linux. +- Removed extra whitespace macOS's HTML copying template. This caused unexpected behavior in some apps. + +### Changed +- Added a timeout when connecting to the X11 server on UNIX platforms. In situations where the X11 socket is present but unusable, the clipboard + initialization will no longer hang indefinitely. +- Removed macOS-specific dependency on the `once_cell` crate. + +## 3.2.0 on 2022-04-11 + +### Changed +- The Windows clipboard now behaves consistently with the other +platform implementations again. +- Significantly improve cross-platform documentation of `Clipboard`. +- Remove lingering uses of the dbg! macro in the Wayland backend. + +## 3.1.1 on 2022-17-10 + +### Added +- Implemented the ability to set HTML on the clipboard + +### Changed +- Updated minimum `clipboard-win` version to `4.4`. +- Updated `wl-clipboard-rs` to the version `0.7`. + +## 3.1.0 on 2022-20-09 + +### Changed +- Updated `image` to the version `0.24`. +- Lowered Wayland clipboard initialization log level. + +## 3.0.0 on 2022-19-09 + +### Added +- Support for clearing the clipboard. +- Spport for excluding Windows clipboard data from cliboard history and OneDrive. +- Support waiting for another process to read clipboard data before returning +from a `write` call to a X11 and Wayland or clipboard + +### Changed +- Updated `wl-clipboard-rs` to the version `0.6`. +- Updated `x11rb` to the version `0.10`. +- Cleaned up spelling in documentation +- (Breaking) Functions that used to accept `String` now take `Into, str>` instead. +This avoids cloning the string more times then necessary on platforms that can. +- (Breaking) `Error` is now marked as `#[non_exhaustive]`. +- (Breaking) Removed all platform specific modules and clipboard structures from the public API. +If you were using these directly, the recommended replacement is using `arboard::Clipboard` and +the new platform-specific extension traits instead. +- (Breaking) On Windows, the clipboard is now opened once per call to `Clipboard::new()` instead of on +each operation. This means that instances of `Clipboard` should be dropped once you're performed the +needed operations to prevent other applications from working with it afterwards. + +## v2.1.1 on 2022-18-05 + +### Changed + +- Fix compilation on FreeBSD +- Internal cleanup and documentation fixes +- Remove direct dependency on the `once_cell` crate. +- Fixed crates.io repository link + +## v2.1.0 on 2022-09-03 + +### Changed + +- Updated most dependencies +- Removed crate deprecation +- Fixed soundness bug in Windows clipboard + +## v2.0.1 on 2021-11-05 + +### Changed + +- On X11, re-assert clipboard ownership every time the data changes. + +## v2.0.0 on 2021-08-07 + +### Changed + +- Update dependency on yanked crate versions +- Make the image operations an optional feature + +### Added + +- Support selecting which linux clipboard is used + +## v1.2.1 on 2021-05-04 + +### Changed + +- Fixed a bug that caused the `set_image` function on Windows to distort the + image colors. + +## v1.2.0 on 2021-04-06 + +### Added + +- Optional native wayland support through the `wl-clipboard-rs` crate. + +## v1.1.0 on 2020-12-29 + +### Changed + +- The `set_image` function on Windows now also provides the image in + `CF_BITMAP` format. + +## v1.0.2 on 2020-10-29 + +### Changed + +- Fixed the clipboard contents sometimes not being preserved after the program + exited. diff --git a/third_party/arboard/Cargo.lock b/third_party/arboard/Cargo.lock new file mode 100644 index 00000000000..19a00bba3e6 --- /dev/null +++ b/third_party/arboard/Cargo.lock @@ -0,0 +1,1016 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" +dependencies = [ + "memchr", +] + +[[package]] +name = "arboard" +version = "3.4.0" +dependencies = [ + "clipboard-win", + "core-graphics", + "env_logger", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "serde", + "serde_derive", + "windows-sys 0.48.0", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ff7d91d3c1d568065b06c899777d1e48dcf76103a672a0adbc238a7f247f1e" +dependencies = [ + "objc2", +] + +[[package]] +name = "bytemuck" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + +[[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-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "core-graphics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "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", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" + +[[package]] +name = "env_logger" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "error-code" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281e452d3bad4005426416cdba5ccfd4f5c1280e10099e21db27f7c1c28347fc" + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.0", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "image" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" +dependencies = [ + "bytemuck", + "byteorder", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libloading" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d580318f95776505201b28cf98eb1fa5e4be3b689633ba6a3e6cd880ff22d8cb" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[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.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" +dependencies = [ + "adler", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da284c198fb9b7b0603f8635185e85fbd5b64ee154b1ed406d489077de2d6d60" + +[[package]] +name = "objc2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b25e1034d0e636cd84707ccdaa9f81243d399196b8a773946dcffec0401659" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb79768a710a9a1798848179edb186d1af7e8a8679f369e4b8d201dd2a034047" +dependencies = [ + "block2", + "objc2", + "objc2-core-data", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e092bc42eaf30a08844e6a076938c60751225ec81431ab89f5d1ccd9f958d6c" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88658da63e4cc2c8adb1262902cd6af51094df0488b760d6fd27194269c0950a" + +[[package]] +name = "objc2-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfaefe14254871ea16c7d88968c0ff14ba554712a20d76421eec52f0a7fb8904" +dependencies = [ + "block2", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" + +[[package]] +name = "os_pipe" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29d73ba8daf8fac13b0501d1abeddcfe21ba7401ada61a819144b6c2a4f32209" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.0", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "petgraph" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "png" +version = "0.17.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0e7f4c94ec26ff209cee506314212639d6c91b80afb82984819fafce9df01c" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52205623b1b0f064a4e71182c3b18ae902267282930c6d5462c91b859668426e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53f98874615aea268107765aa1ed8f6116782501d18e53d08b471733bea6c85" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.100", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "tree_magic_mini" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469a727cac55b41448315cc10427c069c618ac59bb6a4480283fcd811749bdc2" +dependencies = [ + "fnv", + "home", + "memchr", + "nom", + "once_cell", + "petgraph", +] + +[[package]] +name = "unicode-ident" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" + +[[package]] +name = "wayland-backend" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e9e6b6d4a2bb4e7e69433e0b35c7923b95d4dc8503a84d25ec917a4bbfdf07" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e63801c85358a431f986cffa74ba9599ff571fc5774ac113ed3b490c19a1133" +dependencies = [ + "bitflags 2.6.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d0f1056570486e26a3773ec633885124d79ae03827de05ba6c85f79904026c" +dependencies = [ + "bitflags 2.6.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dab47671043d9f5397035975fe1cac639e5bca5cc0b3c32d09f01612e34d24" +dependencies = [ + "bitflags 2.6.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67da50b9f80159dec0ea4c11c13e24ef9e7574bd6ce24b01860a175010cea565" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105b1842da6554f91526c14a2a2172897b7f745a805d62af4ce698706be79c12" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[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-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[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.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "tempfile", + "thiserror", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "x11rb" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" diff --git a/third_party/arboard/Cargo.toml b/third_party/arboard/Cargo.toml new file mode 100644 index 00000000000..a24b1f4e77c --- /dev/null +++ b/third_party/arboard/Cargo.toml @@ -0,0 +1,163 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.67.1" +name = "arboard" +version = "3.4.0" +authors = [ + "Artur Kovacs ", + "Avi Weinstock ", + "Arboard contributors", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Image and text handling for the OS clipboard." +readme = "README.md" +keywords = [ + "clipboard", + "image", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/1Password/arboard" + +[features] +default = [] +wayland-data-control = ["wl-clipboard-rs"] + +[lib] +name = "arboard" +path = "src/lib.rs" + +[[example]] +name = "daemonize" +path = "examples/daemonize.rs" + +[[example]] +name = "get_formats" +path = "examples/get_formats.rs" + +[[example]] +name = "get_image" +path = "examples/get_image.rs" + +[[example]] +name = "get_special" +path = "examples/get_special.rs" + +[[example]] +name = "hello_file_urls" +path = "examples/hello_file_urls.rs" + +[[example]] +name = "hello_formats" +path = "examples/hello_formats.rs" + +[[example]] +name = "hello_html" +path = "examples/hello_html.rs" + +[[example]] +name = "hello_world" +path = "examples/hello_world.rs" + +[[example]] +name = "set_image" +path = "examples/set_image.rs" + +[[example]] +name = "set_special" +path = "examples/set_special.rs" + +[[example]] +name = "set_svg" +path = "examples/set_svg.rs" + +[dependencies] +log = "0.4" +serde = "1.0" +serde_derive = "1.0" + +[dev-dependencies] +env_logger = "0.9.0" + +[target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies] +parking_lot = "0.12" + +[target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies.image] +version = "0.25" +features = ["png"] +default-features = false + +[target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies.percent-encoding] +version = "2.3" + +[target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies.wl-clipboard-rs] +version = "0.9" +optional = true + +[target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies.x11rb] +version = "0.13" + +[target.'cfg(target_os = "macos")'.dependencies.core-graphics] +version = "0.23" + +[target.'cfg(target_os = "macos")'.dependencies.image] +version = "0.25" +features = ["tiff"] +default-features = false + +[target.'cfg(target_os = "macos")'.dependencies.objc2] +version = "0.5.1" +features = ["relax-void-encoding"] + +[target.'cfg(target_os = "macos")'.dependencies.objc2-app-kit] +version = "0.2.0" +features = [ + "NSPasteboard", + "NSPasteboardItem", + "NSImage", +] + +[target.'cfg(target_os = "macos")'.dependencies.objc2-foundation] +version = "0.2.0" +features = [ + "NSArray", + "NSString", + "NSEnumerator", + "NSGeometry", +] + +[target.'cfg(target_os = "macos")'.dependencies.percent-encoding] +version = "2.3" + +[target."cfg(windows)".dependencies] +clipboard-win = "5.4.0" + +[target."cfg(windows)".dependencies.image] +version = "0.25" +features = ["png"] +default-features = false + +[target."cfg(windows)".dependencies.windows-sys] +version = "0.48.0" +features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_System_Ole", +] diff --git a/third_party/arboard/LICENSE-APACHE.txt b/third_party/arboard/LICENSE-APACHE.txt new file mode 100644 index 00000000000..f433b1a53f5 --- /dev/null +++ b/third_party/arboard/LICENSE-APACHE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/third_party/arboard/LICENSE-MIT.txt b/third_party/arboard/LICENSE-MIT.txt new file mode 100644 index 00000000000..796e84b4f05 --- /dev/null +++ b/third_party/arboard/LICENSE-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 The Arboard contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/arboard/README.md b/third_party/arboard/README.md new file mode 100644 index 00000000000..b64ef8e622e --- /dev/null +++ b/third_party/arboard/README.md @@ -0,0 +1,53 @@ +# Arboard (Arthur's Clipboard) + +[![Latest version](https://img.shields.io/crates/v/arboard?color=mediumvioletred)](https://crates.io/crates/arboard) +[![Documentation](https://docs.rs/arboard/badge.svg)](https://docs.rs/arboard) +![MSRV](https://img.shields.io/badge/rustc-1.67.1+-blue.svg) + +## General + +This is a cross-platform library for interacting with the clipboard. It allows +to copy and paste both text and image data in a platform independent way on +Linux, Mac, and Windows. + +## GNU/Linux + +The GNU/Linux implementation uses the X protocol by default for managing the +clipboard but *fear not* because Wayland works with the X11 protocol just as +well. Furthermore this implementation uses the Clipboard selection (as opposed +to the primary selection) and it sends the data to the clipboard manager when +the application exits so that the data placed onto the clipboard with your +application remains to be available after exiting. + +There's also an optional wayland data control backend through the +`wl-clipboard-rs` crate. This can be enabled using the `wayland-data-control` +feature. When enabled this will be prioritized over the X11 backend, but if the +initialization fails, the implementation falls back to using the X11 protocol +automatically. Note that in my tests the wayland backend did not keep the +clipboard contents after the process exited. (Although neither did the X11 +backend on my Wayland setup). + +## Example + +```rust +use arboard::Clipboard; + +fn main() { + let mut clipboard = Clipboard::new().unwrap(); + println!("Clipboard text was: {}", clipboard.get_text().unwrap()); + + let the_string = "Hello, world!"; + clipboard.set_text(the_string).unwrap(); + println!("But now the clipboard text should be: \"{}\"", the_string); +} +``` + +## Yet another clipboard crate + +This is a fork of `rust-clipboard`. The reason for forking instead of making a +PR is that `rust-clipboard` is not being maintained any more. Furthermore note +that the API of this crate is considerably different from that of +`rust-clipboard`. There are already a ton of clipboard crates out there which +is a bit unfortunate; I don't know why this is happening but while it is, we +might as well just start naming the clipboard crates after ourselves. This one +is arboard which stands for Artur's clipboard. diff --git a/third_party/arboard/examples/daemonize.rs b/third_party/arboard/examples/daemonize.rs new file mode 100644 index 00000000000..94986dd6440 --- /dev/null +++ b/third_party/arboard/examples/daemonize.rs @@ -0,0 +1,35 @@ +//! Example showcasing the use of `set_text_wait` and spawning a daemon to allow the clipboard's +//! contents to live longer than the process on Linux. + +use arboard::Clipboard; +#[cfg(target_os = "linux")] +use arboard::SetExtLinux; +use std::{env, error::Error, process}; + +// An argument that can be passed into the program to signal that it should daemonize itself. This +// can be anything as long as it is unlikely to be passed in by the user by mistake. +const DAEMONIZE_ARG: &str = "__internal_daemonize"; + +fn main() -> Result<(), Box> { + #[cfg(target_os = "linux")] + if env::args().nth(1).as_deref() == Some(DAEMONIZE_ARG) { + Clipboard::new()?.set().wait().text("Hello, world!")?; + return Ok(()); + } + + env_logger::init(); + + if cfg!(target_os = "linux") { + process::Command::new(env::current_exe()?) + .arg(DAEMONIZE_ARG) + .stdin(process::Stdio::null()) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .current_dir("/") + .spawn()?; + } else { + Clipboard::new()?.set_text("Hello, world!")?; + } + + Ok(()) +} diff --git a/third_party/arboard/examples/get_formats.rs b/third_party/arboard/examples/get_formats.rs new file mode 100644 index 00000000000..40e5675dba8 --- /dev/null +++ b/third_party/arboard/examples/get_formats.rs @@ -0,0 +1,24 @@ +use arboard::{Clipboard, ClipboardFormat}; + +const FORMAT_SPECIAL: &str = "dyn.arboard.pecial.format"; + +fn main() { + env_logger::init(); + + let mut ctx = Clipboard::new().unwrap(); + + let formats = [ + ClipboardFormat::Text, + ClipboardFormat::Html, + ClipboardFormat::Rtf, + ClipboardFormat::ImageRgba, + ClipboardFormat::ImagePng, + ClipboardFormat::ImageSvg, + #[cfg(any(target_os = "linux", target_os = "macos"))] + ClipboardFormat::FileUrl, + ClipboardFormat::Special(FORMAT_SPECIAL), + ]; + for d in ctx.get_formats(&formats).unwrap() { + println!("data: {:?}", d); + } +} diff --git a/third_party/arboard/examples/get_image.rs b/third_party/arboard/examples/get_image.rs new file mode 100644 index 00000000000..cf62d3b56fa --- /dev/null +++ b/third_party/arboard/examples/get_image.rs @@ -0,0 +1,21 @@ +use arboard::Clipboard; + +fn main() { + let mut ctx = Clipboard::new().unwrap(); + + let img = ctx.get_image().unwrap(); + + match img { + arboard::ImageData::Rgba(img) => { + println!("Image width is: {}", img.width); + println!("Image height is: {}", img.height); + println!("Image data is:\n{:?}", img.bytes); + } + arboard::ImageData::Png(png) => { + println!("PNG data is:\n{:?}", png); + } + arboard::ImageData::Svg(svg) => { + println!("SVG data is:\n{}", svg); + } + } +} diff --git a/third_party/arboard/examples/get_special.rs b/third_party/arboard/examples/get_special.rs new file mode 100644 index 00000000000..ff2faf720bc --- /dev/null +++ b/third_party/arboard/examples/get_special.rs @@ -0,0 +1,10 @@ +use arboard::Clipboard; + +fn main() { + env_logger::init(); + let mut ctx = Clipboard::new().unwrap(); + + let special_format = "dyn.arboard.pecial.format"; + + println!("{:?}", ctx.get_special(special_format).unwrap()); +} diff --git a/third_party/arboard/examples/hello_file_urls.rs b/third_party/arboard/examples/hello_file_urls.rs new file mode 100644 index 00000000000..f7de0edb892 --- /dev/null +++ b/third_party/arboard/examples/hello_file_urls.rs @@ -0,0 +1,19 @@ +use arboard::{Clipboard, ClipboardData, ClipboardFormat}; + +fn main() { + env_logger::init(); + let mut clipboard = Clipboard::new().unwrap(); + println!( + "Clipboard urls was: {:?}", + clipboard.get_formats(&vec![ClipboardFormat::FileUrl]).unwrap() + ); + + let urls = vec!["/tmp/test1.txt".to_owned(), "/tmp/test2.txt".to_owned()]; + clipboard.set_formats(&vec![ClipboardData::FileUrl(urls.clone())]).unwrap(); + println!("But now the clipboard urls should be: \"{}\"", urls.join("\n")); + + println!( + "Clipboard urls is: {:?}", + clipboard.get_formats(&vec![ClipboardFormat::FileUrl]).unwrap() + ); +} diff --git a/third_party/arboard/examples/hello_formats.rs b/third_party/arboard/examples/hello_formats.rs new file mode 100644 index 00000000000..e217b741ba7 --- /dev/null +++ b/third_party/arboard/examples/hello_formats.rs @@ -0,0 +1,84 @@ +use arboard::{Clipboard, ClipboardData, ClipboardFormat}; +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +const FORMAT_SPECIAL: &str = "dyn.arboard.pecial.format"; + +fn set(ctx: Arc>, vec_data: Vec<(ClipboardFormat, ClipboardData)>) { + let mut ctx = ctx.lock().unwrap(); + ctx.set_formats(&vec_data.into_iter().map(|(_, d)| d).collect::>()).unwrap(); +} + +fn get(ctx: Arc>, vec_data: Vec<(ClipboardFormat, ClipboardData)>) { + let mut ctx = ctx.lock().unwrap(); + let mut formats = Vec::new(); + let mut data = Vec::new(); + for (f, d) in vec_data.into_iter() { + formats.push(f); + data.push(d); + } + + for d in ctx.get_formats(&formats).unwrap() { + println!("data: {:?}", d); + } +} + +fn main() { + env_logger::init(); + + let vec_data = vec![ + (ClipboardFormat::Text, ClipboardData::Text("Hello, world!".to_string())), + (ClipboardFormat::Html, ClipboardData::Html("Hello, world!".to_string())), + (ClipboardFormat::Rtf, ClipboardData::Rtf("{\\rtf1\\ansi\\b Hello, world!}".to_string())), + ( + ClipboardFormat::ImageRgba, + ClipboardData::Image(arboard::ImageData::rgba( + 2, + 2, + [255, 100, 100, 255, 100, 255, 100, 100, 100, 100, 255, 100, 0, 0, 0, 255] + .as_ref() + .into(), + )), + ), + ( + ClipboardFormat::ImagePng, + ClipboardData::Image(arboard::ImageData::png( + [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, + 0, 2, 8, 6, 0, 0, 0, 114, 182, 13, 36, 0, 0, 0, 29, 73, 68, 65, 84, 120, 1, 1, + 18, 0, 237, 255, 0, 255, 100, 100, 255, 100, 255, 100, 100, 0, 100, 100, 255, + 100, 0, 0, 0, 255, 83, 20, 8, 28, 106, 36, 154, 137, 0, 0, 0, 0, 73, 69, 78, + 68, 174, 66, 96, 130, + ] + .as_ref() + .into(), + )), + ), + ( + ClipboardFormat::ImageSvg, + ClipboardData::Image(arboard::ImageData::svg( + r#" + + + + +"#, + )), + ), + ( + ClipboardFormat::Special(FORMAT_SPECIAL), + ClipboardData::Special((FORMAT_SPECIAL.to_string(), vec![1])), + ), + ]; + + let ctx = Arc::new(Mutex::new(Clipboard::new().unwrap())); + let ctx2 = ctx.clone(); + let vec_data2 = vec_data.clone(); + thread::spawn(move || set(ctx2, vec_data2)); + + thread::sleep(Duration::from_millis(1000)); + get(ctx.clone(), vec_data); +} diff --git a/third_party/arboard/examples/hello_html.rs b/third_party/arboard/examples/hello_html.rs new file mode 100644 index 00000000000..dd2c0c173fb --- /dev/null +++ b/third_party/arboard/examples/hello_html.rs @@ -0,0 +1,21 @@ +use arboard::Clipboard; +use std::{thread, time::Duration}; + +fn main() { + env_logger::init(); + let mut ctx = Clipboard::new().unwrap(); + + println!("Clipboard html was: {:?}\n", ctx.get_html()); + + let html = r#"

Hello, World!

+Lorem ipsum dolor sit amet,
+consectetur adipiscing elit."#; + + let alt_text = r#"Hello, World! +Lorem ipsum dolor sit amet, +consectetur adipiscing elit."#; + + ctx.set_html(html, Some(alt_text)).unwrap(); + println!("But later the clipboard html should be:\n\n{}", html); + thread::sleep(Duration::from_secs(1)); +} diff --git a/third_party/arboard/examples/hello_world.rs b/third_party/arboard/examples/hello_world.rs new file mode 100644 index 00000000000..efc4ebf784f --- /dev/null +++ b/third_party/arboard/examples/hello_world.rs @@ -0,0 +1,11 @@ +use arboard::Clipboard; + +fn main() { + env_logger::init(); + let mut clipboard = Clipboard::new().unwrap(); + println!("Clipboard text was: {:?}", clipboard.get_text()); + + let the_string = "Hello, world!"; + clipboard.set_text(the_string).unwrap(); + println!("But now the clipboard text should be: \"{}\"", the_string); +} diff --git a/third_party/arboard/examples/set_image.rs b/third_party/arboard/examples/set_image.rs new file mode 100644 index 00000000000..2e4e032a709 --- /dev/null +++ b/third_party/arboard/examples/set_image.rs @@ -0,0 +1,15 @@ +use arboard::{Clipboard, ImageData}; + +fn main() { + let mut ctx = Clipboard::new().unwrap(); + + #[rustfmt::skip] + let bytes = [ + 255, 100, 100, 255, + 100, 255, 100, 100, + 100, 100, 255, 100, + 0, 0, 0, 255, + ]; + let img_data = ImageData::rgba(2, 2, bytes.as_ref().into()); + ctx.set_image(img_data).unwrap(); +} diff --git a/third_party/arboard/examples/set_special.rs b/third_party/arboard/examples/set_special.rs new file mode 100644 index 00000000000..953009134fa --- /dev/null +++ b/third_party/arboard/examples/set_special.rs @@ -0,0 +1,32 @@ +use arboard::Clipboard; +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +fn set(ctx: Arc>) { + let mut ctx = ctx.lock().unwrap(); + + let special_format = "dyn.arboard.pecial.format"; + + ctx.set_special(special_format, &[1]).unwrap(); +} + +fn get(ctx: Arc>) { + let mut ctx = ctx.lock().unwrap(); + + let special_format = "dyn.arboard.pecial.format"; + println!("special format data: {:?}", ctx.get_special(special_format).unwrap()); +} + +fn main() { + env_logger::init(); + + let ctx = Arc::new(Mutex::new(Clipboard::new().unwrap())); + let ctx2 = ctx.clone(); + thread::spawn(move || set(ctx2)); + + thread::sleep(Duration::from_millis(1000)); + get(ctx.clone()); +} diff --git a/third_party/arboard/examples/set_svg.rs b/third_party/arboard/examples/set_svg.rs new file mode 100644 index 00000000000..1003e2b068f --- /dev/null +++ b/third_party/arboard/examples/set_svg.rs @@ -0,0 +1,15 @@ +use arboard::{Clipboard, ImageData}; + +fn main() { + let mut ctx = Clipboard::new().unwrap(); + + let svg = r#" + + + + +"#; + + let img_data = ImageData::svg(svg); + ctx.set_image(img_data).unwrap(); +} diff --git a/third_party/arboard/rustfmt.toml b/third_party/arboard/rustfmt.toml new file mode 100644 index 00000000000..265f850445c --- /dev/null +++ b/third_party/arboard/rustfmt.toml @@ -0,0 +1,4 @@ +hard_tabs=true +use_field_init_shorthand=true +use_small_heuristics="Max" +use_try_shorthand=true diff --git a/third_party/arboard/src/common.rs b/third_party/arboard/src/common.rs new file mode 100644 index 00000000000..b53835cbb9f --- /dev/null +++ b/third_party/arboard/src/common.rs @@ -0,0 +1,272 @@ +/* +SPDX-License-Identifier: Apache-2.0 OR MIT + +Copyright 2022 The Arboard contributors + +The project to which this file belongs is licensed under either of +the Apache 2.0 or the MIT license at the licensee's choice. The terms +and conditions of the chosen license apply to this file. +*/ + +use serde_derive::{Deserialize, Serialize}; +use std::borrow::Cow; + +/// An error that might happen during a clipboard operation. +/// +/// Note that both the `Display` and the `Debug` trait is implemented for this type in such a way +/// that they give a short human-readable description of the error; however the documentation +/// gives a more detailed explanation for each error kind. +#[non_exhaustive] +pub enum Error { + /// The clipboard contents were not available in the requested format. + /// This could either be due to the clipboard being empty or the clipboard contents having + /// an incompatible format to the requested one (eg when calling `get_image` on text) + ContentNotAvailable, + + /// The selected clipboard is not supported by the current configuration (system and/or environment). + /// + /// This can be caused by a few conditions: + /// - Using the Primary clipboard with an older Wayland compositor (that doesn't support version 2) + /// - Using the Secondary clipboard on Wayland + ClipboardNotSupported, + + /// The native clipboard is not accessible due to being held by an other party. + /// + /// This "other party" could be a different process or it could be within + /// the same program. So for example you may get this error when trying + /// to interact with the clipboard from multiple threads at once. + /// + /// Note that it's OK to have multiple `Clipboard` instances. The underlying + /// implementation will make sure that the native clipboard is only + /// opened for transferring data and then closed as soon as possible. + ClipboardOccupied, + + /// The image or the text that was about the be transferred to/from the clipboard could not be + /// converted to the appropriate format. + ConversionFailure, + + /// Any error that doesn't fit the other error types. + /// + /// The `description` field is only meant to help the developer and should not be relied on as a + /// means to identify an error case during runtime. + Unknown { description: String }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ContentNotAvailable => f.write_str("The clipboard contents were not available in the requested format or the clipboard is empty."), + Error::ClipboardNotSupported => f.write_str("The selected clipboard is not supported with the current system configuration."), + Error::ClipboardOccupied => f.write_str("The native clipboard is not accessible due to being held by an other party."), + Error::ConversionFailure => f.write_str("The image or the text that was about the be transferred to/from the clipboard could not be converted to the appropriate format."), + Error::Unknown { description } => f.write_fmt(format_args!("arboard: {description}")), + } + } +} + +impl std::error::Error for Error {} + +impl std::fmt::Debug for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use Error::*; + macro_rules! kind_to_str { + ($( $e: pat ),*) => { + match self { + $( + $e => stringify!($e), + )* + } + } + } + let name = kind_to_str!( + ContentNotAvailable, + ClipboardNotSupported, + ClipboardOccupied, + ConversionFailure, + Unknown { .. } + ); + f.write_fmt(format_args!("{} - \"{}\"", name, self)) + } +} + +impl Error { + #[cfg(windows)] + pub(crate) fn unknown>(message: M) -> Self { + Error::Unknown { description: message.into() } + } +} + +#[derive(Debug, Clone)] +pub enum ClipboardFormat<'a> { + Text, + Html, + Rtf, + ImageRgba, + ImagePng, + ImageSvg, + #[cfg(any(target_os = "linux", target_os = "macos"))] + FileUrl, + Special(&'a str), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ClipboardData { + Unsupported, + Text(String), + Html(String), + Rtf(String), + Image(ImageData<'static>), + Special((String, Vec)), + #[cfg(any(target_os = "linux", target_os = "macos"))] + FileUrl(Vec), + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ImageData<'a> { + Rgba(ImageRgba<'a>), + Png(Cow<'a, [u8]>), + Svg(String), +} + +/// Stores pixel data of an image. +/// +/// Each element in `bytes` stores the value of a channel of a single pixel. +/// This struct stores four channels (red, green, blue, alpha) so +/// a `3*3` image is going to be stored on `3*3*4 = 36` bytes of data. +/// +/// The pixels are in row-major order meaning that the second pixel +/// in `bytes` (starting at the fifth byte) corresponds to the pixel that's +/// sitting to the right side of the top-left pixel (x=1, y=0) +/// +/// Assigning a `2*1` image would for example look like this +/// ``` +/// use arboard::ImageRgba; +/// use std::borrow::Cow; +/// let bytes = [ +/// // A red pixel +/// 255, 0, 0, 255, +/// +/// // A green pixel +/// 0, 255, 0, 255, +/// ]; +/// let img = ImageRgba { +/// width: 2, +/// height: 1, +/// bytes: Cow::from(bytes.as_ref()) +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageRgba<'a> { + pub width: usize, + pub height: usize, + pub bytes: Cow<'a, [u8]>, +} + +impl<'a> ImageData<'a> { + pub fn rgba(width: usize, height: usize, bytes: Cow<'a, [u8]>) -> Self { + ImageData::Rgba(ImageRgba { width, height, bytes }) + } + + pub fn png(bytes: Cow<'a, [u8]>) -> Self { + ImageData::Png(bytes) + } + + pub fn svg>(svg: S) -> Self { + ImageData::Svg(svg.into()) + } + + /// Returns a the bytes field in a way that it's guaranteed to be owned. + /// It moves the bytes if they are already owned and clones them if they are borrowed. + pub fn into_owned_bytes(self) -> Cow<'static, [u8]> { + match self { + ImageData::Rgba(p) => p.into_owned_bytes(), + ImageData::Png(p) => Cow::Owned(p.into_owned()), + ImageData::Svg(s) => Cow::Owned(s.into_bytes()), + } + } + + /// Returns an image data that is guaranteed to own its bytes. + /// It moves the bytes if they are already owned and clones them if they are borrowed. + pub fn to_owned_img(&self) -> ImageData<'static> { + match self { + ImageData::Rgba(p) => ImageData::Rgba(p.to_owned_img()), + ImageData::Png(p) => ImageData::Png(p.clone().into_owned().into()), + ImageData::Svg(s) => ImageData::Svg(s.clone()), + } + } + + /// Returns the bytes of the image data. + pub fn bytes(&self) -> &[u8] { + match self { + ImageData::Rgba(p) => &p.bytes, + ImageData::Png(p) => p.as_ref(), + ImageData::Svg(s) => s.as_bytes(), + } + } + + pub fn get_svg(&self) -> Option<&str> { + match self { + ImageData::Rgba(_) => None, + ImageData::Png(_) => None, + ImageData::Svg(s) => Some(s), + } + } +} + +impl<'a> ImageRgba<'a> { + /// Returns a the bytes field in a way that it's guaranteed to be owned. + /// It moves the bytes if they are already owned and clones them if they are borrowed. + pub fn into_owned_bytes(self) -> Cow<'static, [u8]> { + self.bytes.into_owned().into() + } + + /// Returns an image data that is guaranteed to own its bytes. + /// It moves the bytes if they are already owned and clones them if they are borrowed. + pub fn to_owned_img(&self) -> ImageRgba<'static> { + ImageRgba { + width: self.width, + height: self.height, + bytes: self.bytes.clone().into_owned().into(), + } + } +} + +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +pub(crate) struct ScopeGuard { + callback: Option, +} + +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +impl ScopeGuard { + #[cfg_attr(windows, allow(dead_code))] + pub(crate) fn new(callback: F) -> Self { + ScopeGuard { callback: Some(callback) } + } +} + +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +impl Drop for ScopeGuard { + fn drop(&mut self) { + if let Some(callback) = self.callback.take() { + (callback)(); + } + } +} + +/// Common trait for sealing platform extension traits. +pub(crate) mod private { + // This is currently unused on macOS, so silence the warning which appears + // since there's no extension traits making use of this trait sealing structure. + #[cfg_attr(target_vendor = "apple", allow(unreachable_pub))] + pub trait Sealed {} + + impl Sealed for crate::Get<'_> {} + impl Sealed for crate::Set<'_> {} + impl Sealed for crate::Clear<'_> {} +} + +#[inline] +pub(crate) fn into_unknown(msg: &str, error: E) -> Error { + Error::Unknown { description: format!("{}, {}", msg, error) } +} diff --git a/third_party/arboard/src/lib.rs b/third_party/arboard/src/lib.rs new file mode 100644 index 00000000000..68d1b78a34a --- /dev/null +++ b/third_party/arboard/src/lib.rs @@ -0,0 +1,542 @@ +/* +SPDX-License-Identifier: Apache-2.0 OR MIT + +Copyright 2022 The Arboard contributors + +The project to which this file belongs is licensed under either of +the Apache 2.0 or the MIT license at the licensee's choice. The terms +and conditions of the chosen license apply to this file. +*/ +#![warn(unreachable_pub)] + +mod common; +use std::borrow::Cow; + +pub use common::{ClipboardData, ClipboardFormat, Error}; +pub use common::{ImageData, ImageRgba}; + +mod platform; + +#[cfg(all( + unix, + not(any(target_os = "macos", target_os = "android", target_os = "emscripten")), +))] +pub use platform::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux}; + +#[cfg(windows)] +pub use platform::SetExtWindows; + +/// The OS independent struct for accessing the clipboard. +/// +/// Any number of `Clipboard` instances are allowed to exist at a single point in time. Note however +/// that all `Clipboard`s must be 'dropped' before the program exits. In most scenarios this happens +/// automatically but there are frameworks (for example, `winit`) that take over the execution +/// and where the objects don't get dropped when the application exits. In these cases you have to +/// make sure the object is dropped by taking ownership of it in a confined scope when detecting +/// that your application is about to quit. +/// +/// It is also valid to have these multiple `Clipboards` on separate threads at once but note that +/// executing multiple clipboard operations in parallel might fail with a `ClipboardOccupied` error. +/// +/// # Platform-specific behavior +/// +/// `arboard` does its best to abstract over different platforms, but sometimes the platform-specific +/// behavior leaks through unsolvably. These differences, depending on which platforms are being targeted, +/// may affect your app's clipboard architecture (ex, opening and closing a [`Clipboard`] every time +/// or keeping one open in some application/global state). +/// +/// ## Linux +/// +/// Using either Wayland and X11, the clipboard and its content is "hosted" inside of the application +/// that last put data onto it. This means that when the last `Clipboard` instance is dropped, the contents +/// may become unavailable to other apps. See [SetExtLinux] for more details. +/// +/// ## Windows +/// +/// The clipboard on Windows is a global object, which may only be opened on one thread at once. +/// This means that `arboard` only truly opens the clipboard during each operation to prevent +/// multiple `Clipboard`s from existing at once. +/// +/// This means that attempting operations in parallel has a high likelihood to return an error or +/// deadlock. As such, it is recommended to avoid creating/operating clipboard objects on >1 thread. +#[allow(rustdoc::broken_intra_doc_links)] +pub struct Clipboard { + pub(crate) platform: platform::Clipboard, +} + +impl Clipboard { + /// Creates an instance of the clipboard. + /// + /// # Errors + /// + /// On some platforms or desktop environments, an error can be returned if clipboards are not + /// supported. This may be retried. + pub fn new() -> Result { + Ok(Clipboard { platform: platform::Clipboard::new()? }) + } + + /// Fetches UTF-8 text from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 text. + pub fn get_text(&mut self) -> Result { + self.get().text() + } + + /// Places the text onto the clipboard. Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if `text` failed to be stored on the clipboard. + pub fn set_text<'a, T: Into>>(&mut self, text: T) -> Result<(), Error> { + self.set().text(text) + } + + /// Fetches UTF-8 rtf from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 rtf. + pub fn get_rtf(&mut self) -> Result { + self.get().rtf() + } + + /// Places the rtf onto the clipboard. Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if `rtf` failed to be stored on the clipboard. + pub fn set_rtf<'a, T: Into>>(&mut self, rtf: T) -> Result<(), Error> { + self.set().rtf(rtf) + } + + /// Fetches UTF-8 html from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 html. + pub fn get_html(&mut self) -> Result { + self.get().html() + } + + /// Places the HTML as well as a plain-text alternative onto the clipboard. + /// + /// Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if both `html` and `alt_text` failed to be stored on the clipboard. + pub fn set_html<'a, T: Into>>( + &mut self, + html: T, + alt_text: Option, + ) -> Result<(), Error> { + self.set().html(html, alt_text) + } + + /// Fetches image data from the clipboard, and returns the decoded pixels. + /// + /// Any image data placed on the clipboard with `set_image` will be possible read back, using + /// this function. However it's of not guaranteed that an image placed on the clipboard by any + /// other application will be of a supported format. + /// + /// # Errors + /// + /// Returns error if clipboard is empty, contents are not an image, or the contents cannot be + /// converted to an appropriate format and stored in the [`ImageData`] type. + pub fn get_image(&mut self) -> Result, Error> { + self.get().image() + } + + /// Places an image to the clipboard. + /// + /// The chosen output format, depending on the platform is the following: + /// + /// - On macOS: `NSImage` object + /// - On Linux: PNG, under the atom `image/png` + /// - On Windows: In order of priority `CF_DIB` and `CF_BITMAP` + /// + /// # Errors + /// + /// Returns error if `image` cannot be converted to an appropriate format or if it failed to be + /// stored on the clipboard. + pub fn set_image(&mut self, image: ImageData) -> Result<(), Error> { + self.set().image(image) + } + + pub fn get_special(&mut self, format_name: &str) -> Result, Error> { + self.get().special(format_name) + } + + pub fn set_special(&mut self, format_name: &str, data: &[u8]) -> Result<(), Error> { + self.set().special(format_name, data) + } + + pub fn get_formats( + &mut self, + formats: &[ClipboardFormat], + ) -> Result, Error> { + self.get().formats(formats) + } + + pub fn set_formats(&mut self, data: &[ClipboardData]) -> Result<(), Error> { + self.set().formats(data) + } + + /// Clears any contents that may be present from the platform's default clipboard, + /// regardless of the format of the data. + /// + /// # Errors + /// + /// Returns error on Windows or Linux if clipboard cannot be cleared. + pub fn clear(&mut self) -> Result<(), Error> { + self.clear_with().default() + } + + /// Begins a "clear" option to remove data from the clipboard. + pub fn clear_with(&mut self) -> Clear<'_> { + Clear { platform: platform::Clear::new(&mut self.platform) } + } + + /// Begins a "get" operation to retrieve data from the clipboard. + pub fn get(&mut self) -> Get<'_> { + Get { platform: platform::Get::new(&mut self.platform) } + } + + /// Begins a "set" operation to set the clipboard's contents. + pub fn set(&mut self) -> Set<'_> { + Set { platform: platform::Set::new(&mut self.platform) } + } +} + +/// A builder for an operation that gets a value from the clipboard. +#[must_use] +pub struct Get<'clipboard> { + pub(crate) platform: platform::Get<'clipboard>, +} + +impl Get<'_> { + /// Completes the "get" operation by fetching UTF-8 text from the clipboard. + pub fn text(self) -> Result { + self.platform.text() + } + + /// Completes the "get" operation by fetching UTF-8 rtf from the clipboard. + pub fn rtf(self) -> Result { + self.platform.rtf() + } + + /// Completes the "get" operation by fetching UTF-8 html from the clipboard. + pub fn html(self) -> Result { + self.platform.html() + } + + /// Completes the "get" operation by fetching image data from the clipboard and returning the + /// decoded pixels. + /// + /// Any image data placed on the clipboard with `set_image` will be possible read back, using + /// this function. However it's of not guaranteed that an image placed on the clipboard by any + /// other application will be of a supported format. + pub fn image(self) -> Result, Error> { + self.platform.image() + } + + pub fn special(self, format_name: &str) -> Result, Error> { + self.platform.special(format_name) + } + + pub fn formats(self, formats: &[ClipboardFormat]) -> Result, Error> { + self.platform.formats(formats) + } +} + +/// A builder for an operation that sets a value to the clipboard. +#[must_use] +pub struct Set<'clipboard> { + pub(crate) platform: platform::Set<'clipboard>, +} + +impl Set<'_> { + /// Completes the "set" operation by placing text onto the clipboard. Any valid UTF-8 string + /// is accepted. + pub fn text<'a, T: Into>>(self, text: T) -> Result<(), Error> { + let text = text.into(); + self.platform.text(text) + } + + /// Completes the "set" operation by placing rtf onto the clipboard. Any valid UTF-8 string + /// is accepted. + pub fn rtf<'a, T: Into>>(self, rtf: T) -> Result<(), Error> { + let rtf = rtf.into(); + self.platform.rtf(rtf) + } + + /// Completes the "set" operation by placing HTML as well as a plain-text alternative onto the + /// clipboard. + /// + /// Any valid UTF-8 string is accepted. + pub fn html<'a, T: Into>>( + self, + html: T, + alt_text: Option, + ) -> Result<(), Error> { + let html = html.into(); + let alt_text = alt_text.map(|e| e.into()); + self.platform.html(html, alt_text) + } + + /// Completes the "set" operation by placing an image onto the clipboard. + /// + /// The chosen output format, depending on the platform is the following: + /// + /// - On macOS: `NSImage` object + /// - On Linux: PNG, under the atom `image/png` + /// - On Windows: In order of priority `CF_DIB` and `CF_BITMAP` + pub fn image(self, image: ImageData) -> Result<(), Error> { + self.platform.image(image) + } + + pub fn special(self, format_name: &str, data: &[u8]) -> Result<(), Error> { + self.platform.special(format_name, data) + } + + pub fn formats(self, data: &[ClipboardData]) -> Result<(), Error> { + self.platform.formats(data) + } +} + +/// A builder for an operation that clears the data from the clipboard. +#[must_use] +pub struct Clear<'clipboard> { + pub(crate) platform: platform::Clear<'clipboard>, +} + +impl Clear<'_> { + /// Completes the "clear" operation by deleting any existing clipboard data, + /// regardless of the format. + pub fn default(self) -> Result<(), Error> { + self.platform.clear() + } +} + +/// All tests grouped in one because the windows clipboard cannot be open on +/// multiple threads at once. +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::Arc, thread, time::Duration}; + + #[test] + fn all_tests() { + let _ = env_logger::builder().is_test(true).try_init(); + { + let mut ctx = Clipboard::new().unwrap(); + let text = "some string"; + ctx.set_text(text).unwrap(); + assert_eq!(ctx.get_text().unwrap(), text); + + // We also need to check that the content persists after the drop; this is + // especially important on X11 + drop(ctx); + + // Give any external mechanism a generous amount of time to take over + // responsibility for the clipboard, in case that happens asynchronously + // (it appears that this is the case on X11 plus Mutter 3.34+, see #4) + thread::sleep(Duration::from_millis(300)); + + let mut ctx = Clipboard::new().unwrap(); + assert_eq!(ctx.get_text().unwrap(), text); + } + { + let mut ctx = Clipboard::new().unwrap(); + let text = "Some utf8: 🤓 ∑φ(n)<ε 🐔"; + ctx.set_text(text).unwrap(); + assert_eq!(ctx.get_text().unwrap(), text); + } + { + let mut ctx = Clipboard::new().unwrap(); + let text = "hello world"; + + ctx.set_text(text).unwrap(); + assert_eq!(ctx.get_text().unwrap(), text); + + ctx.clear().unwrap(); + + match ctx.get_text() { + Ok(text) => assert!(text.is_empty()), + Err(Error::ContentNotAvailable) => {} + Err(e) => panic!("unexpected error: {}", e), + }; + + // confirm it is OK to clear when already empty. + ctx.clear().unwrap(); + } + { + let mut ctx = Clipboard::new().unwrap(); + let html = "hello world!"; + + ctx.set_html(html, None).unwrap(); + + match ctx.get_text() { + Ok(text) => assert!(text.is_empty()), + Err(Error::ContentNotAvailable) => {} + Err(e) => panic!("unexpected error: {}", e), + }; + } + { + let mut ctx = Clipboard::new().unwrap(); + + let html = "hello world!"; + let alt_text = "hello world!"; + + ctx.set_html(html, Some(alt_text)).unwrap(); + assert_eq!(ctx.get_text().unwrap(), alt_text); + } + { + let mut ctx = Clipboard::new().unwrap(); + #[rustfmt::skip] + let bytes = [ + 255, 100, 100, 255, + 100, 255, 100, 100, + 100, 100, 255, 100, + 0, 0, 0, 255, + ]; + let img_data = ImageData::rgba(2, 2, Cow::from(bytes.as_ref())); + + // Make sure that setting one format overwrites the other. + ctx.set_image(img_data.clone()).unwrap(); + assert!(matches!(ctx.get_text(), Err(Error::ContentNotAvailable))); + + ctx.set_text("clipboard test").unwrap(); + assert!(matches!(ctx.get_image(), Err(Error::ContentNotAvailable))); + + // Test if we get the same image that we put onto the clipboard + ctx.set_image(img_data.clone()).unwrap(); + let got = ctx.get_image().unwrap(); + assert_eq!(img_data.bytes(), got.bytes()); + + #[rustfmt::skip] + let big_bytes = vec![ + 255, 100, 100, 255, + 100, 255, 100, 100, + 100, 100, 255, 100, + + 0, 1, 2, 255, + 0, 1, 2, 255, + 0, 1, 2, 255, + ]; + let bytes_cloned = big_bytes.clone(); + let big_img_data = ImageData::rgba(3, 2, Cow::from(bytes.as_ref())); + ctx.set_image(big_img_data).unwrap(); + let got = ctx.get_image().unwrap(); + assert_eq!(bytes_cloned.as_slice(), got.bytes().as_ref()); + } + #[cfg(all( + unix, + not(any(target_os = "macos", target_os = "android", target_os = "emscripten")), + ))] + { + use crate::{LinuxClipboardKind, SetExtLinux}; + use std::sync::atomic::{self, AtomicBool}; + + let mut ctx = Clipboard::new().unwrap(); + + const TEXT1: &str = "I'm a little teapot,"; + const TEXT2: &str = "short and stout,"; + const TEXT3: &str = "here is my handle"; + + ctx.set().clipboard(LinuxClipboardKind::Clipboard).text(TEXT1.to_string()).unwrap(); + + ctx.set().clipboard(LinuxClipboardKind::Primary).text(TEXT2.to_string()).unwrap(); + + // The secondary clipboard is not available under wayland + if !cfg!(feature = "wayland-data-control") + || std::env::var_os("WAYLAND_DISPLAY").is_none() + { + ctx.set().clipboard(LinuxClipboardKind::Secondary).text(TEXT3.to_string()).unwrap(); + } + + assert_eq!(TEXT1, &ctx.get().clipboard(LinuxClipboardKind::Clipboard).text().unwrap()); + + assert_eq!(TEXT2, &ctx.get().clipboard(LinuxClipboardKind::Primary).text().unwrap()); + + // The secondary clipboard is not available under wayland + if !cfg!(feature = "wayland-data-control") + || std::env::var_os("WAYLAND_DISPLAY").is_none() + { + assert_eq!( + TEXT3, + &ctx.get().clipboard(LinuxClipboardKind::Secondary).text().unwrap() + ); + } + + let was_replaced = Arc::new(AtomicBool::new(false)); + + let setter = thread::spawn({ + let was_replaced = was_replaced.clone(); + move || { + thread::sleep(Duration::from_millis(100)); + let mut ctx = Clipboard::new().unwrap(); + ctx.set_text("replacement text".to_owned()).unwrap(); + was_replaced.store(true, atomic::Ordering::Release); + } + }); + + ctx.set().wait().text("initial text".to_owned()).unwrap(); + + assert!(was_replaced.load(atomic::Ordering::Acquire)); + + setter.join().unwrap(); + } + } + + // The cross-platform abstraction should allow any number of clipboards + // to be open at once without issue, as documented under [Clipboard]. + #[test] + fn multiple_clipboards_at_once() { + const THREAD_COUNT: usize = 100; + + let mut handles = Vec::with_capacity(THREAD_COUNT); + let barrier = Arc::new(std::sync::Barrier::new(THREAD_COUNT)); + + for _ in 0..THREAD_COUNT { + let barrier = barrier.clone(); + handles.push(thread::spawn(move || { + // As long as the clipboard isn't used multiple times at once, multiple instances + // are perfectly fine. + let _ctx = Clipboard::new().unwrap(); + + thread::sleep(Duration::from_millis(10)); + + barrier.wait(); + })); + } + + for thread_handle in handles { + thread_handle.join().unwrap(); + } + } + + #[test] + fn clipboard_trait_consistently() { + fn assert_send_sync() {} + + assert_send_sync::(); + assert!(std::mem::needs_drop::()); + } + + #[test] + fn get_set_special() { + env_logger::init(); + let mut ctx = Clipboard::new().unwrap(); + + let special_format = "dyn.arboard.pecial.format"; + + ctx.set_special(special_format, &[1]).unwrap(); + assert_eq!(ctx.get_special(special_format).unwrap(), vec![1]); + + ctx.set_special(special_format, &[0]).unwrap(); + assert_eq!(ctx.get_special(special_format).unwrap(), vec![0]); + } +} diff --git a/third_party/arboard/src/platform/linux/mod.rs b/third_party/arboard/src/platform/linux/mod.rs new file mode 100644 index 00000000000..1fa14dbc901 --- /dev/null +++ b/third_party/arboard/src/platform/linux/mod.rs @@ -0,0 +1,414 @@ +use std::{borrow::Cow, time::Instant}; + +#[cfg(feature = "wayland-data-control")] +use log::{trace, warn}; + +use crate::{ + common::{into_unknown, private}, + ClipboardData, ClipboardFormat, Error, ImageData, ImageRgba, +}; + +mod x11; +mod url; + +#[cfg(feature = "wayland-data-control")] +mod wayland; + +fn encode_as_png(image: &ImageRgba) -> Result, Error> { + use image::ImageEncoder as _; + + if image.bytes.is_empty() || image.width == 0 || image.height == 0 { + return Err(Error::ConversionFailure); + } + + let mut png_bytes = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes); + encoder + .write_image( + image.bytes.as_ref(), + image.width as u32, + image.height as u32, + image::ExtendedColorType::Rgba8, + ) + .map_err(|e| into_unknown("failed to write png", e))?; + + Ok(png_bytes) +} + +pub(crate) fn decode_from_png(bytes: Vec) -> Result, Error> { + let img = match image::load_from_memory(&bytes) { + Ok(img) => img, + Err(_) => return Err(Error::ConversionFailure), + }; + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + let bytes = rgba.into_raw(); + Ok(ImageRgba { bytes: bytes.into(), width: width as _, height: height as _ }) +} + +/// Clipboard selection +/// +/// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This +/// enum provides a way to get/set to a specific clipboard (the default +/// [`Clipboard`](Self::Clipboard) being used for the common platform API). You can choose which +/// clipboard to use with [`GetExtLinux::clipboard`] and [`SetExtLinux::clipboard`]. +/// +/// See for a better +/// description of the different clipboards. +#[derive(Copy, Clone, Debug)] +pub enum LinuxClipboardKind { + /// Typically used selection for explicit cut/copy/paste actions (ie. windows/macos like + /// clipboard behavior) + Clipboard, + + /// Typically used for mouse selections and/or currently selected text. Accessible via middle + /// mouse click. + /// + /// *On Wayland, this may not be available for all systems (requires a compositor supporting + /// version 2 or above) and operations using this will return an error if unsupported.* + Primary, + + /// The secondary clipboard is rarely used but theoretically available on X11. + /// + /// *On Wayland, this is not be available and operations using this variant will return an + /// error.* + Secondary, +} + +pub(crate) enum Clipboard { + X11(x11::Clipboard), + + #[cfg(feature = "wayland-data-control")] + WlDataControl(wayland::Clipboard), +} + +impl Clipboard { + pub(crate) fn new() -> Result { + #[cfg(feature = "wayland-data-control")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + // Wayland is available + match wayland::Clipboard::new() { + Ok(clipboard) => { + trace!("Successfully initialized the Wayland data control clipboard."); + return Ok(Self::WlDataControl(clipboard)); + } + Err(e) => warn!( + "Tried to initialize the wayland data control protocol clipboard, but failed. Falling back to the X11 clipboard protocol. The error was: {}", + e + ), + } + } + } + Ok(Self::X11(x11::Clipboard::new()?)) + } +} + +pub(crate) struct Get<'clipboard> { + clipboard: &'clipboard mut Clipboard, + selection: LinuxClipboardKind, +} + +impl<'clipboard> Get<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard, selection: LinuxClipboardKind::Clipboard } + } + + pub(crate) fn text(self) -> Result { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_text(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_text(self.selection), + } + } + + pub(crate) fn rtf(self) -> Result { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_rtf(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_rtf(self.selection), + } + } + + pub(crate) fn html(self) -> Result { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_html(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_html(self.selection), + } + } + + pub(crate) fn image(self) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_image(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_image(self.selection), + } + } + + pub(crate) fn special(self, format_name: &str) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_special(format_name, self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_special(format_name, self.selection), + } + } + + pub(crate) fn formats(self, formats: &[ClipboardFormat]) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_formats(formats, self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_formats(formats, self.selection), + } + } +} + +/// Linux-specific extensions to the [`Get`](super::Get) builder. +pub trait GetExtLinux: private::Sealed { + /// Sets the clipboard the operation will retrieve data from. + /// + /// If wayland support is enabled and available, attempting to use the Secondary clipboard will + /// return an error. + fn clipboard(self, selection: LinuxClipboardKind) -> Self; +} + +impl GetExtLinux for crate::Get<'_> { + fn clipboard(mut self, selection: LinuxClipboardKind) -> Self { + self.platform.selection = selection; + self + } +} + +/// Configuration on how long to wait for a new X11 copy event is emitted. +#[derive(Default, Copy, Clone)] +pub(crate) enum WaitConfig { + /// Waits until the given [`Instant`] has reached. + Until(Instant), + + /// Waits forever until a new event is reached. + Forever, + + /// It shouldn't wait. + #[default] + None, +} + +pub(crate) struct Set<'clipboard> { + clipboard: &'clipboard mut Clipboard, + wait: WaitConfig, + selection: LinuxClipboardKind, +} + +impl<'clipboard> Set<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard, wait: WaitConfig::default(), selection: LinuxClipboardKind::Clipboard } + } + + pub(crate) fn text(self, text: Cow<'_, str>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_text(text, self.selection, self.wait), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_text(text, self.selection, self.wait), + } + } + + pub(crate) fn rtf(self, rtf: Cow<'_, str>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_rtf(rtf, self.selection, self.wait), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_rtf(rtf, self.selection, self.wait), + } + } + + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + } + } + + pub(crate) fn image(self, image: ImageData<'_>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_image(image, self.selection, self.wait), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_image(image, self.selection, self.wait), + } + } + + pub(crate) fn special(self, format_name: &str, data: &[u8]) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => { + clipboard.set_special(format_name, data, self.selection, self.wait) + } + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => { + clipboard.set_special(format_name, data, self.selection, self.wait) + } + } + } + + pub(crate) fn formats(self, data: &[ClipboardData]) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_formats(data, self.selection, self.wait), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_formats(data, self.selection, self.wait), + } + } +} + +/// Linux specific extensions to the [`Set`](super::Set) builder. +pub trait SetExtLinux: private::Sealed { + /// Whether to wait for the clipboard's contents to be replaced after setting it. + /// + /// The Wayland and X11 clipboards work by having the clipboard content being, at any given + /// time, "owned" by a single process, and that process is expected to reply to all the requests + /// from any other system process that wishes to access the clipboard's contents. As a + /// consequence, when that process exits the contents of the clipboard will effectively be + /// cleared since there is no longer anyone around to serve requests for it. + /// + /// This poses a problem for short-lived programs that just want to copy to the clipboard and + /// then exit, since they don't want to wait until the user happens to copy something else just + /// to finish. To resolve that, whenever the user copies something you can offload the actual + /// work to a newly-spawned daemon process which will run in the background (potentially + /// outliving the current process) and serve all the requests. That process will then + /// automatically and silently exit once the user copies something else to their clipboard so it + /// doesn't take up too many resources. + /// + /// To support that pattern, this method will not only have the contents of the clipboard be + /// set, but will also wait and continue to serve requests until the clipboard is overwritten. + /// As long as you don't exit the current process until that method has returned, you can avoid + /// all surprising situations where the clipboard's contents seemingly disappear from under your + /// feet. + /// + /// See the [daemonize example] for a demo of how you could implement this. + /// + /// [daemonize example]: https://github.com/1Password/arboard/blob/master/examples/daemonize.rs + fn wait(self) -> Self; + + /// Whether or not to wait for the clipboard's content to be replaced after setting it. This waits until the + /// `deadline` has exceeded. + /// + /// This is useful for short-lived programs so it won't block until new contents on the clipboard + /// were added. + /// + /// Note: this is a superset of [`wait()`][SetExtLinux::wait] and will overwrite any state + /// that was previously set using it. + fn wait_until(self, deadline: Instant) -> Self; + + /// Sets the clipboard the operation will store its data to. + /// + /// If wayland support is enabled and available, attempting to use the Secondary clipboard will + /// return an error. + /// + /// # Examples + /// + /// ``` + /// use arboard::{Clipboard, SetExtLinux, LinuxClipboardKind}; + /// # fn main() -> Result<(), arboard::Error> { + /// let mut ctx = Clipboard::new()?; + /// + /// let clipboard = "This goes in the traditional (ex. Copy & Paste) clipboard."; + /// ctx.set().clipboard(LinuxClipboardKind::Clipboard).text(clipboard.to_owned())?; + /// + /// let primary = "This goes in the primary keyboard. It's typically used via middle mouse click."; + /// ctx.set().clipboard(LinuxClipboardKind::Primary).text(primary.to_owned())?; + /// # Ok(()) + /// # } + /// ``` + fn clipboard(self, selection: LinuxClipboardKind) -> Self; +} + +impl SetExtLinux for crate::Set<'_> { + fn wait(mut self) -> Self { + self.platform.wait = WaitConfig::Forever; + self + } + + fn clipboard(mut self, selection: LinuxClipboardKind) -> Self { + self.platform.selection = selection; + self + } + + fn wait_until(mut self, deadline: Instant) -> Self { + self.platform.wait = WaitConfig::Until(deadline); + self + } +} + +pub(crate) struct Clear<'clipboard> { + clipboard: &'clipboard mut Clipboard, +} + +impl<'clipboard> Clear<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard } + } + + pub(crate) fn clear(self) -> Result<(), Error> { + self.clear_inner(LinuxClipboardKind::Clipboard) + } + + fn clear_inner(self, selection: LinuxClipboardKind) -> Result<(), Error> { + let mut set = Set::new(self.clipboard); + set.selection = selection; + + set.text(Cow::Borrowed("")) + } +} + +/// Linux specific extensions to the [Clear] builder. +pub trait ClearExtLinux: private::Sealed { + /// Performs the "clear" operation on the selected clipboard. + /// + /// ### Example + /// + /// ```no_run + /// # use arboard::{Clipboard, LinuxClipboardKind, ClearExtLinux, Error}; + /// # fn main() -> Result<(), Error> { + /// let mut clipboard = Clipboard::new()?; + /// + /// clipboard + /// .clear_with() + /// .clipboard(LinuxClipboardKind::Secondary)?; + /// # Ok(()) + /// # } + /// ``` + /// + /// If wayland support is enabled and available, attempting to use the Secondary clipboard will + /// return an error. + fn clipboard(self, selection: LinuxClipboardKind) -> Result<(), Error>; +} + +impl ClearExtLinux for crate::Clear<'_> { + fn clipboard(self, selection: LinuxClipboardKind) -> Result<(), Error> { + self.platform.clear_inner(selection) + } +} + +mod tests { + #[test] + fn test_png_rgba_convertion() { + use super::{decode_from_png, encode_as_png}; + + let rgba_bytes = + [255u8, 100, 100, 255, 100, 255, 100, 100, 100, 100, 255, 100, 0, 0, 0, 255]; + let w1 = 2; + let h1 = 2; + + let img_rgba = + crate::ImageRgba { bytes: rgba_bytes.clone().to_vec().into(), width: w1, height: h1 }; + let png_bytes = encode_as_png(&img_rgba).unwrap(); + let img_rgba2 = decode_from_png(png_bytes).unwrap(); + + assert_eq!(rgba_bytes.to_vec(), img_rgba2.bytes.to_vec()); + assert_eq!(w1, img_rgba2.width); + assert_eq!(h1, img_rgba2.height); + } +} diff --git a/third_party/arboard/src/platform/linux/url.rs b/third_party/arboard/src/platform/linux/url.rs new file mode 100644 index 00000000000..73be18dedec --- /dev/null +++ b/third_party/arboard/src/platform/linux/url.rs @@ -0,0 +1,69 @@ +use super::into_unknown; +use crate::Error; + +// on x11, path will be encode as +// "/home/rustdesk/pictures/🖼️.png" -> "file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png" +// url encode and decode is needed +const ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS.add(b' ').remove(b'/'); + +pub(super) fn encode_path_to_uri(path: &str) -> String { + let encoded = percent_encoding::percent_encode(path.as_bytes(), &ENCODE_SET).to_string(); + format!("file://{}", encoded) +} + +pub(super) fn parse_uri_to_path(encoded_uri: &str) -> Result { + let encoded_path = encoded_uri.trim_start_matches("file://"); + let path_str = percent_encoding::percent_decode_str(encoded_path) + .decode_utf8() + .map_err(|e| into_unknown("failed to decode path", e))?; + Ok(path_str.to_string()) +} + +// helper parse function +// convert 'text/uri-list' data to a list of valid Paths +// # Note +// - none utf8 data will lead to error +pub(super) fn parse_plain_uri_list(v: Vec) -> Result, Error> { + let text = String::from_utf8(v) + .map_err(|e| into_unknown("failed to convert file urls to utf-8", e))?; + parse_uri_list(&text) +} + +// helper parse function +// convert 'text/uri-list' data to a list of valid Paths +// # Note +// - none utf8 data will lead to error +pub(super) fn parse_uri_list(text: &str) -> Result, Error> { + let mut list = Vec::new(); + + for line in text.lines() { + if !line.starts_with("file://") { + continue; + } + let decoded = parse_uri_to_path(line)?; + list.push(decoded) + } + Ok(list) +} + +#[cfg(test)] +mod uri_test { + #[test] + fn test_conversion() { + let path = "/home/rustdesk/pictures/🖼️.png"; + let uri = super::encode_path_to_uri(&path); + assert_eq!(uri, "file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png"); + let convert_back = super::parse_uri_to_path(&uri).unwrap(); + assert_eq!(path, convert_back); + } + + #[test] + fn parse_list() { + let uri_list = r#"file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png +file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png +"#; + let list = super::parse_uri_list(uri_list.into()).unwrap(); + assert!(list.len() == 2); + assert_eq!(list[0], list[1]); + } +} diff --git a/third_party/arboard/src/platform/linux/wayland.rs b/third_party/arboard/src/platform/linux/wayland.rs new file mode 100644 index 00000000000..e68f192e9b9 --- /dev/null +++ b/third_party/arboard/src/platform/linux/wayland.rs @@ -0,0 +1,530 @@ +use std::borrow::Cow; +use std::io::Read; + +use wl_clipboard_rs::{ + copy::{self, Error as CopyError, MimeSource, MimeType, Options, Source}, + paste::{self, get_contents, Error as PasteError, Seat}, + utils::is_primary_selection_supported, +}; + +use super::encode_as_png; +use super::{into_unknown, LinuxClipboardKind, WaitConfig}; +use crate::common::{ClipboardData, ClipboardFormat, Error}; +use crate::common::{ImageData, ImageRgba}; + +const MIME_PNG: &str = "image/png"; +const MIME_SVG: &str = "image/svg+xml"; +const MIME_HTML: &'static str = "text/html"; +const MIME_RTF: &'static str = "text/rtf"; +const MIME_URL_LIST: &'static str = "text/uri-list"; + +pub(crate) struct Clipboard {} + +impl TryInto for LinuxClipboardKind { + type Error = Error; + + fn try_into(self) -> Result { + match self { + LinuxClipboardKind::Clipboard => Ok(copy::ClipboardType::Regular), + LinuxClipboardKind::Primary => Ok(copy::ClipboardType::Primary), + LinuxClipboardKind::Secondary => Err(Error::ClipboardNotSupported), + } + } +} + +impl TryInto for LinuxClipboardKind { + type Error = Error; + + fn try_into(self) -> Result { + match self { + LinuxClipboardKind::Clipboard => Ok(paste::ClipboardType::Regular), + LinuxClipboardKind::Primary => Ok(paste::ClipboardType::Primary), + LinuxClipboardKind::Secondary => Err(Error::ClipboardNotSupported), + } + } +} + +impl Clipboard { + #[allow(clippy::unnecessary_wraps)] + pub(crate) fn new() -> Result { + // Check if it's possible to communicate with the wayland compositor + if let Err(e) = is_primary_selection_supported() { + return Err(into_unknown("failed to check is_primary_selection_supported", e)); + } + Ok(Self {}) + } + + fn set_source( + &self, + source: MimeSource, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let mut opts = Options::new(); + opts.foreground(matches!(wait, WaitConfig::Forever)); + opts.clipboard(selection.try_into()?); + opts.copy(source.source, source.mime_type.clone()).map_err(|e| match e { + CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown( + &format!("failed to copy clipboard with {:?}", source.mime_type), + other, + ), + }) + } + + fn set_multi_source( + &self, + sources: Vec, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let mut opts = Options::new(); + opts.foreground(matches!(wait, WaitConfig::Forever)); + opts.clipboard(selection.try_into()?); + opts.copy_multi(sources).map_err(|e| match e { + CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown("failed to copy multi sources", other), + }) + } + + pub(crate) fn get_text(&mut self, selection: LinuxClipboardKind) -> Result { + self.get_plain(selection, wl_clipboard_rs::paste::MimeType::Text) + } + + pub(crate) fn set_text( + &self, + text: Cow<'_, str>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + self.set_source(Self::text_to_mime_source(text), selection, wait) + } + + fn text_to_mime_source(text: Cow<'_, str>) -> MimeSource { + MimeSource { + source: Source::Bytes(text.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Text, + } + } + + pub(crate) fn get_rtf(&mut self, selection: LinuxClipboardKind) -> Result { + self.get_plain(selection, wl_clipboard_rs::paste::MimeType::Specific(MIME_RTF)) + } + + pub(crate) fn set_rtf( + &self, + rtf: Cow<'_, str>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + self.set_source(Self::rtf_to_mime_source(rtf), selection, wait) + } + + fn rtf_to_mime_source(rtf: Cow<'_, str>) -> MimeSource { + MimeSource { + source: Source::Bytes(rtf.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_RTF)), + } + } + + pub(crate) fn get_html(&mut self, selection: LinuxClipboardKind) -> Result { + self.get_plain(selection, wl_clipboard_rs::paste::MimeType::Specific(MIME_HTML)) + } + + pub(crate) fn get_url_list(&mut self, selection: LinuxClipboardKind) -> Result { + self.get_plain(selection, wl_clipboard_rs::paste::MimeType::Specific(&MIME_URL_LIST)) + } + + fn get_plain( + &mut self, + selection: LinuxClipboardKind, + mime_type: wl_clipboard_rs::paste::MimeType, + ) -> Result { + let result = get_contents(selection.try_into()?, Seat::Unspecified, mime_type); + match result { + Ok((mut pipe, _)) => { + let mut contents = vec![]; + pipe.read_to_end(&mut contents) + .map_err(|e| into_unknown("failed to read pipe", e))?; + String::from_utf8(contents) + .map_err(|e| into_unknown("failed to convert from utf8", e)) + } + + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + + Err(PasteError::PrimarySelectionUnsupported) => Err(Error::ClipboardNotSupported), + + Err(err) => Err(Error::Unknown { description: err.to_string() }), + } + } + + pub(crate) fn set_html( + &self, + html: Cow<'_, str>, + alt: Option>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let html_source = Self::html_to_mime_source(html); + match alt { + Some(alt_text) => self.set_multi_source( + vec![Self::text_to_mime_source(alt_text), html_source], + selection, + wait, + ), + None => self.set_source(html_source, selection, wait), + } + } + + fn html_to_mime_source(html: Cow<'_, str>) -> MimeSource { + MimeSource { + source: Source::Bytes(html.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_HTML)), + } + } + + fn url_list_to_mime_source(urls: &[String]) -> MimeSource { + let urls: Vec = urls.iter().map(|s| super::url::encode_path_to_uri(s)).collect(); + let urls = urls.join("\n"); + MimeSource { + source: Source::Bytes(urls.into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_URL_LIST)), + } + } + + pub(crate) fn get_image( + &mut self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + match self.get_image_svg(selection) { + Err(Error::ContentNotAvailable) => self.get_image_png(selection), + result => result, + } + } + + pub(crate) fn get_image_rgba( + &mut self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + use wl_clipboard_rs::paste::MimeType; + + let result = + get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Specific(MIME_PNG)); + match result { + Ok((mut pipe, _mime_type)) => { + let mut buffer = vec![]; + pipe.read_to_end(&mut buffer) + .map_err(|e| into_unknown("failed to read pipe", e))?; + let image_data = super::decode_from_png(buffer)?; + Ok(ImageData::Rgba(image_data)) + } + + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + + Err(err) => Err(Error::Unknown { description: err.to_string() }), + } + } + + pub(crate) fn get_image_png( + &mut self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + use wl_clipboard_rs::paste::MimeType; + + let result = + get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Specific(MIME_PNG)); + match result { + Ok((mut pipe, _mime_type)) => { + let mut buffer = vec![]; + pipe.read_to_end(&mut buffer) + .map_err(|e| into_unknown("failed to read pipe", e))?; + Ok(ImageData::png(buffer.into())) + } + + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + + Err(err) => Err(Error::Unknown { description: err.to_string() }), + } + } + + pub(crate) fn get_image_svg( + &mut self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + use wl_clipboard_rs::paste::MimeType; + + let result = + get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Specific(MIME_SVG)); + match result { + Ok((mut pipe, _mime_type)) => { + let mut buffer = vec![]; + pipe.read_to_end(&mut buffer) + .map_err(|e| into_unknown("failed to read pipe", e))?; + Ok(ImageData::svg( + String::from_utf8(buffer) + .map_err(|e| into_unknown("failed to convert from utf8", e))?, + )) + } + + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + + Err(err) => Err(Error::Unknown { description: err.to_string() }), + } + } + + pub(crate) fn set_image( + &mut self, + image: ImageData, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + match image { + ImageData::Rgba(image) => self.set_image_rgba(image, selection, wait), + ImageData::Png(png) => self.set_image_png(png.to_vec(), selection, wait), + ImageData::Svg(svg) => self.set_image_svg(svg, selection, wait), + } + } + + pub(crate) fn set_image_rgba( + &mut self, + image: ImageRgba, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let image = encode_as_png(&image)?; + self.set_source(Self::png_to_mime_source(image), selection, wait) + } + + pub(crate) fn set_image_png( + &mut self, + png: Vec, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + self.set_source(Self::png_to_mime_source(png), selection, wait) + } + + fn png_to_mime_source(png: Vec) -> MimeSource { + MimeSource { + source: Source::Bytes(png.into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_PNG)), + } + } + + pub(crate) fn set_image_svg( + &mut self, + svg: String, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + self.set_source(Self::svg_to_mime_source(svg), selection, wait) + } + + fn svg_to_mime_source(svg: String) -> MimeSource { + MimeSource { + source: Source::Bytes(svg.into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_SVG)), + } + } + + pub(crate) fn get_special( + &self, + format_name: &str, + selection: LinuxClipboardKind, + ) -> Result, Error> { + use wl_clipboard_rs::paste::MimeType; + + let result = + get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Specific(format_name)); + match result { + Ok((mut pipe, _mime_type)) => { + let mut buffer = vec![]; + pipe.read_to_end(&mut buffer) + .map_err(|e| into_unknown("failed to read pipe", e))?; + Ok(buffer) + } + + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + + Err(err) => Err(Error::Unknown { description: err.to_string() }), + } + } + + pub(crate) fn set_special( + &self, + format_name: &str, + data: &[u8], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + self.set_source(Self::special_to_mime_source(format_name, data), selection, wait) + } + + fn special_to_mime_source(format_name: &str, data: &[u8]) -> MimeSource { + MimeSource { + source: Source::Bytes(data.into()), + mime_type: MimeType::Specific(String::from(format_name)), + } + } + + pub(crate) fn get_formats( + &mut self, + formats: &[ClipboardFormat], + selection: LinuxClipboardKind, + ) -> Result, Error> { + let mut results = Vec::new(); + let mut err = None; + let mut err_count = 0; + for format in formats { + match format { + ClipboardFormat::Text => match self.get_text(selection) { + Ok(text) => results.push(ClipboardData::Text(text)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting text: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Rtf => match self.get_rtf(selection) { + Ok(rtf) => results.push(ClipboardData::Rtf(rtf)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting rtf: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Html => match self.get_html(selection) { + Ok(html) => results.push(ClipboardData::Html(html)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting html: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImageRgba => match self.get_image_rgba(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImagePng => match self.get_image_png(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImageSvg => match self.get_image_svg(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::FileUrl => match self.get_url_list(selection) { + Ok(urls) => { + results.push(ClipboardData::FileUrl(super::url::parse_uri_list(&urls)?)) + } + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting url list: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Special(format_name) => { + match self.get_special(format_name, selection) { + Ok(data) => { + results.push(ClipboardData::Special((format_name.to_string(), data))) + } + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error getting special: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + } + } + } + } + if err_count == formats.len() { + if let Some(e) = err { + Err(e) + } else { + // unreachable!() because `err_count == formats.len()` + Ok(results) + } + } else { + Ok(results) + } + } + + pub(crate) fn set_formats( + &self, + data: &[ClipboardData], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let mut sources = Vec::new(); + for item in data { + match item { + ClipboardData::Text(text) => { + sources.push(Self::text_to_mime_source(Cow::Borrowed(text))); + } + ClipboardData::Rtf(rtf) => { + sources.push(Self::rtf_to_mime_source(Cow::Borrowed(rtf))); + } + ClipboardData::Html(html) => { + sources.push(Self::html_to_mime_source(Cow::Borrowed(html))); + } + ClipboardData::Image(image) => match image { + ImageData::Rgba(image) => { + sources.push(Self::png_to_mime_source(encode_as_png(image)?)); + } + ImageData::Png(png) => { + sources.push(Self::png_to_mime_source(png.to_vec())); + } + ImageData::Svg(svg) => { + sources.push(Self::svg_to_mime_source(svg.to_string())); + } + }, + ClipboardData::FileUrl(urls) => { + sources.push(Self::url_list_to_mime_source(urls)); + } + ClipboardData::Special((format_name, data)) => { + sources.push(Self::special_to_mime_source(format_name, data)); + } + _ => {} + } + } + self.set_multi_source(sources, selection, wait) + } +} diff --git a/third_party/arboard/src/platform/linux/x11.rs b/third_party/arboard/src/platform/linux/x11.rs new file mode 100644 index 00000000000..8c986283a21 --- /dev/null +++ b/third_party/arboard/src/platform/linux/x11.rs @@ -0,0 +1,1319 @@ +/* +SPDX-License-Identifier: Apache-2.0 OR MIT + +Copyright 2022 The Arboard contributors + +The project to which this file belongs is licensed under either of +the Apache 2.0 or the MIT license at the licensee's choice. The terms +and conditions of the chosen license apply to this file. +*/ + +// More info about using the clipboard on X11: +// https://tronche.com/gui/x/icccm/sec-2.html#s-2.6 +// https://freedesktop.org/wiki/ClipboardManager/ + +use std::{ + borrow::Cow, + cell::RefCell, + collections::{hash_map::Entry, HashMap}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::JoinHandle, + thread_local, + time::{Duration, Instant}, + usize, vec, +}; + +use log::{error, trace, warn}; +use parking_lot::{Condvar, Mutex, MutexGuard, RwLock}; +use x11rb::{ + connection::Connection, + protocol::{ + xproto::{ + Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property, + PropertyNotifyEvent, SelectionNotifyEvent, SelectionRequestEvent, Time, WindowClass, + SELECTION_NOTIFY_EVENT, + }, + Event, + }, + rust_connection::RustConnection, + wrapper::ConnectionExt as _, + COPY_DEPTH_FROM_PARENT, COPY_FROM_PARENT, NONE, +}; + +use super::encode_as_png; +use super::{into_unknown, LinuxClipboardKind, WaitConfig}; +use crate::{common::ScopeGuard, ClipboardData, ClipboardFormat, Error}; +use crate::{ImageData, ImageRgba}; + +type Result = std::result::Result; + +static CLIPBOARD: Mutex> = parking_lot::const_mutex(None); + +x11rb::atom_manager! { + pub Atoms: AtomCookies { + CLIPBOARD, + PRIMARY, + SECONDARY, + + CLIPBOARD_MANAGER, + SAVE_TARGETS, + TARGETS, + ATOM, + INCR, + + UTF8_STRING, + UTF8_MIME_0: b"text/plain;charset=utf-8", + UTF8_MIME_1: b"text/plain;charset=UTF-8", + // Text in ISO Latin-1 encoding + // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 + STRING, + // Text in unknown encoding + // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 + TEXT, + TEXT_MIME_UNKNOWN: b"text/plain", + + RTF: b"text/rtf", + HTML: b"text/html", + + PNG_MIME: b"image/png", + + SVG_MIME: b"image/svg+xml", + + // Works on KDE Plasma + URL_LIST: b"text/uri-list", + + // These are some special formats that are used by some file managers + // Works on GNOME + X_SPECIAL_GNOME_COPIED_FILES: b"x-special/gnome-copied-files", + X_SPECIAL_NAUTILUS_CLIPBOARD: b"x-special/nautilus-clipboard", + + // This is just some random name for the property on our window, into which + // the clipboard owner writes the data we requested. + ARBOARD_CLIPBOARD, + } +} + +thread_local! { + static ATOM_NAME_CACHE: RefCell> = Default::default(); +} + +// Some clipboard items, like images, may take a very long time to produce a +// `SelectionNotify`. Multiple seconds long. +const LONG_TIMEOUT_DUR: Duration = Duration::from_millis(4000); +const SHORT_TIMEOUT_DUR: Duration = Duration::from_millis(10); + +#[derive(Debug, PartialEq, Eq)] +enum ManagerHandoverState { + Idle, + InProgress, + Finished, +} + +struct GlobalClipboard { + inner: Arc, + + /// Join handle to the thread which serves selection requests. + server_handle: JoinHandle<()>, +} + +struct XContext { + conn: RustConnection, + win_id: u32, +} + +struct Inner { + /// The context for the thread which serves clipboard read + /// requests coming to us. + server: XContext, + atoms: Atoms, + + clipboard: Selection, + primary: Selection, + secondary: Selection, + + handover_state: Mutex, + handover_cv: Condvar, + + serve_stopped: AtomicBool, +} + +impl XContext { + fn new() -> Result { + // create a new connection to an X11 server + let (conn, screen_num): (RustConnection, _) = + RustConnection::connect(None).map_err(|_| Error::Unknown { + description: String::from( + "X11 server connection timed out because it was unreachable", + ), + })?; + let screen = conn + .setup() + .roots + .get(screen_num) + .ok_or(Error::Unknown { description: String::from("no screen found") })?; + let win_id = conn.generate_id().map_err(|e| into_unknown("failed to gen id", e))?; + + let event_mask = + // Just in case that some program reports SelectionNotify events + // with XCB_EVENT_MASK_PROPERTY_CHANGE mask. + EventMask::PROPERTY_CHANGE | + // To receive DestroyNotify event and stop the message loop. + EventMask::STRUCTURE_NOTIFY; + // create the window + conn.create_window( + // copy as much as possible from the parent, because no other specific input is needed + COPY_DEPTH_FROM_PARENT, + win_id, + screen.root, + 0, + 0, + 1, + 1, + 0, + WindowClass::COPY_FROM_PARENT, + COPY_FROM_PARENT, + // don't subscribe to any special events because we are requesting everything we need ourselves + &CreateWindowAux::new().event_mask(event_mask), + ) + .map_err(|e| into_unknown("failed to create window", e))?; + conn.flush().map_err(|e| into_unknown("failed to flush conn", e))?; + + Ok(Self { conn, win_id }) + } +} + +#[derive(Default)] +struct Selection { + data: RwLock>>, + /// Mutex around nothing to use with the below condvar. + mutex: Mutex<()>, + /// A condvar that is notified when the contents of this clipboard are changed. + /// + /// This is associated with `Self::mutex`. + data_changed: Condvar, +} + +#[derive(Debug, Clone)] +struct ClipboardDataX11 { + bytes: Vec, + + /// The atom representing the format in which the data is encoded. + format: Atom, +} + +enum ReadSelNotifyResult { + GotData(Vec), + IncrStarted, + EventNotRecognized, +} + +impl Inner { + fn new() -> Result { + let server = XContext::new()?; + let atoms = Atoms::new(&server.conn) + .map_err(|e| into_unknown("failed to new atoms", e))? + .reply() + .map_err(|e| into_unknown("failed to reply", e))?; + + Ok(Self { + server, + atoms, + clipboard: Selection::default(), + primary: Selection::default(), + secondary: Selection::default(), + handover_state: Mutex::new(ManagerHandoverState::Idle), + handover_cv: Condvar::new(), + serve_stopped: AtomicBool::new(false), + }) + } + + fn write( + &self, + data: Vec, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + if self.serve_stopped.load(Ordering::Relaxed) { + return Err(Error::Unknown { + description: "The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)".into() + }); + } + + let server_win = self.server.win_id; + + // ICCCM version 2, section 2.6.1.3 states that we should re-assert ownership whenever data + // changes. + self.server + .conn + .set_selection_owner(server_win, self.atom_of(selection), Time::CURRENT_TIME) + .map_err(|_| Error::ClipboardOccupied)?; + + self.server.conn.flush().map_err(|e| into_unknown("failed to flush conn", e))?; + + // Just setting the data, and the `serve_requests` will take care of the rest. + let selection = self.selection_of(selection); + let mut data_guard = selection.data.write(); + *data_guard = Some(data); + + // Lock the mutex to both ensure that no wakers of `data_changed` can wake us between + // dropping the `data_guard` and calling `wait[_for]` and that we don't we wake other + // threads in that position. + let mut guard = selection.mutex.lock(); + + // Notify any existing waiting threads that we have changed the data in the selection. + // It is important that the mutex is locked to prevent this notification getting lost. + selection.data_changed.notify_all(); + + match wait { + WaitConfig::None => {} + WaitConfig::Forever => { + drop(data_guard); + selection.data_changed.wait(&mut guard); + } + + WaitConfig::Until(deadline) => { + drop(data_guard); + selection.data_changed.wait_until(&mut guard, deadline); + } + } + + Ok(()) + } + + /// `formats` must be a slice of atoms, where each atom represents a target format. + /// The first format from `formats`, which the clipboard owner supports will be the + /// format of the return value. + fn read(&self, formats: &[Atom], selection: LinuxClipboardKind) -> Result { + // if we are the current owner, we can get the current clipboard ourselves + if self.is_owner(selection)? { + let data = self.selection_of(selection).data.read(); + if let Some(data_list) = &*data { + for data in data_list { + for format in formats { + if *format == data.format { + return Ok(data.clone()); + } + } + } + } + return Err(Error::ContentNotAvailable); + } + // if let Some(data) = self.data.read().clone() { + // return Ok(data) + // } + let reader = XContext::new()?; + + trace!("Trying to get the clipboard data."); + for format in formats { + match self.read_single(&reader, selection, *format) { + Ok(bytes) => { + return Ok(ClipboardDataX11 { bytes, format: *format }); + } + Err(Error::ContentNotAvailable) => { + continue; + } + Err(e) => return Err(e), + } + } + Err(Error::ContentNotAvailable) + } + + fn read_single( + &self, + reader: &XContext, + selection: LinuxClipboardKind, + target_format: Atom, + ) -> Result> { + // Delete the property so that we can detect (using property notify) + // when the selection owner receives our request. + reader + .conn + .delete_property(reader.win_id, self.atoms.ARBOARD_CLIPBOARD) + .map_err(|e| into_unknown("failed to delete clipboard property", e))?; + + // request to convert the clipboard selection to our data type(s) + reader + .conn + .convert_selection( + reader.win_id, + self.atom_of(selection), + target_format, + self.atoms.ARBOARD_CLIPBOARD, + Time::CURRENT_TIME, + ) + .map_err(|e| into_unknown("failed to convert selection", e))?; + reader.conn.sync().map_err(|e| into_unknown("failed to sync conn", e))?; + + trace!("Finished `convert_selection`"); + + let mut incr_data: Vec = Vec::new(); + let mut using_incr = false; + + let mut timeout_end = Instant::now() + LONG_TIMEOUT_DUR; + + while Instant::now() < timeout_end { + let event = + reader.conn.poll_for_event().map_err(|e| into_unknown("failed to poll", e))?; + let event = match event { + Some(e) => e, + None => { + std::thread::sleep(Duration::from_millis(1)); + continue; + } + }; + match event { + // The first response after requesting a selection. + Event::SelectionNotify(event) => { + trace!("Read SelectionNotify"); + let result = self.handle_read_selection_notify( + reader, + target_format, + &mut using_incr, + &mut incr_data, + event, + )?; + match result { + ReadSelNotifyResult::GotData(data) => return Ok(data), + ReadSelNotifyResult::IncrStarted => { + // This means we received an indication that an the + // data is going to be sent INCRementally. Let's + // reset our timeout. + timeout_end += SHORT_TIMEOUT_DUR; + } + ReadSelNotifyResult::EventNotRecognized => (), + } + } + // If the previous SelectionNotify event specified that the data + // will be sent in INCR segments, each segment is transferred in + // a PropertyNotify event. + Event::PropertyNotify(event) => { + let result = self.handle_read_property_notify( + reader, + target_format, + using_incr, + &mut incr_data, + &mut timeout_end, + event, + )?; + if result { + return Ok(incr_data); + } + } + _ => log::trace!("An unexpected event arrived while reading the clipboard."), + } + } + log::info!("Time-out hit while reading the clipboard."); + Err(Error::ContentNotAvailable) + } + + fn atom_of(&self, selection: LinuxClipboardKind) -> Atom { + match selection { + LinuxClipboardKind::Clipboard => self.atoms.CLIPBOARD, + LinuxClipboardKind::Primary => self.atoms.PRIMARY, + LinuxClipboardKind::Secondary => self.atoms.SECONDARY, + } + } + + fn selection_of(&self, selection: LinuxClipboardKind) -> &Selection { + match selection { + LinuxClipboardKind::Clipboard => &self.clipboard, + LinuxClipboardKind::Primary => &self.primary, + LinuxClipboardKind::Secondary => &self.secondary, + } + } + + fn kind_of(&self, atom: Atom) -> Option { + match atom { + a if a == self.atoms.CLIPBOARD => Some(LinuxClipboardKind::Clipboard), + a if a == self.atoms.PRIMARY => Some(LinuxClipboardKind::Primary), + a if a == self.atoms.SECONDARY => Some(LinuxClipboardKind::Secondary), + _ => None, + } + } + + fn is_owner(&self, selection: LinuxClipboardKind) -> Result { + let current = self + .server + .conn + .get_selection_owner(self.atom_of(selection)) + .map_err(|e| into_unknown("failed to get selection owner", e))? + .reply() + .map_err(|e| into_unknown("failed to reply if is owner", e))? + .owner; + + Ok(current == self.server.win_id) + } + + fn atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> Result { + String::from_utf8( + self.server + .conn + .get_atom_name(atom) + .map_err(|e| into_unknown("failed to get atom name", e))? + .reply() + .map_err(|e| into_unknown("failed to reply atom name", e))? + .name, + ) + .map_err(|e| into_unknown("failed to convert atom name to utf8", e)) + } + fn atom_name_dbg(&self, atom: x11rb::protocol::xproto::Atom) -> &'static str { + ATOM_NAME_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + match cache.entry(atom) { + Entry::Occupied(entry) => *entry.get(), + Entry::Vacant(entry) => { + let s = self + .atom_name(atom) + .map(|s| Box::leak(s.into_boxed_str()) as &str) + .unwrap_or("FAILED-TO-GET-THE-ATOM-NAME"); + entry.insert(s); + s + } + } + }) + } + + fn handle_read_selection_notify( + &self, + reader: &XContext, + target_format: u32, + using_incr: &mut bool, + incr_data: &mut Vec, + event: SelectionNotifyEvent, + ) -> Result { + // The property being set to NONE means that the `convert_selection` + // failed. + + // According to: https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 + // the target must be set to the same as what we requested. + if event.property == NONE || event.target != target_format { + return Err(Error::ContentNotAvailable); + } + if self.kind_of(event.selection).is_none() { + log::info!("Received a SelectionNotify for a selection other than CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."); + return Ok(ReadSelNotifyResult::EventNotRecognized); + } + if *using_incr { + log::warn!("Received a SelectionNotify while already expecting INCR segments."); + return Ok(ReadSelNotifyResult::EventNotRecognized); + } + // request the selection + let mut reply = reader + .conn + .get_property(true, event.requestor, event.property, event.target, 0, u32::MAX / 4) + .map_err(|e| into_unknown("failed to get property", e))? + .reply() + .map_err(|e| into_unknown("failed to reply getting property", e))?; + + // trace!("Property.type: {:?}", self.atom_name(reply.type_)); + + // we found something + if reply.type_ == target_format { + Ok(ReadSelNotifyResult::GotData(reply.value)) + } else if reply.type_ == self.atoms.INCR { + // Note that we call the get_property again because we are + // indicating that we are ready to receive the data by deleting the + // property, however deleting only works if the type matches the + // property type. But the type didn't match in the previous call. + reply = reader + .conn + .get_property( + true, + event.requestor, + event.property, + self.atoms.INCR, + 0, + u32::MAX / 4, + ) + .map_err(|e| into_unknown("failed to get property", e))? + .reply() + .map_err(|e| into_unknown("failed to reply getting property", e))?; + log::trace!("Receiving INCR segments"); + *using_incr = true; + if reply.value_len == 4 { + let min_data_len = reply.value32().and_then(|mut vals| vals.next()).unwrap_or(0); + incr_data.reserve(min_data_len as usize); + } + Ok(ReadSelNotifyResult::IncrStarted) + } else { + // this should never happen, we have sent a request only for supported types + Err(Error::Unknown { + description: String::from("incorrect type received from clipboard"), + }) + } + } + + /// Returns Ok(true) when the incr_data is ready + fn handle_read_property_notify( + &self, + reader: &XContext, + target_format: u32, + using_incr: bool, + incr_data: &mut Vec, + timeout_end: &mut Instant, + event: PropertyNotifyEvent, + ) -> Result { + if event.atom != self.atoms.ARBOARD_CLIPBOARD || event.state != Property::NEW_VALUE { + return Ok(false); + } + if !using_incr { + // This must mean the selection owner received our request, and is + // now preparing the data + return Ok(false); + } + let reply = reader + .conn + .get_property(true, event.window, event.atom, target_format, 0, u32::MAX / 4) + .map_err(|e| into_unknown("failed to get property", e))? + .reply() + .map_err(|e| into_unknown("failed to reply getting property", e))?; + + // log::trace!("Received segment. value_len {}", reply.value_len,); + if reply.value_len == 0 { + // This indicates that all the data has been sent. + return Ok(true); + } + incr_data.extend(reply.value); + + // Let's reset our timeout, since we received a valid chunk. + *timeout_end = Instant::now() + SHORT_TIMEOUT_DUR; + + // Not yet complete + Ok(false) + } + + fn handle_selection_request(&self, event: SelectionRequestEvent) -> Result<()> { + let selection = match self.kind_of(event.selection) { + Some(kind) => kind, + None => { + warn!("Received a selection request to a selection other than the CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."); + return Ok(()); + } + }; + + let success; + // we are asked for a list of supported conversion targets + if event.target == self.atoms.TARGETS { + trace!("Handling TARGETS, dst property is {}", self.atom_name_dbg(event.property)); + let mut targets = Vec::with_capacity(10); + targets.push(self.atoms.TARGETS); + targets.push(self.atoms.SAVE_TARGETS); + let data = self.selection_of(selection).data.read(); + if let Some(data_list) = &*data { + for data in data_list { + targets.push(data.format); + if data.format == self.atoms.UTF8_STRING { + // When we are storing a UTF8 string, + // add all equivalent formats to the supported targets + targets.push(self.atoms.UTF8_MIME_0); + targets.push(self.atoms.UTF8_MIME_1); + } + } + } + self.server + .conn + .change_property32( + PropMode::REPLACE, + event.requestor, + event.property, + // TODO: change to `AtomEnum::ATOM` + self.atoms.ATOM, + &targets, + ) + .map_err(|e| into_unknown("failed to change property32", e))?; + self.server.conn.flush().map_err(|e| into_unknown("failed to flush conn", e))?; + success = true; + } else { + trace!("Handling request for (probably) the clipboard contents."); + let data = self.selection_of(selection).data.read(); + if let Some(data_list) = &*data { + success = match data_list.iter().find(|d| d.format == event.target) { + Some(data) => { + self.server + .conn + .change_property8( + PropMode::REPLACE, + event.requestor, + event.property, + event.target, + &data.bytes, + ) + .map_err(|e| into_unknown("failed to change property8", e))?; + self.server + .conn + .flush() + .map_err(|e| into_unknown("failed to flush conn", e))?; + true + } + None => false, + }; + } else { + // This must mean that we lost ownership of the data + // since the other side requested the selection. + // Let's respond with the property set to none. + success = false; + } + } + // on failure we notify the requester of it + let property = if success { event.property } else { AtomEnum::NONE.into() }; + // tell the requestor that we finished sending data + self.server + .conn + .send_event( + false, + event.requestor, + EventMask::NO_EVENT, + SelectionNotifyEvent { + response_type: SELECTION_NOTIFY_EVENT, + sequence: event.sequence, + time: event.time, + requestor: event.requestor, + selection: event.selection, + target: event.target, + property, + }, + ) + .map_err(|e| into_unknown("failed to send event", e))?; + + self.server.conn.flush().map_err(|e| into_unknown("failed to send flush", e)) + } + + fn ask_clipboard_manager_to_request_our_data(&self) -> Result<()> { + if self.server.win_id == 0 { + // This shouldn't really ever happen but let's just check. + error!("The server's window id was 0. This is unexpected"); + return Ok(()); + } + + if !self.is_owner(LinuxClipboardKind::Clipboard)? { + // We are not owning the clipboard, nothing to do. + return Ok(()); + } + if self.selection_of(LinuxClipboardKind::Clipboard).data.read().is_none() { + // If we don't have any data, there's nothing to do. + return Ok(()); + } + + // It's important that we lock the state before sending the request + // because we don't want the request server thread to lock the state + // after the request but before we can lock it here. + let mut handover_state = self.handover_state.lock(); + + trace!("Sending the data to the clipboard manager"); + self.server + .conn + .convert_selection( + self.server.win_id, + self.atoms.CLIPBOARD_MANAGER, + self.atoms.SAVE_TARGETS, + self.atoms.ARBOARD_CLIPBOARD, + Time::CURRENT_TIME, + ) + .map_err(|e| into_unknown("failed to convert selection", e))?; + self.server.conn.flush().map_err(|e| into_unknown("failed to flush conn", e))?; + + *handover_state = ManagerHandoverState::InProgress; + let max_handover_duration = Duration::from_millis(100); + + // Note that we are using a parking_lot condvar here, which doesn't wake up + // spuriously + let result = self.handover_cv.wait_for(&mut handover_state, max_handover_duration); + + if *handover_state == ManagerHandoverState::Finished { + return Ok(()); + } + if result.timed_out() { + warn!("Could not hand the clipboard contents over to the clipboard manager. The request timed out."); + return Ok(()); + } + + Err(Error::Unknown { + description: "The handover was not finished and the condvar didn't time out, yet the condvar wait ended. This should be unreachable.".into() + }) + } +} + +fn serve_requests(context: Arc) -> Result<(), Box> { + fn handover_finished(clip: &Arc, mut handover_state: MutexGuard) { + log::trace!("Finishing clipboard manager handover."); + *handover_state = ManagerHandoverState::Finished; + + // Not sure if unlocking the mutex is necessary here but better safe than sorry. + drop(handover_state); + + clip.handover_cv.notify_all(); + } + + trace!("Started serve requests thread."); + + let _guard = ScopeGuard::new(|| { + context.serve_stopped.store(true, Ordering::Relaxed); + }); + + let mut written = false; + let mut notified = false; + + loop { + match context + .server + .conn + .wait_for_event() + .map_err(|e| into_unknown("failed to wait for event", e))? + { + Event::DestroyNotify(_) => { + // This window is being destroyed. + trace!("Clipboard server window is being destroyed x_x"); + return Ok(()); + } + Event::SelectionClear(event) => { + // TODO: check if this works + // Someone else has new content in the clipboard, so it is + // notifying us that we should delete our data now. + trace!("Somebody else owns the clipboard now"); + + if let Some(selection) = context.kind_of(event.selection) { + let selection = context.selection_of(selection); + let mut data_guard = selection.data.write(); + *data_guard = None; + + // It is important that this mutex is locked at the time of calling + // `notify_all` to prevent notifications getting lost in case the sleeping + // thread has unlocked its `data_guard` and is just about to sleep. + // It is also important that the RwLock is kept write-locked for the same + // reason. + let _guard = selection.mutex.lock(); + selection.data_changed.notify_all(); + } + } + Event::SelectionRequest(event) => { + trace!( + "SelectionRequest - selection is: {}, target is {}", + context.atom_name_dbg(event.selection), + context.atom_name_dbg(event.target), + ); + // Someone is requesting the clipboard content from us. + context + .handle_selection_request(event) + .map_err(|e| into_unknown("failed to handle selection request", e))?; + + // if we are in the progress of saving to the clipboard manager + // make sure we save that we have finished writing + let handover_state = context.handover_state.lock(); + if *handover_state == ManagerHandoverState::InProgress { + // Only set written, when the actual contents were written, + // not just a response to what TARGETS we have. + if event.target != context.atoms.TARGETS { + trace!("The contents were written to the clipboard manager."); + written = true; + // if we have written and notified, make sure to notify that we are done + if notified { + handover_finished(&context, handover_state); + } + } + } + } + Event::SelectionNotify(event) => { + // We've requested the clipboard content and this is the answer. + // Considering that this thread is not responsible for reading + // clipboard contents, this must come from the clipboard manager + // signaling that the data was handed over successfully. + if event.selection != context.atoms.CLIPBOARD_MANAGER { + error!("Received a `SelectionNotify` from a selection other than the CLIPBOARD_MANAGER. This is unexpected in this thread."); + continue; + } + let handover_state = context.handover_state.lock(); + if *handover_state == ManagerHandoverState::InProgress { + // Note that some clipboard managers send a selection notify + // before even sending a request for the actual contents. + // (That's why we use the "notified" & "written" flags) + trace!("The clipboard manager indicated that it's done requesting the contents from us."); + notified = true; + + // One would think that we could also finish if the property + // here is set 0, because that indicates failure. However + // this is not the case; for example on KDE plasma 5.18, we + // immediately get a SelectionNotify with property set to 0, + // but following that, we also get a valid SelectionRequest + // from the clipboard manager. + if written { + handover_finished(&context, handover_state); + } + } + } + _event => { + // May be useful for debugging but nothing else really. + // trace!("Received unwanted event: {:?}", event); + } + } + } +} + +pub(crate) struct Clipboard { + inner: Arc, +} + +impl Clipboard { + pub(crate) fn new() -> Result { + let mut global_cb = CLIPBOARD.lock(); + if let Some(global_cb) = &*global_cb { + return Ok(Self { inner: Arc::clone(&global_cb.inner) }); + } + // At this point we know that the clipboard does not exist. + let ctx = Arc::new(Inner::new()?); + let join_handle; + { + let ctx = Arc::clone(&ctx); + join_handle = std::thread::spawn(move || { + if let Err(error) = serve_requests(ctx) { + error!("Worker thread errored with: {}", error); + } + }); + } + *global_cb = Some(GlobalClipboard { inner: Arc::clone(&ctx), server_handle: join_handle }); + Ok(Self { inner: ctx }) + } + + pub(crate) fn get_text(&self, selection: LinuxClipboardKind) -> Result { + let formats = [ + self.inner.atoms.UTF8_STRING, + self.inner.atoms.UTF8_MIME_0, + self.inner.atoms.UTF8_MIME_1, + self.inner.atoms.STRING, + self.inner.atoms.TEXT, + self.inner.atoms.TEXT_MIME_UNKNOWN, + ]; + let result = self.inner.read(&formats, selection)?; + if result.format == self.inner.atoms.STRING { + // ISO Latin-1 + // See: https://stackoverflow.com/questions/28169745/what-are-the-options-to-convert-iso-8859-1-latin-1-to-a-string-utf-8 + Ok(result.bytes.into_iter().map(|c| c as char).collect()) + } else { + String::from_utf8(result.bytes) + .map_err(|e| into_unknown("failed to convert from utf8", e)) + } + } + + pub(crate) fn set_text( + &self, + message: Cow<'_, str>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.text_to_clip_data(message)]; + self.inner.write(data, selection, wait) + } + + fn text_to_clip_data(&self, text: Cow<'_, str>) -> ClipboardDataX11 { + ClipboardDataX11 { + bytes: text.into_owned().into_bytes(), + format: self.inner.atoms.UTF8_STRING, + } + } + + pub(crate) fn get_rtf(&self, selection: LinuxClipboardKind) -> Result { + let formats = [self.inner.atoms.RTF]; + let result = self.inner.read(&formats, selection)?; + String::from_utf8(result.bytes).map_err(|e| into_unknown("failed to convert from utf8", e)) + } + + pub(crate) fn set_rtf( + &self, + message: Cow<'_, str>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.rtf_to_clip_data(message)]; + self.inner.write(data, selection, wait) + } + + fn rtf_to_clip_data(&self, text: Cow<'_, str>) -> ClipboardDataX11 { + ClipboardDataX11 { bytes: text.into_owned().into_bytes(), format: self.inner.atoms.RTF } + } + + pub(crate) fn get_html(&self, selection: LinuxClipboardKind) -> Result { + let formats = [self.inner.atoms.HTML]; + let result = self.inner.read(&formats, selection)?; + String::from_utf8(result.bytes).map_err(|e| into_unknown("failed to convert from utf8", e)) + } + + pub(crate) fn set_html( + &self, + html: Cow<'_, str>, + alt: Option>, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let mut data = vec![]; + if let Some(alt_text) = alt { + data.push(self.text_to_clip_data(alt_text)); + } + data.push(self.html_to_clip_data(html)); + self.inner.write(data, selection, wait) + } + + fn html_to_clip_data(&self, html: Cow<'_, str>) -> ClipboardDataX11 { + ClipboardDataX11 { bytes: html.into_owned().into_bytes(), format: self.inner.atoms.HTML } + } + + pub(crate) fn get_image(&self, selection: LinuxClipboardKind) -> Result> { + let formats = [self.inner.atoms.SVG_MIME, self.inner.atoms.PNG_MIME]; + let result = self.inner.read(&formats, selection)?; + match result.format { + atom if atom == self.inner.atoms.SVG_MIME => self.get_image_svg(selection), + atom if atom == self.inner.atoms.PNG_MIME => self.get_image_png(selection), + _ => Err(Error::ContentNotAvailable), + } + } + + pub(crate) fn get_image_rgba( + &self, + selection: LinuxClipboardKind, + ) -> Result> { + let formats = [self.inner.atoms.PNG_MIME]; + let bytes = self.inner.read(&formats, selection)?.bytes; + let image_data = super::decode_from_png(bytes)?; + Ok(ImageData::Rgba(image_data)) + } + + pub(crate) fn get_image_png( + &self, + selection: LinuxClipboardKind, + ) -> Result> { + let formats = [self.inner.atoms.PNG_MIME]; + let bytes = self.inner.read(&formats, selection)?.bytes; + Ok(ImageData::png(bytes.into())) + } + + pub(crate) fn get_image_svg( + &self, + selection: LinuxClipboardKind, + ) -> Result> { + let formats = [self.inner.atoms.SVG_MIME]; + let bytes = self.inner.read(&formats, selection)?.bytes; + let svg = + String::from_utf8(bytes).map_err(|e| into_unknown("failed to convert from utf8", e))?; + Ok(ImageData::svg(svg)) + } + + pub(crate) fn set_image( + &self, + image: ImageData, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + match image { + ImageData::Rgba(data) => self.set_image_rgba(data, selection, wait), + ImageData::Png(png) => self.set_image_png(png.to_vec(), selection, wait), + ImageData::Svg(svg) => self.set_image_svg(svg, selection, wait), + } + } + + pub(crate) fn set_image_rgba( + &self, + image: ImageRgba, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.rgba_to_clip_data(image)?]; + self.inner.write(data, selection, wait) + } + + fn rgba_to_clip_data(&self, image: ImageRgba) -> Result { + let encoded = encode_as_png(&image)?; + Ok(ClipboardDataX11 { bytes: encoded, format: self.inner.atoms.PNG_MIME }) + } + + pub(crate) fn set_image_png( + &self, + png: Vec, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.png_to_clip_data(png)]; + self.inner.write(data, selection, wait) + } + + fn png_to_clip_data(&self, png: Vec) -> ClipboardDataX11 { + ClipboardDataX11 { bytes: png, format: self.inner.atoms.PNG_MIME } + } + + pub(crate) fn set_image_svg( + &self, + svg: String, + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.svg_to_clip_data(svg)]; + self.inner.write(data, selection, wait.clone()) + } + + fn svg_to_clip_data(&self, svg: String) -> ClipboardDataX11 { + ClipboardDataX11 { bytes: svg.into_bytes(), format: self.inner.atoms.SVG_MIME } + } + + pub(crate) fn get_file_urls( + &self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + let formats = [ + self.inner.atoms.URL_LIST, + self.inner.atoms.X_SPECIAL_GNOME_COPIED_FILES, + self.inner.atoms.X_SPECIAL_NAUTILUS_CLIPBOARD, + ]; + let result = self.inner.read(&formats, selection)?; + super::url::parse_plain_uri_list(result.bytes) + } + + fn file_urls_to_clip_data(&self, urls: &[String]) -> Vec { + let urls: Vec = urls.iter().map(|s| super::url::encode_path_to_uri(s)).collect(); + let urls = urls.join("\n"); + let text_uri_list_data = urls.as_bytes().to_vec(); + let gnome_copied_files_data = ["copy\n".as_bytes(), urls.as_bytes()].concat(); + vec![ + ClipboardDataX11 { bytes: text_uri_list_data, format: self.inner.atoms.URL_LIST }, + ClipboardDataX11 { + bytes: gnome_copied_files_data.clone(), + format: self.inner.atoms.X_SPECIAL_GNOME_COPIED_FILES, + }, + ClipboardDataX11 { + bytes: gnome_copied_files_data.clone(), + format: self.inner.atoms.X_SPECIAL_NAUTILUS_CLIPBOARD, + }, + ] + } + + pub(crate) fn get_special( + &self, + format_name: &str, + selection: LinuxClipboardKind, + ) -> Result, Error> { + let atom = self + .inner + .server + .conn + .intern_atom(false, format_name.as_bytes()) + .map_err(|e| into_unknown("failed to get atom identifier", e))? + .reply() + .map_err(|e| into_unknown("failed to reply", e))? + .atom; + let formats = [atom]; + self.inner.read(&formats, selection).map(|data| data.bytes) + } + + pub(crate) fn set_special( + &self, + format_name: &str, + data: &[u8], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let data = vec![self.special_to_clip_data(format_name, data)?]; + self.inner.write(data, selection, wait) + } + + fn special_to_clip_data(&self, format_name: &str, data: &[u8]) -> Result { + let atom = self + .inner + .server + .conn + .intern_atom(false, format_name.as_bytes()) + .map_err(|e| into_unknown("failed to get atom identifier", e))? + .reply() + .map_err(|e| into_unknown("failed to reply", e))? + .atom; + Ok(ClipboardDataX11 { bytes: data.to_vec(), format: atom }) + } + + pub(crate) fn get_formats( + &self, + formats: &[ClipboardFormat], + selection: LinuxClipboardKind, + ) -> Result, Error> { + let mut results = Vec::new(); + let mut err = None; + let mut err_count = 0; + for format in formats { + match format { + ClipboardFormat::Text => match self.get_text(selection) { + Ok(text) => results.push(ClipboardData::Text(text)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting text: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Rtf => match self.get_rtf(selection) { + Ok(rtf) => results.push(ClipboardData::Rtf(rtf)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting rtf: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Html => match self.get_html(selection) { + Ok(html) => results.push(ClipboardData::Html(html)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting html: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImageRgba => match self.get_image_rgba(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImagePng => match self.get_image_png(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::ImageSvg => match self.get_image_svg(selection) { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting image: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::FileUrl => match self.get_file_urls(selection) { + Ok(urls) => results.push(ClipboardData::FileUrl(urls)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting file urls: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + }, + ClipboardFormat::Special(format_name) => { + match self.get_special(format_name, selection) { + Ok(data) => { + results.push(ClipboardData::Special((format_name.to_string(), data))) + } + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error while getting special: {:?}", e); + results.push(ClipboardData::None); + err = Some(e); + err_count += 1; + } + } + } + } + } + if err_count == formats.len() { + if let Some(e) = err { + Err(e) + } else { + // unreachable!() because `err_count == formats.len()` + Ok(results) + } + } else { + Ok(results) + } + } + + pub(crate) fn set_formats( + &self, + data: &[ClipboardData], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let mut vec_data_x11 = Vec::new(); + for d in data { + match d { + ClipboardData::Text(text) => vec_data_x11.push(self.text_to_clip_data(text.into())), + ClipboardData::Rtf(rtf) => vec_data_x11.push(self.rtf_to_clip_data(rtf.into())), + ClipboardData::Html(html) => vec_data_x11.push(self.html_to_clip_data(html.into())), + ClipboardData::Image(image) => match image { + ImageData::Rgba(data) => { + vec_data_x11.push(self.rgba_to_clip_data(data.clone())?) + } + ImageData::Png(png) => vec_data_x11.push(self.png_to_clip_data(png.to_vec())), + ImageData::Svg(svg) => vec_data_x11.push(self.svg_to_clip_data(svg.clone())), + }, + ClipboardData::FileUrl(urls) => { + vec_data_x11.extend(self.file_urls_to_clip_data(&urls)); + } + ClipboardData::Special((format_name, data)) => { + vec_data_x11.push(self.special_to_clip_data(format_name, data)?) + } + _ => {} + } + } + self.inner.write(vec_data_x11, selection, wait) + } +} + +impl Drop for Clipboard { + fn drop(&mut self) { + // There are always at least 3 owners: + // the global, the server thread, and one `Clipboard::inner` + const MIN_OWNERS: usize = 3; + + // We start with locking the global guard to prevent race + // conditions below. + let mut global_cb = CLIPBOARD.lock(); + if Arc::strong_count(&self.inner) == MIN_OWNERS { + // If the are the only owners of the clipboard are ourselves and + // the global object, then we should destroy the global object, + // and send the data to the clipboard manager + + if let Err(e) = self.inner.ask_clipboard_manager_to_request_our_data() { + error!("Could not hand the clipboard data over to the clipboard manager: {}", e); + } + let global_cb = global_cb.take(); + if let Err(e) = self.inner.server.conn.destroy_window(self.inner.server.win_id) { + error!("Failed to destroy the clipboard window. Error: {}", e); + return; + } + if let Err(e) = self.inner.server.conn.flush() { + error!("Failed to flush the clipboard window. Error: {}", e); + return; + } + if let Some(global_cb) = global_cb { + if let Err(e) = global_cb.server_handle.join() { + // Let's try extracting the error message + let message; + if let Some(msg) = e.downcast_ref::<&'static str>() { + message = Some((*msg).to_string()); + } else if let Some(msg) = e.downcast_ref::() { + message = Some(msg.clone()); + } else { + message = None; + } + if let Some(message) = message { + error!( + "The clipboard server thread panicked. Panic message: '{}'", + message, + ); + } else { + error!("The clipboard server thread panicked."); + } + } + } + } + } +} diff --git a/third_party/arboard/src/platform/mod.rs b/third_party/arboard/src/platform/mod.rs new file mode 100644 index 00000000000..b3364632e3b --- /dev/null +++ b/third_party/arboard/src/platform/mod.rs @@ -0,0 +1,17 @@ +#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))] +mod linux; +#[cfg(all( + unix, + not(any(target_os = "macos", target_os = "android", target_os = "emscripten")) +))] +pub use linux::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; + +#[cfg(target_os = "macos")] +mod osx; +#[cfg(target_os = "macos")] +pub(crate) use osx::*; diff --git a/third_party/arboard/src/platform/osx.rs b/third_party/arboard/src/platform/osx.rs new file mode 100644 index 00000000000..5b870848c71 --- /dev/null +++ b/third_party/arboard/src/platform/osx.rs @@ -0,0 +1,762 @@ +/* +SPDX-License-Identifier: Apache-2.0 OR MIT + +Copyright 2022 The Arboard contributors + +The project to which this file belongs is licensed under either of +the Apache 2.0 or the MIT license at the licensee's choice. The terms +and conditions of the chosen license apply to this file. +*/ + +use crate::{ + common::{into_unknown, Error, ImageData, ImageRgba}, + ClipboardData, ClipboardFormat, +}; +use objc2::{ + class, msg_send, msg_send_id, + rc::{autoreleasepool, Id}, + runtime::ProtocolObject, + ClassType, +}; +use objc2_app_kit::{ + NSPasteboard, NSPasteboardType, NSPasteboardTypeFileURL, NSPasteboardTypeHTML, + NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeString, NSPasteboardWriting, +}; +use objc2_foundation::{NSArray, NSData, NSString, NSURL}; +use std::{ + borrow::Cow, + os::raw::c_void, + panic::{RefUnwindSafe, UnwindSafe}, +}; + +const NS_PASTEBOARD_TYPE_SVG: &str = "public.svg-image"; + +mod url_encode { + use percent_encoding::AsciiSet; + const ENCODE_SET: AsciiSet = percent_encoding::CONTROLS.add(b' ').add(b'-').add(b'%'); + + pub(super) fn encode_path_to_uri(path: &str) -> String { + let encoded = percent_encoding::percent_encode(path.as_bytes(), &ENCODE_SET).to_string(); + format!("file://{}", encoded) + } +} + +/// Returns an NSImage object on success. +fn image_from_pixels( + pixels: Vec, + width: usize, + height: usize, +) -> Result, Box> { + use core_graphics::{ + base::{kCGBitmapByteOrderDefault, kCGImageAlphaLast, kCGRenderingIntentDefault, CGFloat}, + color_space::CGColorSpace, + data_provider::{CGDataProvider, CustomData}, + image::{CGImage, CGImageRef}, + }; + use objc2_app_kit::NSImage; + use objc2_foundation::NSSize; + use std::ffi::c_void; + + #[derive(Debug)] + struct PixelArray { + data: Vec, + } + + impl CustomData for PixelArray { + unsafe fn ptr(&self) -> *const u8 { + self.data.as_ptr() + } + unsafe fn len(&self) -> usize { + self.data.len() + } + } + + let colorspace = CGColorSpace::create_device_rgb(); + let pixel_data: Box> = Box::new(Box::new(PixelArray { data: pixels })); + let provider = unsafe { CGDataProvider::from_custom_data(pixel_data) }; + + let cg_image = CGImage::new( + width, + height, + 8, + 32, + 4 * width, + &colorspace, + kCGBitmapByteOrderDefault | kCGImageAlphaLast, + &provider, + false, + kCGRenderingIntentDefault, + ); + + // Convert the owned `CGImage` into a reference `&CGImageRef`, and pass + // that as `*const c_void`, since `CGImageRef` does not implement + // `RefEncode`. + let cg_image: *const CGImageRef = &*cg_image; + let cg_image: *const c_void = cg_image.cast(); + + let size = NSSize { width: width as CGFloat, height: height as CGFloat }; + // XXX: Use `NSImage::initWithCGImage_size` once `objc2-app-kit` supports + // CoreGraphics. + let image: Id = + unsafe { msg_send_id![NSImage::alloc(), initWithCGImage: cg_image, size:size] }; + + Ok(image) +} + +pub(crate) struct Clipboard { + pasteboard: Id, +} + +unsafe impl Send for Clipboard {} +unsafe impl Sync for Clipboard {} +impl UnwindSafe for Clipboard {} +impl RefUnwindSafe for Clipboard {} + +impl Clipboard { + pub(crate) fn new() -> Result { + // Rust only supports 10.7+, while `generalPasteboard` first appeared + // in 10.0, so this should always be available. + // + // However, in some edge cases, like running under launchd (in some + // modes) as a daemon, the clipboard object may be unavailable, and + // then `generalPasteboard` will return NULL even though it's + // documented not to. + // + // Otherwise we'd just use `NSPasteboard::generalPasteboard()` here. + let pasteboard: Option> = + unsafe { msg_send_id![NSPasteboard::class(), generalPasteboard] }; + + if let Some(pasteboard) = pasteboard { + Ok(Clipboard { pasteboard }) + } else { + Err(Error::ClipboardNotSupported) + } + } + + fn clear(&mut self) { + unsafe { self.pasteboard.clearContents() }; + } + + // fn get_binary_contents(&mut self) -> Result, Box> { + // let string_class: Id = { + // let cls: Id = unsafe { Id::from_ptr(class("NSString")) }; + // unsafe { transmute(cls) } + // }; + // let image_class: Id = { + // let cls: Id = unsafe { Id::from_ptr(class("NSImage")) }; + // unsafe { transmute(cls) } + // }; + // let url_class: Id = { + // let cls: Id = unsafe { Id::from_ptr(class("NSURL")) }; + // unsafe { transmute(cls) } + // }; + // let classes = vec![url_class, image_class, string_class]; + // let classes: Id> = NSArray::from_vec(classes); + // let options: Id> = NSDictionary::new(); + // let contents: Id> = unsafe { + // let obj: *mut NSArray = + // msg_send![self.pasteboard, readObjectsForClasses:&*classes options:&*options]; + // if obj.is_null() { + // return Err(err("pasteboard#readObjectsForClasses:options: returned null")); + // } + // Id::from_ptr(obj) + // }; + // if contents.count() == 0 { + // Ok(None) + // } else { + // let obj = &contents[0]; + // if obj.is_kind_of(Class::get("NSString").unwrap()) { + // let s: &NSString = unsafe { transmute(obj) }; + // Ok(Some(ClipboardContent::Utf8(s.as_str().to_owned()))) + // } else if obj.is_kind_of(Class::get("NSImage").unwrap()) { + // let tiff: &NSArray = unsafe { msg_send![obj, TIFFRepresentation] }; + // let len: usize = unsafe { msg_send![tiff, length] }; + // let bytes: *const u8 = unsafe { msg_send![tiff, bytes] }; + // let vec = unsafe { std::slice::from_raw_parts(bytes, len) }; + // // Here we copy the entire &[u8] into a new owned `Vec` + // // Is there another way that doesn't copy multiple megabytes? + // Ok(Some(ClipboardContent::Tiff(vec.into()))) + // } else if obj.is_kind_of(Class::get("NSURL").unwrap()) { + // let s: &NSString = unsafe { msg_send![obj, absoluteString] }; + // Ok(Some(ClipboardContent::Utf8(s.as_str().to_owned()))) + // } else { + // // let cls: &Class = unsafe { msg_send![obj, class] }; + // // println!("{}", cls.name()); + // Err(err("pasteboard#readObjectsForClasses:options: returned unknown class")) + // } + // } + // } +} + +pub(crate) struct Get<'clipboard> { + clipboard: &'clipboard Clipboard, +} + +impl<'clipboard> Get<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard } + } + + #[inline] + pub(crate) fn text(self) -> Result { + unsafe { self.plain(NSPasteboardTypeString) } + } + + #[inline] + pub(crate) fn rtf(self) -> Result { + unsafe { self.plain(NSPasteboardTypeRTF) } + } + + #[inline] + pub(crate) fn html(self) -> Result { + unsafe { self.plain(NSPasteboardTypeHTML) } + } + + fn plain(self, r#type: &NSPasteboardType) -> Result { + // XXX: There does not appear to be an alternative for obtaining text without the need for + // autorelease behavior. + autoreleasepool(|_| { + // XXX: We explicitly use `pasteboardItems` and not `stringForType` since the latter will concat + // multiple strings, if present, into one and return it instead of reading just the first which is `arboard`'s + // historical behavior. + let contents = + unsafe { self.clipboard.pasteboard.pasteboardItems() }.ok_or_else(|| { + Error::Unknown { + description: String::from("NSPasteboard#pasteboardItems errored"), + } + })?; + + for item in contents { + if let Some(string) = unsafe { item.stringForType(r#type) } { + return Ok(string.to_string()); + } + } + + Err(Error::ContentNotAvailable) + }) + } + + pub(crate) fn image(self) -> Result, Error> { + match self.image_svg() { + Err(Error::ContentNotAvailable) => match self.image_png() { + Ok(image) => Ok(image), + Err(Error::ContentNotAvailable) => self.image_tiff(), + Err(e) => Err(e), + }, + result => result, + } + } + + fn image_tiff(&self) -> Result, Error> { + use objc2_app_kit::NSPasteboardTypeTIFF; + use std::io::Cursor; + + // XXX: There does not appear to be an alternative for obtaining images without the need for + // autorelease behavior. + let image = autoreleasepool(|_| { + let image_data = unsafe { self.clipboard.pasteboard.dataForType(NSPasteboardTypeTIFF) } + .ok_or(Error::ContentNotAvailable)?; + + let data = Cursor::new(image_data.bytes()); + + let reader = image::io::Reader::with_format(data, image::ImageFormat::Tiff); + reader.decode().map_err(|e| into_unknown("failed to decode tiff", e)) + })?; + + let rgba = image.into_rgba8(); + let (width, height) = rgba.dimensions(); + + Ok(ImageData::rgba(width as _, height as _, rgba.into_raw().into())) + } + + fn image_png(&self) -> Result, Error> { + autoreleasepool(|_| { + let image_data = unsafe { self.clipboard.pasteboard.dataForType(NSPasteboardTypePNG) } + .ok_or(Error::ContentNotAvailable)?; + Ok(ImageData::png(image_data.bytes().to_owned().into())) + }) + } + + fn image_svg(&self) -> Result, Error> { + autoreleasepool(|_| { + let image_data = unsafe { + self.clipboard.pasteboard.stringForType(&NSString::from_str(NS_PASTEBOARD_TYPE_SVG)) + } + .ok_or(Error::ContentNotAvailable)?; + Ok(ImageData::Svg(image_data.to_string())) + }) + } + + pub(crate) fn special(self, format_name: &str) -> Result, Error> { + autoreleasepool(|_| { + let contents = + unsafe { self.clipboard.pasteboard.pasteboardItems() }.ok_or_else(|| { + Error::Unknown { + description: String::from("NSPasteboard#pasteboardItems errored"), + } + })?; + + for item in contents { + if let Some(data) = unsafe { item.dataForType(&NSString::from_str(format_name)) } { + return Ok(data.bytes().to_vec()); + } + } + + Err(Error::ContentNotAvailable) + }) + } + + pub(crate) fn formats(self, formats: &[ClipboardFormat]) -> Result, Error> { + autoreleasepool(|_| { + let contents = + unsafe { self.clipboard.pasteboard.pasteboardItems() }.ok_or_else(|| { + Error::Unknown { + description: String::from("NSPasteboard#pasteboardItems errored"), + } + })?; + + let mut results = Vec::new(); + for format in formats { + let pre_size = results.len(); + let mut file_urls = Vec::new(); + for item in contents.iter() { + match format { + ClipboardFormat::Text => { + if let Some(string) = + unsafe { item.stringForType(NSPasteboardTypeString) } + { + results.push(ClipboardData::Text(string.to_string())); + break; + } + } + ClipboardFormat::Rtf => { + if let Some(string) = unsafe { item.stringForType(NSPasteboardTypeRTF) } + { + results.push(ClipboardData::Rtf(string.to_string())); + break; + } + } + ClipboardFormat::Html => { + if let Some(string) = + unsafe { item.stringForType(NSPasteboardTypeHTML) } + { + results.push(ClipboardData::Html(string.to_string())); + break; + } + } + ClipboardFormat::ImageRgba => match self.image_tiff() { + Ok(image) => { + results.push(ClipboardData::Image(image)); + break; + } + Err(Error::ContentNotAvailable) => {} + Err(e) => { + log::debug!("Error reading image: {:?}", e); + break; + } + }, + ClipboardFormat::ImagePng => match self.image_png() { + Ok(image) => { + results.push(ClipboardData::Image(image)); + break; + } + Err(Error::ContentNotAvailable) => {} + Err(e) => { + log::debug!("Error reading image: {:?}", e); + break; + } + }, + ClipboardFormat::ImageSvg => match self.image_svg() { + Ok(image) => { + results.push(ClipboardData::Image(image)); + break; + } + Err(Error::ContentNotAvailable) => {} + Err(e) => { + log::debug!("Error reading image: {:?}", e); + break; + } + }, + ClipboardFormat::FileUrl => unsafe { + if let Some(urls) = item.stringForType(NSPasteboardTypeFileURL) { + let Some(urls) = NSURL::URLWithString(&urls) else { + log::debug!("Error converting to NSURL"); + break; + }; + if let Some(path) = urls.path() { + file_urls.push(path.to_string()); + } + } + }, + ClipboardFormat::Special(format_name) => { + if let Some(data) = + unsafe { item.dataForType(&NSString::from_str(format_name)) } + { + results.push(ClipboardData::Special(( + format_name.to_string(), + data.bytes().to_vec(), + ))); + break; + } + } + } + } + if !file_urls.is_empty() { + results.push(ClipboardData::FileUrl(file_urls)); + } + + if results.len() == pre_size { + results.push(ClipboardData::None); + } + } + Ok(results) + }) + } +} + +pub(crate) struct Set<'clipboard> { + clipboard: &'clipboard mut Clipboard, +} + +impl<'clipboard> Set<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard } + } + + pub(crate) fn text(mut self, data: Cow<'_, str>) -> Result<(), Error> { + self.text_(data, true) + } + + fn text_(&mut self, data: Cow<'_, str>, clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + let string_array = + NSArray::from_vec(vec![ProtocolObject::from_id(NSString::from_str(&data))]); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&string_array) }; + if success { + Ok(()) + } else { + Err(Error::Unknown { description: "NSPasteboard#writeObjects: returned false".into() }) + } + } + + pub(crate) fn rtf(mut self, data: Cow<'_, str>) -> Result<(), Error> { + self.rtf_(data, true) + } + + fn rtf_(&mut self, data: Cow<'_, str>, clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + let success = unsafe { + self.clipboard + .pasteboard + .setString_forType(&NSString::from_str(&data), NSPasteboardTypeRTF) + }; + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: "NSPasteboard#setString_forType: returned false".into(), + }) + } + } + + pub(crate) fn html( + mut self, + html: Cow<'_, str>, + alt: Option>, + ) -> Result<(), Error> { + self.html_(html, alt, true) + } + + fn try_wrap_html(html: Cow<'_, str>) -> Id { + // Text goes to the clipboard as UTF-8 but may be interpreted as Windows Latin 1. + // This wrapping forces it to be interpreted as UTF-8. + // + // See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=466599 + // https://bugs.chromium.org/p/chromium/issues/detail?id=11957 + let wrap_prefix = r#""#; + let wrap_suffix = ""; + if html.starts_with(wrap_prefix) { + NSString::from_str(&html) + } else { + let html = format!("{wrap_prefix}{html}{wrap_suffix}",); + NSString::from_str(&html) + } + } + + fn html_( + &mut self, + html: Cow<'_, str>, + alt: Option>, + clear: bool, + ) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + let html_nss = Self::try_wrap_html(html); + // Make sure that we pass a pointer to the string and not the object itself. + let mut success = + unsafe { self.clipboard.pasteboard.setString_forType(&html_nss, NSPasteboardTypeHTML) }; + if success { + if let Some(alt_text) = alt { + let alt_nss = NSString::from_str(&alt_text); + // Similar to the primary string, we only want a pointer here too. + success = unsafe { + self.clipboard.pasteboard.setString_forType(&alt_nss, NSPasteboardTypeString) + }; + } + } + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: "NSPasteboard#setString_forType: returned false".into(), + }) + } + } + + pub(crate) fn image(mut self, data: ImageData) -> Result<(), Error> { + self.image_(data, true) + } + + fn image_(&mut self, data: ImageData, clear: bool) -> Result<(), Error> { + match data { + ImageData::Rgba(data) => self.image_pixels(data, clear), + ImageData::Png(data) => self.image_png(&data, clear), + ImageData::Svg(data) => self.image_svg(data, clear), + } + } + + pub(crate) fn image_pixels(&mut self, data: ImageRgba, clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + + let pixels = data.bytes.into(); + let image = image_from_pixels(pixels, data.width, data.height) + .map_err(|e| into_unknown("failed to get image from pixels", e))?; + + let image_array = NSArray::from_vec(vec![ProtocolObject::from_id(image)]); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&image_array) }; + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: + "Failed to write the image to the pasteboard (`writeObjects` returned NO)." + .into(), + }) + } + } + + pub(crate) fn image_png(&mut self, data: &[u8], clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + + autoreleasepool(|_| { + let success = unsafe { + let nsdata: *const objc2_foundation::NSData = msg_send![class!(NSData), dataWithBytes:data.as_ptr() as *const c_void length:data.len() as u64]; + if nsdata.is_null() { + return Err(Error::Unknown { + description: "Failed to create NSData from bytes".into(), + }); + } + + self.clipboard + .pasteboard + .setData_forType(Some(&*(nsdata as *const NSData)), NSPasteboardTypePNG) + }; + + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: "Failed to write the PNG image to the pasteboard.".into(), + }) + } + }) + } + + pub(crate) fn image_svg(&mut self, data: String, clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + + let svg = NSString::from_str(&data); + let success = unsafe { + self.clipboard + .pasteboard + .setString_forType(&svg, &NSString::from_str(NS_PASTEBOARD_TYPE_SVG)) + }; + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: "Failed to write the SVG image to the pasteboard.".into(), + }) + } + } + + pub(crate) fn special(mut self, format_name: &str, data: &[u8]) -> Result<(), Error> { + self.special_(format_name, data, true) + } + + fn special_(&mut self, format_name: &str, data: &[u8], clear: bool) -> Result<(), Error> { + if clear { + self.clipboard.clear(); + } + autoreleasepool(|_| { + let success = unsafe { + let nsdata: *const objc2_foundation::NSData = msg_send![class!(NSData), dataWithBytes:data.as_ptr() as *const c_void length:data.len() as u64]; + if nsdata.is_null() { + return Err(Error::Unknown { + description: "Failed to create NSData from bytes".into(), + }); + } + + self.clipboard.pasteboard.setData_forType( + Some(&*(nsdata as *const NSData)), + &NSString::from_str(format_name), + ) + }; + if success { + Ok(()) + } else { + Err(Error::Unknown { + description: "NSPasteboard#setData_forType: returned false".into(), + }) + } + }) + } + + pub(crate) fn formats(self, data: &[ClipboardData]) -> Result<(), Error> { + self.clipboard.clear(); + + autoreleasepool(|_| unsafe { + // Use a single NSPasteboardItem for text-based formats (Text, Rtf, Html, Special) + // so they are treated as multiple representations of the same content, + // not as separate items that would all be pasted. + // + // Design note: Special data (e.g., owner marker) is placed in main_item along with + // text formats, while Image/FileUrl use separate items. Ideally Special should be + // in the same item as Image/FileUrl too, but this doesn't affect functionality + // since RustDesk iterates all items when reading. Can be optimized later if needed. + let main_item = objc2_app_kit::NSPasteboardItem::new(); + let mut has_main_item_data = false; + + let mut write_objects: Vec>> = + vec![]; + + for d in data { + match d { + // Text-based formats go into the main_item as different representations + ClipboardData::Text(text) => { + main_item + .setString_forType(&NSString::from_str(&text), NSPasteboardTypeString); + has_main_item_data = true; + } + ClipboardData::Rtf(rtf) => { + main_item.setString_forType(&NSString::from_str(&rtf), NSPasteboardTypeRTF); + has_main_item_data = true; + } + ClipboardData::Html(html) => { + main_item + .setString_forType(&NSString::from_str(&html), NSPasteboardTypeHTML); + has_main_item_data = true; + } + // Image and FileUrl use separate items as they require different object types + ClipboardData::Image(data) => match data { + ImageData::Rgba(data) => { + let pixels = data.bytes.clone().into(); + let image = image_from_pixels(pixels, data.width, data.height) + .map_err(|e| into_unknown("failed to get rgba from pixels", e))?; + write_objects.push(ProtocolObject::from_id(image)); + } + ImageData::Png(data) => { + let nsdata: *const objc2_foundation::NSData = msg_send![class!(NSData), dataWithBytes:data.as_ptr() as *const c_void length:data.len() as u64]; + if nsdata.is_null() { + return Err(Error::Unknown { + description: "Failed to create NSData from bytes".into(), + }); + } + let item = objc2_app_kit::NSPasteboardItem::new(); + item.setData_forType(&*(nsdata as *const NSData), NSPasteboardTypePNG); + write_objects.push(ProtocolObject::from_id(item)); + } + ImageData::Svg(data) => { + let item = objc2_app_kit::NSPasteboardItem::new(); + item.setString_forType( + &NSString::from_str(&data), + &NSString::from_str(NS_PASTEBOARD_TYPE_SVG), + ); + write_objects.push(ProtocolObject::from_id(item)); + } + }, + ClipboardData::FileUrl(urls) => { + for url in urls.iter() { + let url = url_encode::encode_path_to_uri(url); + let item = objc2_app_kit::NSPasteboardItem::new(); + item.setString_forType( + &NSString::from_str(&url), + NSPasteboardTypeFileURL, + ); + write_objects.push(ProtocolObject::from_id(item)); + } + } + ClipboardData::Special((format_name, data)) => { + let nsdata: *const objc2_foundation::NSData = msg_send![class!(NSData), dataWithBytes:data.as_ptr() as *const c_void length:data.len() as u64]; + if nsdata.is_null() { + return Err(Error::Unknown { + description: "Failed to create NSData from bytes".into(), + }); + } + main_item.setData_forType( + &*(nsdata as *const NSData), + &NSString::from_str(format_name), + ); + has_main_item_data = true; + } + _ => {} + } + } + + // Add the main item first if it has data + if has_main_item_data { + write_objects.insert(0, ProtocolObject::from_id(main_item)); + } + + if write_objects.is_empty() { + return Ok(()); + } + + if !self.clipboard.pasteboard.writeObjects(&NSArray::from_vec(write_objects)) { + return Err(Error::Unknown { + description: "NSPasteboard#writeObjects: returned false".into(), + }); + } + Ok(()) + })?; + + Ok(()) + } +} + +pub(crate) struct Clear<'clipboard> { + clipboard: &'clipboard mut Clipboard, +} + +impl<'clipboard> Clear<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard } + } + + pub(crate) fn clear(self) -> Result<(), Error> { + self.clipboard.clear(); + Ok(()) + } +} diff --git a/third_party/arboard/src/platform/windows.rs b/third_party/arboard/src/platform/windows.rs new file mode 100644 index 00000000000..0def864b5c3 --- /dev/null +++ b/third_party/arboard/src/platform/windows.rs @@ -0,0 +1,1290 @@ +/* +SPDX-License-Identifier: Apache-2.0 OR MIT + +Copyright 2022 The Arboard contributors + +The project to which this file belongs is licensed under either of +the Apache 2.0 or the MIT license at the licensee's choice. The terms +and conditions of the chosen license apply to this file. +*/ + +use crate::{ + common::{into_unknown, private, Error, ImageData, ImageRgba}, + ClipboardData, ClipboardFormat, +}; +use clipboard_win::{formats::Html, options, Getter}; +use std::{borrow::Cow, marker::PhantomData, thread, time::Duration}; +use windows_sys::Win32::Foundation::{ + ERROR_CLIPBOARD_NOT_OPEN, ERROR_IO_INCOMPLETE, ERROR_NOT_FOUND, ERROR_SUCCESS, +}; + +const CFSTR_MIME_RICHTEXT: &str = "text/richtext"; +const CFSTR_MIME_PNG: &str = "image/png"; +const CFSTR_MIME_SVG_XML: &str = "image/svg+xml"; + +// If there're multiple threads or processes trying to access the clipboard at the same time, +// the previous clipboard owner will fail to access the clipboard. +// This is a common issue on Windows, so we just return `ClipboardOccupied` in this case. +// The user can retry the operation later. +#[inline] +fn last_error(msg: &str) -> Error { + let last_err = std::io::Error::last_os_error(); + let raw_os_error = last_err.raw_os_error().unwrap_or(ERROR_SUCCESS as _); + if raw_os_error == ERROR_CLIPBOARD_NOT_OPEN as _ || raw_os_error == ERROR_IO_INCOMPLETE as _ { + Error::ClipboardOccupied + } else if raw_os_error == ERROR_NOT_FOUND as _ { + Error::ContentNotAvailable + } else { + into_unknown(msg, last_err) + } +} + +#[inline] +fn map_error_code(msg: &str, error_code: clipboard_win::ErrorCode) -> Error { + let raw_code = error_code.raw_code(); + if raw_code == ERROR_CLIPBOARD_NOT_OPEN as _ || raw_code == ERROR_IO_INCOMPLETE as _ { + Error::ClipboardOccupied + } else if raw_code == ERROR_NOT_FOUND as _ { + Error::ContentNotAvailable + } else { + into_unknown(msg, error_code) + } +} + +mod image_data { + use super::*; + use crate::common::ScopeGuard; + use image::{codecs::png::PngEncoder, ExtendedColorType, ImageEncoder}; + use std::{convert::TryInto, ffi::c_void, io, mem::size_of, ptr::copy_nonoverlapping}; + use windows_sys::Win32::{ + Foundation::HGLOBAL, + Graphics::Gdi::{ + CreateDIBitmap, DeleteObject, GetDC, GetDIBits, BITMAPINFO, BITMAPINFOHEADER, + BITMAPV5HEADER, BI_BITFIELDS, BI_RGB, CBM_INIT, DIB_RGB_COLORS, HBITMAP, HDC, + LCS_GM_IMAGES, RGBQUAD, + }, + System::{ + DataExchange::SetClipboardData, + Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GHND}, + Ole::CF_DIBV5, + }, + }; + + unsafe fn global_unlock_checked(hdata: isize) { + // If the memory object is unlocked after decrementing the lock count, the function + // returns zero and GetLastError returns NO_ERROR. If it fails, the return value is + // zero and GetLastError returns a value other than NO_ERROR. + if GlobalUnlock(hdata) == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(0) { + log::error!("Failed calling GlobalUnlock when writing data: {}", err); + } + } + } + + pub(super) fn add_cf_dibv5(image: ImageRgba) -> Result<(), Error> { + // This constant is missing in windows-rs + // https://github.com/microsoft/windows-rs/issues/2711 + #[allow(non_upper_case_globals)] + const LCS_sRGB: u32 = 0x7352_4742; + + let header_size = size_of::(); + let header = BITMAPV5HEADER { + bV5Size: header_size as u32, + bV5Width: image.width as i32, + bV5Height: image.height as i32, + bV5Planes: 1, + bV5BitCount: 32, + bV5Compression: BI_BITFIELDS, + bV5SizeImage: (4 * image.width * image.height) as u32, + bV5XPelsPerMeter: 0, + bV5YPelsPerMeter: 0, + bV5ClrUsed: 0, + bV5ClrImportant: 0, + bV5RedMask: 0x00ff0000, + bV5GreenMask: 0x0000ff00, + bV5BlueMask: 0x000000ff, + bV5AlphaMask: 0xff000000, + bV5CSType: LCS_sRGB, + // SAFETY: Windows ignores this field because `bV5CSType` is not set to `LCS_CALIBRATED_RGB`. + bV5Endpoints: unsafe { std::mem::zeroed() }, + bV5GammaRed: 0, + bV5GammaGreen: 0, + bV5GammaBlue: 0, + bV5Intent: LCS_GM_IMAGES as u32, // I'm not sure about this. + bV5ProfileData: 0, + bV5ProfileSize: 0, + bV5Reserved: 0, + }; + + // In theory we don't need to flip the image because we could just specify + // a negative height in the header, which according to the documentation, indicates that the + // image rows are in top-to-bottom order. HOWEVER: MS Word (and WordPad) cannot paste an image + // that has a negative height in its header. + let image = flip_v(image); + + let data_size = header_size + image.bytes.len(); + let hdata = unsafe { global_alloc(data_size)? }; + unsafe { + let data_ptr = global_lock(hdata)?; + let _unlock = ScopeGuard::new(|| global_unlock_checked(hdata)); + + copy_nonoverlapping::((&header) as *const _ as *const u8, data_ptr, header_size); + + // Not using the `add` function, because that has a restriction, that the result cannot overflow isize + let pixels_dst = (data_ptr as usize + header_size) as *mut u8; + copy_nonoverlapping::(image.bytes.as_ptr(), pixels_dst, image.bytes.len()); + + let dst_pixels_slice = std::slice::from_raw_parts_mut(pixels_dst, image.bytes.len()); + + // If the non-allocating version of the function failed, we need to assign the new bytes to + // the global allocation. + if let Cow::Owned(new_pixels) = rgba_to_win(dst_pixels_slice) { + // SAFETY: `data_ptr` is valid to write to and has no outstanding mutable borrows, and + // `new_pixels` will be the same length as the original bytes. + copy_nonoverlapping::(new_pixels.as_ptr(), data_ptr, new_pixels.len()) + } + } + + if unsafe { SetClipboardData(CF_DIBV5 as u32, hdata as _) } == 0 { + unsafe { DeleteObject(hdata as _) }; + Err(last_error("SetClipboardData failed with error")) + } else { + Ok(()) + } + } + + pub(super) fn add_png_file_from_rgba(image: &ImageRgba) -> Result<(), Error> { + // Try encoding the image as PNG. + let mut buf = Vec::new(); + let encoder = PngEncoder::new(&mut buf); + encoder + .write_image( + &image.bytes, + image.width as u32, + image.height as u32, + ExtendedColorType::Rgba8, + ) + .map_err(|e| into_unknown("failed to write png from rgba", e))?; + add_png_file(&buf) + } + + pub(super) fn add_png_file(buf: &[u8]) -> Result<(), Error> { + // Register PNG format. + let format_id = register_format_(CFSTR_MIME_PNG)?; + + let data_size = buf.len(); + let hdata = unsafe { global_alloc(data_size)? }; + + unsafe { + let pixels_dst = global_lock(hdata)?; + copy_nonoverlapping::(buf.as_ptr(), pixels_dst, data_size); + global_unlock_checked(hdata); + } + + if unsafe { SetClipboardData(format_id, hdata as _) } == 0 { + unsafe { DeleteObject(hdata as _) }; + Err(last_error("SetClipboardData failed with error")) + } else { + Ok(()) + } + } + + unsafe fn global_alloc(bytes: usize) -> Result { + let hdata = GlobalAlloc(GHND, bytes); + if hdata == 0 { + Err(last_error("Could not allocate global memory object")) + } else { + Ok(hdata) + } + } + + unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, Error> { + let data_ptr = GlobalLock(hmem) as *mut u8; + if data_ptr.is_null() { + Err(last_error("Could not lock the global memory object")) + } else { + Ok(data_ptr) + } + } + + pub(super) fn read_cf_dibv5(dibv5: &[u8]) -> Result, Error> { + // The DIBV5 format is a BITMAPV5HEADER followed by the pixel data according to + // https://docs.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats + + // These constants are missing in windows-rs + const PROFILE_EMBEDDED: u32 = 0x4D42_4544; + const PROFILE_LINKED: u32 = 0x4C49_4E4B; + + // so first let's get a pointer to the header + let header_size = size_of::(); + if dibv5.len() < header_size { + return Err(Error::unknown("When reading the DIBV5 data, it contained fewer bytes than the BITMAPV5HEADER size. This is invalid.")); + } + let header = unsafe { &*(dibv5.as_ptr() as *const BITMAPV5HEADER) }; + + let has_profile = + header.bV5CSType == PROFILE_LINKED || header.bV5CSType == PROFILE_EMBEDDED; + + let pixel_data_start = if has_profile { + header.bV5ProfileData as isize + header.bV5ProfileSize as isize + } else { + header_size as isize + }; + + unsafe { + let image_bytes = dibv5.as_ptr().offset(pixel_data_start) as *const _; + let hdc = get_screen_device_context()?; + let hbitmap = create_bitmap_from_dib(hdc, header as _, image_bytes)?; + // Now extract the pixels in a desired format + let w = header.bV5Width; + let h = header.bV5Height.abs(); + let result_size = w as usize * h as usize * 4; + + let mut result_bytes = Vec::::with_capacity(result_size); + + let mut output_header = BITMAPINFO { + bmiColors: [RGBQUAD { rgbRed: 0, rgbGreen: 0, rgbBlue: 0, rgbReserved: 0 }], + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: w, + biHeight: -h, + biBitCount: 32, + biPlanes: 1, + biCompression: BI_RGB as u32, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }, + }; + + let lines = convert_bitmap_to_rgb( + hdc, + hbitmap, + h as _, + result_bytes.as_mut_ptr() as _, + &mut output_header as _, + )?; + let read_len = lines as usize * w as usize * 4; + assert!( + read_len <= result_bytes.capacity(), + "Segmentation fault. Read more bytes than allocated to pixel buffer", + ); + result_bytes.set_len(read_len); + + let mut result_bytes = win_to_rgba(&mut result_bytes); + repair_missing_alpha(&mut result_bytes, dibv5_alpha_format(header)); + + let result = ImageData::rgba(w as _, h as _, Cow::Owned(result_bytes)); + Ok(result) + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Dibv5AlphaFormat { + Present, + Missing, + Unknown, + } + + fn dibv5_alpha_format(header: &BITMAPV5HEADER) -> Dibv5AlphaFormat { + // Some applications set bV5AlphaMask even with BI_RGB, despite the docs + // saying the high byte is unused for BI_RGB. Preserve alpha whenever the + // producer explicitly provides an alpha mask. + if header.bV5AlphaMask != 0 { + return Dibv5AlphaFormat::Present; + } + + match header.bV5Compression { + BI_RGB | BI_BITFIELDS => Dibv5AlphaFormat::Missing, + _ => Dibv5AlphaFormat::Unknown, + } + } + + fn repair_missing_alpha(bytes: &mut [u8], alpha_format: Dibv5AlphaFormat) -> bool { + match alpha_format { + Dibv5AlphaFormat::Present => false, + Dibv5AlphaFormat::Missing => set_alpha_opaque(bytes), + Dibv5AlphaFormat::Unknown => repair_missing_alpha_if_opaque_rgb(bytes), + } + } + + fn set_alpha_opaque(bytes: &mut [u8]) -> bool { + debug_assert_eq!(bytes.len() % 4, 0); + + let mut changed = false; + for pixel in bytes.chunks_exact_mut(4) { + changed |= pixel[3] != 255; + pixel[3] = 255; + } + + changed + } + + /// Some Windows screenshot tools put 32-bit DIB data on the clipboard with + /// the alpha byte left as zero for every pixel even though the RGB channels + /// contain the visible screenshot. Use this only when the DIBV5 header does + /// not tell us whether the alpha channel is present. + fn repair_missing_alpha_if_opaque_rgb(bytes: &mut [u8]) -> bool { + debug_assert_eq!(bytes.len() % 4, 0); + + let mut has_rgb_content = false; + let mut has_alpha_content = false; + + for pixel in bytes.chunks_exact(4) { + has_rgb_content |= pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0; + has_alpha_content |= pixel[3] != 0; + + if has_rgb_content && has_alpha_content { + return false; + } + } + + if has_rgb_content && !has_alpha_content { + for pixel in bytes.chunks_exact_mut(4) { + pixel[3] = 255; + } + true + } else { + false + } + } + + fn get_screen_device_context() -> Result { + // SAFETY: Calling `GetDC` with `NULL` is safe. + let hdc = unsafe { GetDC(0) }; + if hdc == 0 { + Err(Error::unknown("Failed to get the device context. GetDC returned null")) + } else { + Ok(hdc) + } + } + + unsafe fn create_bitmap_from_dib( + hdc: HDC, + header: *const BITMAPV5HEADER, + image_bytes: *const c_void, + ) -> Result { + let hbitmap = CreateDIBitmap( + hdc, + header as _, + CBM_INIT as u32, + image_bytes, + header as _, + DIB_RGB_COLORS, + ); + if hbitmap == 0 { + Err(Error::unknown( + "Failed to create the HBITMAP while reading DIBV5. CreateDIBitmap returned null", + )) + } else { + Ok(hbitmap) + } + } + + /// Copies the bitmap image into given buffer with DIB RGB format and + /// returns the number of scan lines copied from the bitmap. + unsafe fn convert_bitmap_to_rgb( + hdc: HDC, + hbitmap: HBITMAP, + lines: u32, + dst: *mut c_void, + header: *mut BITMAPINFO, + ) -> Result { + let lines = GetDIBits(hdc, hbitmap, 0, lines, dst, header, DIB_RGB_COLORS); + if lines == 0 { + Err(Error::unknown("Could not get the bitmap bits, GetDIBits returned 0")) + } else { + Ok(lines) + } + } + + /// Converts the RGBA (u8) pixel data into the bitmap-native ARGB (u32) + /// format in-place. + /// + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + #[allow(clippy::identity_op, clippy::erasing_op)] + #[must_use] + pub(super) unsafe fn rgba_to_win(bytes: &mut [u8]) -> Cow<'_, [u8]> { + // Check safety invariants to catch obvious bugs. + debug_assert_eq!(bytes.len() % 4, 0); + + let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); + let u32pixels = match u32pixels_buffer { + ImageDataCow::Borrowed(ref mut b) => b, + ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), + }; + + for p in u32pixels.iter_mut() { + let [mut r, mut g, mut b, mut a] = p.to_ne_bytes().map(u32::from); + r <<= 2 * 8; + g <<= 1 * 8; + b <<= 0 * 8; + a <<= 3 * 8; + + *p = r | g | b | a; + } + + match u32pixels_buffer { + ImageDataCow::Borrowed(_) => Cow::Borrowed(bytes), + ImageDataCow::Owned(bytes) => { + Cow::Owned(bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect()) + } + } + } + + /// Vertically flips the image pixels in memory + fn flip_v(image: ImageRgba) -> ImageRgba<'static> { + let w = image.width; + let h = image.height; + + let mut bytes = image.bytes.into_owned(); + + let rowsize = w * 4; // each pixel is 4 bytes + let mut tmp_a = vec![0; rowsize]; + // I believe this could be done safely with `as_chunks_mut`, but that's not stable yet + for a_row_id in 0..(h / 2) { + let b_row_id = h - a_row_id - 1; + + // swap rows `first_id` and `second_id` + let a_byte_start = a_row_id * rowsize; + let a_byte_end = a_byte_start + rowsize; + let b_byte_start = b_row_id * rowsize; + let b_byte_end = b_byte_start + rowsize; + tmp_a.copy_from_slice(&bytes[a_byte_start..a_byte_end]); + bytes.copy_within(b_byte_start..b_byte_end, a_byte_start); + bytes[b_byte_start..b_byte_end].copy_from_slice(&tmp_a); + } + + ImageRgba { width: image.width, height: image.height, bytes: bytes.into() } + } + + /// Converts the ARGB (u32) pixel data into the RGBA (u8) format in-place + /// + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + #[allow(clippy::identity_op, clippy::erasing_op)] + #[must_use] + pub(super) unsafe fn win_to_rgba(bytes: &mut [u8]) -> Vec { + // Check safety invariants to catch obvious bugs. + debug_assert_eq!(bytes.len() % 4, 0); + + let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); + let u32pixels = match u32pixels_buffer { + ImageDataCow::Borrowed(ref mut b) => b, + ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), + }; + + for p in u32pixels { + let mut bytes = p.to_ne_bytes(); + bytes[0] = (*p >> (2 * 8)) as u8; + bytes[1] = (*p >> (1 * 8)) as u8; + bytes[2] = (*p >> (0 * 8)) as u8; + bytes[3] = (*p >> (3 * 8)) as u8; + *p = u32::from_ne_bytes(bytes); + } + + match u32pixels_buffer { + ImageDataCow::Borrowed(_) => bytes.to_vec(), + ImageDataCow::Owned(bytes) => bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect(), + } + } + + // XXX: std's Cow is not usable here because it does not allow mutably + // borrowing data. + enum ImageDataCow<'a> { + Borrowed(&'a mut [u32]), + Owned(Vec), + } + + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + unsafe fn convert_bytes_to_u32s(bytes: &mut [u8]) -> ImageDataCow<'_> { + // When the correct conditions are upheld, `std` should return everything in the well-aligned slice. + let (prefix, _, suffix) = bytes.align_to::(); + + // Check if `align_to` gave us the optimal result. + // + // If it didn't, use the slow path with more allocations + if prefix.is_empty() && suffix.is_empty() { + // We know that the newly-aligned slice will contain all the values + ImageDataCow::Borrowed(bytes.align_to_mut::().1) + } else { + // XXX: Use `as_chunks` when it stabilizes. + let u32pixels_buffer = bytes + .chunks(4) + .map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap())) + .collect(); + ImageDataCow::Owned(u32pixels_buffer) + } + } +} + +/// A shim clipboard type that can have operations performed with it, but +/// does not represent an open clipboard itself. +/// +/// Windows only allows one thread on the entire system to have the clipboard +/// open at once, so we have to open it very sparingly or risk causing the rest +/// of the system to be unresponsive. Instead, the clipboard is opened for +/// every operation and then closed afterwards. +pub(crate) struct Clipboard(()); + +// The other platforms have `Drop` implementation on their +// clipboard, so Windows should too for consistently. +impl Drop for Clipboard { + fn drop(&mut self) {} +} + +struct OpenClipboard<'clipboard> { + _inner: clipboard_win::Clipboard, + // The Windows clipboard can not be sent between threads once + // open. + _marker: PhantomData<*const ()>, + _for_shim: &'clipboard mut Clipboard, +} + +impl Clipboard { + const DEFAULT_OPEN_ATTEMPTS: usize = 5; + + pub(crate) fn new() -> Result { + Ok(Self(())) + } + + fn open(&mut self) -> Result { + // Attempt to open the clipboard multiple times. On Windows, its common for something else to temporarily + // be using it during attempts. + // + // For past work/evidence, see Firefox(https://searchfox.org/mozilla-central/source/widget/windows/nsClipboard.cpp#421) and + // Chromium(https://source.chromium.org/chromium/chromium/src/+/main:ui/base/clipboard/clipboard_win.cc;l=86). + // + // Note: This does not use `Clipboard::new_attempts` because its implementation sleeps for `0ms`, which can + // cause race conditions between closing/opening the clipboard in single-threaded apps. + let mut attempts = Self::DEFAULT_OPEN_ATTEMPTS; + let clipboard = loop { + match clipboard_win::Clipboard::new() { + Ok(this) => break Ok(this), + Err(err) => match attempts { + 0 => break Err(err), + _ => attempts -= 1, + }, + } + + // The default value matches Chromium's implementation, but could be tweaked later. + thread::sleep(Duration::from_millis(5)); + } + .map_err(|_| Error::ClipboardOccupied)?; + + Ok(OpenClipboard { _inner: clipboard, _marker: PhantomData, _for_shim: self }) + } +} + +// Note: In all of the builders, a clipboard opening result is stored. +// This is done for a few reasons: +// 1. consistently with the other platforms which can have an occupied clipboard. +// It is better if the operation fails at the most similar place on all platforms. +// 2. `{Get, Set, Clear}::new()` don't return a `Result`. Windows is the only case that +// needs this kind of handling, so it doesn't need to affect the other APIs. +// 3. Due to how the clipboard works on Windows, we need to open it for every operation +// and keep it open until its finished. This approach allows RAII to still be applicable. + +pub(crate) struct Get<'clipboard> { + clipboard: Result, Error>, +} + +impl<'clipboard> Get<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard: clipboard.open() } + } + + pub(crate) fn text(self) -> Result { + let _clipboard_assertion = self.clipboard?; + Self::text_() + } + + fn text_() -> Result { + const FORMAT: u32 = clipboard_win::formats::CF_UNICODETEXT; + + // XXX: ToC/ToU race conditions are not possible because we are the sole owners of the clipboard currently. + if !clipboard_win::is_format_avail(FORMAT) { + return Err(Error::ContentNotAvailable); + } + + let Some(text_size) = clipboard_win::raw::size(FORMAT) else { + return Err(last_error("failed to read clipboard text size")); + }; + + // Allocate the specific number of WTF-16 characters we need to receive. + // This division is always accurate because Windows uses 16-bit characters. + let mut out: Vec = vec![0u16; text_size.get() / 2]; + + let bytes_read = { + // SAFETY: The source slice has a greater alignment than the resulting one. + let out: &mut [u8] = + unsafe { std::slice::from_raw_parts_mut(out.as_mut_ptr().cast(), out.len() * 2) }; + + let mut bytes_read = clipboard_win::raw::get(FORMAT, out) + .map_err(|e| map_error_code("failed to read clipboard string", e))?; + + // Convert the number of bytes read to the number of `u16`s + bytes_read /= 2; + + // Remove the NUL terminator, if it existed. + if let Some(last) = out.last().copied() { + if last == 0 { + bytes_read -= 1; + } + } + + bytes_read + }; + + // Create a UTF-8 string from WTF-16 data, if it was valid. + String::from_utf16(&out[..bytes_read]) + .map_err(|e| into_unknown("failed to convert string from utf16", e)) + } + + pub(crate) fn rtf(self) -> Result { + let _clipboard_assertion = self.clipboard?; + Self::rtf_() + } + + #[inline] + fn rtf_() -> Result { + let format = register_format_(CFSTR_MIME_RICHTEXT)?; + + // XXX: ToC/ToU race conditions are not possible because we are the sole owners of the clipboard currently. + if !clipboard_win::is_format_avail(format) { + return Err(Error::ContentNotAvailable); + } + + let mut data = Vec::new(); + clipboard_win::raw::get_vec(format, &mut data) + .map_err(|e| map_error_code("failed to read clipboard image data", e))?; + Ok(String::from_utf8_lossy(&data).into_owned()) + } + + pub(crate) fn html(self) -> Result { + let _clipboard_assertion = self.clipboard?; + Self::html_() + } + + fn html_() -> Result { + match Html::new() { + Some(h) => { + let mut out = Vec::new(); + match h.read_clipboard(&mut out) { + Ok(_s) => {} + Err(e) => { + return Err(map_error_code("failed to read clipboard html", e)); + } + } + String::from_utf8(out) + .map_err(|e| into_unknown("failed to write html from utf8", e)) + } + None => Err(Error::ContentNotAvailable), + } + } + + pub(crate) fn image(self) -> Result, Error> { + let _clipboard_assertion = self.clipboard?; + Self::image_() + } + + fn image_() -> Result, Error> { + match Self::image_svg() { + Err(Error::ContentNotAvailable) => match Self::image_png() { + Err(Error::ContentNotAvailable) => Self::image_dibv5(), + result => result, + }, + result => result, + } + } + + fn image_dibv5() -> Result, Error> { + const FORMAT: u32 = clipboard_win::formats::CF_DIBV5; + + if !clipboard_win::is_format_avail(FORMAT) { + return Err(Error::ContentNotAvailable); + } + + let mut data = Vec::new(); + + clipboard_win::raw::get_vec(FORMAT, &mut data) + .map_err(|e| map_error_code("failed to read clipboard image data", e))?; + + image_data::read_cf_dibv5(&data) + } + + fn image_png() -> Result, Error> { + let format = register_format_(CFSTR_MIME_PNG)?; + if !clipboard_win::is_format_avail(format) { + return Err(Error::ContentNotAvailable); + } + + let mut data = Vec::new(); + clipboard_win::raw::get_vec(format, &mut data) + .map_err(|e| map_error_code("failed to read clipboard image data", e))?; + Ok(ImageData::png(data.into())) + } + + fn image_svg() -> Result, Error> { + let format = register_format_(CFSTR_MIME_SVG_XML)?; + if !clipboard_win::is_format_avail(format) { + return Err(Error::ContentNotAvailable); + } + + let mut data = Vec::new(); + clipboard_win::raw::get_vec(format, &mut data) + .map_err(|e| map_error_code("failed to read clipboard image data", e))?; + Ok(ImageData::Svg(String::from_utf8_lossy(&data).into_owned())) + } + + pub(crate) fn special(self, format_name: &str) -> Result, Error> { + let _clipboard_assertion = self.clipboard?; + Self::special_(format_name) + } + + fn special_(format_name: &str) -> Result, Error> { + let format = register_format_(format_name)?; + if !clipboard_win::is_format_avail(format) { + return Err(Error::ContentNotAvailable); + } + + let mut data = Vec::new(); + clipboard_win::raw::get_vec(format, &mut data) + .map_err(|e| map_error_code("failed to read clipboard data", e))?; + Ok(data) + } + + pub(crate) fn formats(self, formats: &[ClipboardFormat]) -> Result, Error> { + let _clipboard_assertion = self.clipboard?; + + let mut results = Vec::new(); + let mut err = None; + let mut err_count = 0; + for format in formats.iter() { + let mut cur_err = None; + match format { + ClipboardFormat::Text => match Self::text_() { + Ok(text) => { + results.push(ClipboardData::Text(text)); + } + Err(Error::ContentNotAvailable) => { + results.push(ClipboardData::None); + } + Err(e) => { + log::debug!("Error reading text from clipboard: {}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::Rtf => match Self::rtf_() { + Ok(rtf) => results.push(ClipboardData::Rtf(rtf)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading RTF from clipboard: {}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::Html => match Self::html_() { + Ok(html) => results.push(ClipboardData::Html(html)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading HTML from clipboard, {:?}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::ImageRgba => match Self::image_dibv5() { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading image from clipboard, {:?}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::ImagePng => match Self::image_png() { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading PNG from clipboard, {:?}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::ImageSvg => match Self::image_svg() { + Ok(image) => results.push(ClipboardData::Image(image)), + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading SVG from clipboard, {:?}", e); + cur_err = Some(e); + } + }, + ClipboardFormat::Special(format_name) => match Self::special_(format_name) { + Ok(data) => { + results.push(ClipboardData::Special((format_name.to_string(), data))) + } + Err(Error::ContentNotAvailable) => results.push(ClipboardData::None), + Err(e) => { + log::debug!("Error reading special format from clipboard, {:?}", e); + cur_err = Some(e); + } + }, + } + if let Some(e) = cur_err { + match e { + Error::ClipboardOccupied => { + // If the clipboard is occupied, we need to stop trying to read from it. + return Err(e); + } + _ => { + results.push(ClipboardData::None); + err = Some(e); + err_count += 1 + } + } + } + } + + if err_count == formats.len() { + if let Some(e) = err { + Err(e) + } else { + // unreachable!() because `err_count == formats.len()` + Ok(results) + } + } else { + Ok(results) + } + } +} + +pub(crate) struct Set<'clipboard> { + clipboard: Result, Error>, + exclude_from_monitoring: bool, + exclude_from_cloud: bool, + exclude_from_history: bool, +} + +impl<'clipboard> Set<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { + clipboard: clipboard.open(), + exclude_from_monitoring: false, + exclude_from_cloud: false, + exclude_from_history: false, + } + } + + pub(crate) fn text(self, data: Cow<'_, str>) -> Result<(), Error> { + let open_clipboard = self.clipboard?; + Self::text_(data, true)?; + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } + + fn text_(data: Cow<'_, str>, clear: bool) -> Result<(), Error> { + if clear { + clipboard_win::raw::set_string(&data) + } else { + clipboard_win::raw::set_string_with(&data, options::NoClear) + } + .map_err(|e| map_error_code("Could not place the specified text to the clipboard", e)) + } + + pub(crate) fn rtf(self, data: Cow<'_, str>) -> Result<(), Error> { + let open_clipboard = self.clipboard?; + if let Err(e) = clipboard_win::raw::empty() { + return Err(map_error_code("Failed to empty the clipboard. Got error code: {:?}", e)); + }; + + Self::rtf_(data)?; + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } + + #[inline] + fn rtf_(data: Cow<'_, str>) -> Result<(), Error> { + let format = register_format_(CFSTR_MIME_RICHTEXT)?; + clipboard_win::raw::set_without_clear(format, data.as_bytes()) + .map_err(|e| map_error_code("failed to set without clear", e)) + } + + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { + let open_clipboard = self.clipboard?; + Self::html_(html, alt, true)?; + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } + + fn html_(html: Cow<'_, str>, alt: Option>, clear: bool) -> Result<(), Error> { + if clear { + if let Err(e) = clipboard_win::raw::empty() { + return Err(map_error_code("Failed to empty the clipboard, {:?}", e)); + }; + } + + let alt = match alt { + Some(s) => s.into(), + None => String::new(), + }; + clipboard_win::raw::set_string_with(&alt, options::NoClear).map_err(|e| { + map_error_code("Could not place the specified text to the clipboard", e) + })?; + + Self::html_without_alt_(html) + } + + #[inline] + fn html_without_alt_(html: Cow<'_, str>) -> Result<(), Error> { + let format = register_format_("HTML Format")?; + let html = wrap_html(&html); + clipboard_win::raw::set_without_clear(format, html.as_bytes()) + .map_err(|e| map_error_code("failed to set without clear", e)) + } + + pub(crate) fn image(self, image: ImageData) -> Result<(), Error> { + let _open_clipboard = self.clipboard?; + if let Err(e) = clipboard_win::raw::empty() { + return Err(map_error_code("Failed to empty the clipboard", e)); + }; + Self::image_(image) + } + + fn image_(image: ImageData) -> Result<(), Error> { + match image { + ImageData::Rgba(image) => Self::image_rgba(image), + ImageData::Png(png) => image_data::add_png_file(&png), + ImageData::Svg(svg) => Self::image_svg(svg), + } + } + + #[inline] + fn image_rgba(image: ImageRgba) -> Result<(), Error> { + // XXX: The ordering of these functions is important, as some programs will grab the + // first format available. PNGs tend to have better compatibility on Windows, so it is set first. + image_data::add_png_file_from_rgba(&image)?; + image_data::add_cf_dibv5(image) + } + + #[inline] + fn image_svg(svg: String) -> Result<(), Error> { + let format = register_format_(CFSTR_MIME_SVG_XML)?; + clipboard_win::raw::set_without_clear(format, svg.as_bytes()) + .map_err(|e| map_error_code("Failed to set SVG data to clipboard", e)) + } + + pub(crate) fn special(self, format_name: &str, data: &[u8]) -> Result<(), Error> { + let open_clipboard = self.clipboard?; + if let Err(e) = clipboard_win::raw::empty() { + return Err(map_error_code("Failed to empty the clipboard", e)); + }; + Self::special_(format_name, data)?; + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } + + #[inline] + fn special_(format_name: &str, data: &[u8]) -> Result<(), Error> { + let format = register_format_(format_name)?; + clipboard_win::raw::set_without_clear(format, data) + .map_err(|e| map_error_code("failed to set clipboard data", e)) + } + + // No need to implement checking `ClipboardOccupied` as the `Get` does. + // Because it's a common case that `Get`s are called consecutively in multiple threads or processes. + pub(crate) fn formats(self, data: &[ClipboardData]) -> Result<(), Error> { + let open_clipboard = self.clipboard?; + + if let Err(e) = clipboard_win::raw::empty() { + return Err(map_error_code("Failed to empty the clipboard", e)); + }; + + for item in data.iter() { + match item { + ClipboardData::Text(text) => { + Self::text_(text.clone().into(), false)?; + } + ClipboardData::Rtf(rtf) => { + Self::rtf_(rtf.clone().into())?; + } + ClipboardData::Html(html) => { + Self::html_without_alt_(html.clone().into())?; + } + ClipboardData::Image(image) => { + Self::image_(image.clone())?; + } + ClipboardData::Special((format_name, data)) => { + let format_name = format_name.as_str(); + Self::special_(format_name, data)?; + } + _ => {} + } + } + + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } +} + +fn add_clipboard_exclusions( + _open_clipboard: OpenClipboard<'_>, + exclude_from_monitoring: bool, + exclude_from_cloud: bool, + exclude_from_history: bool, +) -> Result<(), Error> { + /// `set` should be called with the registered format and a DWORD value of 0. + /// + /// See https://docs.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats#cloud-clipboard-and-clipboard-history-formats + const CLIPBOARD_EXCLUSION_DATA: &[u8] = &0u32.to_ne_bytes(); + + // Clipboard exclusions are applied retroactively (we still have the clipboard lock) to the item that is currently in the clipboard. + // See the MS docs on `CLIPBOARD_EXCLUSION_DATA` for specifics. Once the item is added to the clipboard, + // tell Windows to remove it from cloud syncing and history. + + if exclude_from_monitoring { + if let Some(format) = + clipboard_win::register_format("ExcludeClipboardContentFromMonitorProcessing") + { + // The documentation states "place any data on the clipboard in this format to prevent...", and using the zero bytes + // like the others for consistency works. + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA).map_err( + |e| map_error_code("failed to exclude data from clipboard monitoring", e), + )?; + } + } + + if exclude_from_cloud { + if let Some(format) = clipboard_win::register_format("CanUploadToCloudClipboard") { + // We believe that it would be a logic error if this call failed, since we've validated the format is supported, + // we still have full ownership of the clipboard and aren't moving it to another thread, and this is a well-documented operation. + // Due to these reasons, `Error::Unknown` is used because we never expect the error path to be taken. + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA) + .map_err(|e| map_error_code("failed to exclude data from cloud clipboard", e))?; + } + } + + if exclude_from_history { + if let Some(format) = clipboard_win::register_format("CanIncludeInClipboardHistory") { + // See above for reasoning about using `Error::Unknown`. + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA) + .map_err(|e| map_error_code("failed to exclude data from clipboard history", e))?; + } + } + + Ok(()) +} + +/// Windows-specific extensions to the [`Set`](crate::Set) builder. +pub trait SetExtWindows: private::Sealed { + /// Exclude the data which will be set on the clipboard from being processed + /// at all, either in the local clipboard history or getting uploaded to the cloud. + /// + /// If this is set, it is not recommended to call [exclude_from_cloud](SetExtWindows::exclude_from_cloud) or [exclude_from_history](SetExtWindows::exclude_from_history). + fn exclude_from_monitoring(self) -> Self; + + /// Excludes the data which will be set on the clipboard from being uploaded to + /// the Windows 10/11 [cloud clipboard]. + /// + /// [cloud clipboard]: https://support.microsoft.com/en-us/windows/clipboard-in-windows-c436501e-985d-1c8d-97ea-fe46ddf338c6 + fn exclude_from_cloud(self) -> Self; + + /// Excludes the data which will be set on the clipboard from being added to + /// the system's [clipboard history] list. + /// + /// [clipboard history]: https://support.microsoft.com/en-us/windows/get-help-with-clipboard-30375039-ce71-9fe4-5b30-21b7aab6b13f + fn exclude_from_history(self) -> Self; +} + +impl SetExtWindows for crate::Set<'_> { + fn exclude_from_monitoring(mut self) -> Self { + self.platform.exclude_from_monitoring = true; + self + } + + fn exclude_from_cloud(mut self) -> Self { + self.platform.exclude_from_cloud = true; + self + } + + fn exclude_from_history(mut self) -> Self { + self.platform.exclude_from_history = true; + self + } +} + +pub(crate) struct Clear<'clipboard> { + clipboard: Result, Error>, +} + +impl<'clipboard> Clear<'clipboard> { + pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { + Self { clipboard: clipboard.open() } + } + + pub(crate) fn clear(self) -> Result<(), Error> { + let _clipboard_assertion = self.clipboard?; + clipboard_win::empty().map_err(|e| map_error_code("failed to clear clipboard", e)) + } +} + +#[inline] +fn register_format_(name: &str) -> Result { + Ok(clipboard_win::register_format(name) + .ok_or_else(|| Error::unknown(format!("failed to register clipboard format \"{}\"", name)))? + .get()) +} + +fn wrap_html(ctn: &str) -> String { + let h_version = "Version:0.9"; + let h_start_html = "\r\nStartHTML:"; + let h_end_html = "\r\nEndHTML:"; + let h_start_frag = "\r\nStartFragment:"; + let h_end_frag = "\r\nEndFragment:"; + let c_start_frag = "\r\n\r\n\r\n\r\n"; + let c_end_frag = "\r\n\r\n\r\n"; + let h_len = h_version.len() + + h_start_html.len() + + 10 + h_end_html.len() + + 10 + h_start_frag.len() + + 10 + h_end_frag.len() + + 10; + let n_start_html = h_len + 2; + let n_start_frag = h_len + c_start_frag.len(); + let n_end_frag = n_start_frag + ctn.len(); + let n_end_html = n_end_frag + c_end_frag.len(); + format!( + "{}{}{:010}{}{:010}{}{:010}{}{:010}{}{}{}", + h_version, + h_start_html, + n_start_html, + h_end_html, + n_end_html, + h_start_frag, + n_start_frag, + h_end_frag, + n_end_frag, + c_start_frag, + ctn, + c_end_frag, + ) +} + +#[cfg(test)] +mod tests { + use super::image_data::{read_cf_dibv5, rgba_to_win, win_to_rgba}; + use crate::common::ImageData; + use std::mem::size_of; + use windows_sys::Win32::Graphics::Gdi::{BITMAPV5HEADER, BI_BITFIELDS, BI_RGB, LCS_GM_IMAGES}; + + #[test] + fn conversion_between_win_and_rgba() { + const DATA: [u8; 16] = + [100, 100, 255, 100, 0, 0, 0, 255, 255, 100, 100, 255, 100, 255, 100, 100]; + + let mut data = DATA; + let _converted = unsafe { win_to_rgba(&mut data) }; + + let mut data = DATA; + let _converted = unsafe { rgba_to_win(&mut data) }; + + let mut data = DATA; + let _converted = unsafe { win_to_rgba(&mut data) }; + let _converted = unsafe { rgba_to_win(&mut data) }; + assert_eq!(data, DATA); + + let mut data = DATA; + let _converted = unsafe { rgba_to_win(&mut data) }; + let _converted = unsafe { win_to_rgba(&mut data) }; + assert_eq!(data, DATA); + } + + fn dibv5_test_data( + width: usize, + height: usize, + compression: i32, + alpha_mask: u32, + pixels: &[u8], + ) -> Vec { + let header = BITMAPV5HEADER { + bV5Size: size_of::() as u32, + bV5Width: width as i32, + bV5Height: height as i32, + bV5Planes: 1, + bV5BitCount: 32, + bV5Compression: compression, + bV5SizeImage: pixels.len() as u32, + bV5XPelsPerMeter: 0, + bV5YPelsPerMeter: 0, + bV5ClrUsed: 0, + bV5ClrImportant: 0, + bV5RedMask: if compression == BI_BITFIELDS { 0x00ff0000 } else { 0 }, + bV5GreenMask: if compression == BI_BITFIELDS { 0x0000ff00 } else { 0 }, + bV5BlueMask: if compression == BI_BITFIELDS { 0x000000ff } else { 0 }, + bV5AlphaMask: alpha_mask, + bV5CSType: 0, + // SAFETY: Windows ignores this field because `bV5CSType` is not set to `LCS_CALIBRATED_RGB`. + bV5Endpoints: unsafe { std::mem::zeroed() }, + bV5GammaRed: 0, + bV5GammaGreen: 0, + bV5GammaBlue: 0, + bV5Intent: LCS_GM_IMAGES as u32, + bV5ProfileData: 0, + bV5ProfileSize: 0, + bV5Reserved: 0, + }; + + let header_bytes = unsafe { + std::slice::from_raw_parts( + (&header as *const BITMAPV5HEADER) as *const u8, + size_of::(), + ) + }; + + let mut data = Vec::with_capacity(header_bytes.len() + pixels.len()); + data.extend_from_slice(header_bytes); + data.extend_from_slice(pixels); + data + } + + #[test] + fn read_cf_dibv5_repairs_all_black_missing_alpha() { + let data = dibv5_test_data(2, 1, BI_RGB, 0, &[0, 0, 0, 0, 0, 0, 0, 0]); + let ImageData::Rgba(image) = read_cf_dibv5(&data).unwrap() else { + panic!("expected RGBA image"); + }; + + assert_eq!(image.width, 2); + assert_eq!(image.height, 1); + assert_eq!(image.bytes.as_ref(), &[0, 0, 0, 255, 0, 0, 0, 255]); + } + + #[test] + fn read_cf_dibv5_preserves_declared_transparency() { + let data = dibv5_test_data(2, 1, BI_BITFIELDS, 0xff000000, &[0, 0, 255, 0, 0, 255, 0, 0]); + let ImageData::Rgba(image) = read_cf_dibv5(&data).unwrap() else { + panic!("expected RGBA image"); + }; + + assert_eq!(image.width, 2); + assert_eq!(image.height, 1); + assert_eq!(image.bytes.as_ref(), &[255, 0, 0, 0, 0, 255, 0, 0]); + } +} diff --git a/third_party/arboard/tools/debugger.entitlements b/third_party/arboard/tools/debugger.entitlements new file mode 100644 index 00000000000..0afe3048f56 --- /dev/null +++ b/third_party/arboard/tools/debugger.entitlements @@ -0,0 +1,7 @@ + + + + com.apple.security.get-task-allow + + + \ No newline at end of file diff --git a/third_party/arboard/tools/run_with_leaks.sh b/third_party/arboard/tools/run_with_leaks.sh new file mode 100755 index 00000000000..edfa91675b8 --- /dev/null +++ b/third_party/arboard/tools/run_with_leaks.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +# This script is a utility on Apple platforms to run one +# of arboard's example binaries under the `leaks` CLI tool, +# which can help to diagnose memory leakage in any kind of +# native or runtime-managed code. + +example_name="$@" + +script_dir=$(dirname $BASH_SOURCE[0]) + +# Build the example +cargo build --example "$example_name" + +# Sign it with the required entitlements for process debugging. +codesign -s - -v -f --entitlements "$script_dir/debugger.entitlements" "./target/debug/examples/$example_name" + +# Run the example binary under `leaks` to look for any leaked objects. +leaks --atExit -- "./target/debug/examples/$example_name" \ No newline at end of file diff --git a/third_party/cacao/.cargo-checksum.json b/third_party/cacao/.cargo-checksum.json new file mode 100644 index 00000000000..bf70ee9ee7b --- /dev/null +++ b/third_party/cacao/.cargo-checksum.json @@ -0,0 +1 @@ +{"files":{".github/dependabot.yml":"927978ebff158195963d1d4f30c1f8d56ac4f3aa8dddcc2db924485a400b2b26",".github/workflows/ci.yml":"eb61e4858af1ac5bf1253d14eaea756814ba9b41fbc8a9cb9f4fea888e5034a9","ARCHITECTURE.md":"977254ddf73b6031be10d1672cd6c736d1422e8b6692ef6d5740d636d8c4c826","CONTRIBUTING.md":"083a2a1cf15b9eb37f86448d5f6e804da05239c70dc7adb84d861ab06fc93d9e","Cargo.toml":"e634ef3105856af50482957b060b759890eabf848ab9669fede4097fb124e3c7","LICENSE-MIT.md":"aaa91ed2f7e53631a0a6ac1460595be7ae65a1ab27f61000947973eec6d31e77","LICENSE-MPL.md":"e6b6566f085df5746515c6d7e2edcaec0d2b77d527ac40d91409d783fb6c8508","README.md":"3038245253e9faddfcd017b841d61b5203ecf3266fe70b0cf0afc6df3d2437ea","build.rs":"a4f4afab20403577718c92fef61602d07a009cb87004cf67d59f296eca465b9e","clippy.toml":"ca78b679ac0bac9662824a4c90c85cd432a4762408ac3689e1c31f4df6c02b4e","code_of_conduct.md":"7c286f064d52d76dbd4ee6436fbce51c348e9f65569fd5500c025824cb89dd32","examples/animation.rs":"44d9df80797236ac28150c0406712609baaf75d3376b4e70976cd9403b2dd33b","examples/autolayout.rs":"f90faa2b34f06677ebbd4e03b6a6c1f939509502bd0a9b06eedd5cc3a85ede3a","examples/browser/main.rs":"62916b60dc019a45102af37025fd9ea2450779556d1f6aa9b73285d02baea55c","examples/browser/toolbar.rs":"8f986df137c02337c84bb9d66caac5b73238b653632e77d234b25b3f2c2d4198","examples/calculator/button_row.rs":"9a94d22632f6dfe184be416c273d36ab74bf12c7d7ac0325d248295ae8b08fe3","examples/calculator/calculator.rs":"7907701b04e80e2b68fb8d23d4eb856f3303728d384d2e7bb9e255e6bd068aaf","examples/calculator/content_view.rs":"9adc8b47180d0b6ebc124bb9092780da49bc59288367a45bbf1b8b92cbf27d7b","examples/calculator/main.rs":"396e97a1a8351a163ac2145ac9f06e6083d94fd31e231c6d0a532368c1dcd4ad","examples/custom_image_drawing.rs":"52d04de6474bbf08c6e77e2f1cb89493f1bd781fc0d86eac5d36330240ccf83c","examples/defaults.rs":"dfe0704eabc6ef9f8ce0d189a31dcd83999eefb88f476dc8d040780bf1b7d464","examples/frame_layout.rs":"4bed60a524dbc0ee5d2cf3b4b70e1fdadde477dbdfddf9e47f31980b07ca50a1","examples/ios-beta/main.rs":"68554c0a063307ff53b05a25a7133b18327b9ffea35fd19aa4bc89e161aa56d1","examples/ios-beta/readme.md":"9f56d4c1bdaced4beae6476ade962fc5afde5d1ea3a70859b4b5c3df1ae33fb5","examples/popover.rs":"3feb011062f20be77f7449ca871fa2a802f0635a120adf391ee871e53c243838","examples/readme.md":"d3195260dc5a1126a0e70a59176009de36c690610d608d997dea3cbb538da811","examples/safe_area.rs":"b7ed1c25e78f65befe58fba7cd71348fd9d1487277afb92d43216642fb299190","examples/text_input.rs":"fa61ffdb46843670d8880500593d140945f3c2754510ebb0dac9caf5f684387e","examples/todos_list/README.md":"5b287deb85e813e68d9865193a004de840dc0e0f1a4dbfbb4317b92a1c0653ab","examples/todos_list/add/mod.rs":"de8992ced1c6e036ab95dee4b6b0bf0c7d598ac8ed05a88a777771fc14393bd1","examples/todos_list/add/view.rs":"bc75bef4165d016ba327a1d1496fb69595f0c2e41acdcde6b16f86ef3ac5d8f9","examples/todos_list/app.rs":"8647cf0fc683b349f53ce4d8a10829768d01d00cab3aebc9bcd93af0b8a48fd7","examples/todos_list/main.rs":"1ec6476a969fffe9ef1d5c9b4e9d865b05ad6995360d56b79adf10f8644ca3db","examples/todos_list/menu.rs":"bc98a3a25df9f436a3e32d9ae88429a2d6838884cd630e7d7933327fe157578d","examples/todos_list/preferences/advanced.rs":"8419fe3e95026507e6511d7e6c5643f8eccd1f20e27680111f17cc47d6680822","examples/todos_list/preferences/general.rs":"e3f4c1c60bc498f53fc556f4735835ac531ee2e17891adb686c585ed107e5c52","examples/todos_list/preferences/mod.rs":"f2302624dcd8a8e3ce20e958755d76aec5e255b61d685b8de2896a603bc89455","examples/todos_list/preferences/toggle_option_view.rs":"7dd8cac9fa18928488d4fd1dcf339a7dc821e2e094b724662667c80fc2c80849","examples/todos_list/preferences/toolbar.rs":"82ff91b9f738ae1c3bef667c72829f5a296c0b51f1ffb83a27a574726fbaa9bf","examples/todos_list/storage/defaults.rs":"22bb079cf53e69c79a150cff92bf5328463c7437eae1ac346e008f741c6e4966","examples/todos_list/storage/mod.rs":"ba0ccaf10b53e936b8cd8422795627b63caa97ca514456f66df29a7167e3f022","examples/todos_list/storage/todos.rs":"692e3114a092ef169302ebafcff551bae3fe904e1d347abd799643e528014253","examples/todos_list/todos/content_view.rs":"9ce558e2de3cbec30cd78ecf6dc0813500369a0d95a858c8b8dbd032918f8770","examples/todos_list/todos/list/mod.rs":"ee73fd4757a0a41efdcc82ef7ff859cc391c5b84cf1343ef6d9f5461c156e80b","examples/todos_list/todos/list/row.rs":"75b411ad0308dcbf46e482a039cb359a6426cb08100360f16f980cd4c8bfeb94","examples/todos_list/todos/mod.rs":"d0e65afa07aaecc473cf43758af8a2d2e132fba82ba28f12e4ed063caedd07fb","examples/todos_list/todos/toolbar.rs":"c48c37928915d018900e9b421b9cce90cdfcd0b1a773b79b89c8e167ff25a65d","examples/todos_list/windows.rs":"6daa9300ab65965c8b2209a9616b1d279c7be230ba814676988bee2622f76697","examples/webview_custom_protocol.rs":"915fb11db3637c670aa7898055811b5cac9649880ba20874bfbd795570bcf7c1","examples/window.rs":"f926d516bcc736ec82762e758b14c65929ac8227145d410dc04ecf1b32da65c9","examples/window_controller.rs":"cbcfe3ca0a1f4c1f6e4d21bcac785283cfb995825bb0bd92b4c66949ff74fb83","examples/window_delegate.rs":"fe4c51e03622284db365a0b7958ffb645290ef78a08d094734ad289c842992f2","rustfmt.toml":"951f891696b83ea7ec7dfeaf4ca839a8262d4341c3be9436ef3e89b50d3b2bd2","src/appkit/alert.rs":"2cc58665c697327354fad2570dcb2acbecfce7abcbe319ac050bd787fb777297","src/appkit/animation.rs":"be745bac022f6628a61e4a1788e8a08f389fe0be7bf1bf665b17b2b08ecd1f7d","src/appkit/app/class.rs":"8b05bb63a10a10d76dccdf62997bf7b8e096651df77fe3993262f26e463d4200","src/appkit/app/delegate.rs":"59bed9cbf768bb2a9b197b66feb60478130aa4ca2f80c30187457e8f7e8e0a21","src/appkit/app/enums.rs":"9a788534a0e7eaad1fe014935e80b2d55c10a68c19cbf1a12f93e5d54610103c","src/appkit/app/mod.rs":"3e34bce2bab6b0056a6fea98bccb25f5dda5007f1d001e01a5a206d43e52c474","src/appkit/app/traits.rs":"a6df641d4a87359f0b70d61248f6a7ed742eb326643588aeb27e6ba17e015d9f","src/appkit/cursor.rs":"170eb148fb0a9e9b0c4342f9eb7f5b99d9eda405be5fef9b066a9a8ec81a1993","src/appkit/enums.rs":"5cb064fa2851b488d1296599f5b0436fea3f160ac1b298fbd5a4edfb709ca213","src/appkit/event/mod.rs":"9a71d39a6dd89674648fed523fe984946ec60489b9215369a92d6dfaa0864fd2","src/appkit/haptics.rs":"2bc3488ac81ac020aaf6561412dc3050ec5b8cb863a2aaa5d10be90d7321d254","src/appkit/menu/item.rs":"4bf20ae19158239209b24d1aa76021e2052e9801206d81dadf2107fdf6e5614b","src/appkit/menu/menu.rs":"cd13c333bb97f67125ec3876be9018ff5eb6a5a988e81bdef37fa3ecd24dd43c","src/appkit/menu/mod.rs":"c0c52b703472f959c429e7d11c0a8677b2faa70acdee9a21676b688f5cea7f17","src/appkit/mod.rs":"c8b7d3085cfc2730428f7f34c34470209462f6716e4635f18aab4530f00a59b3","src/appkit/printing/enums.rs":"3acdb7faad6bdadf2b17527765eea48084e6a956b053bf94aeee235bea0c6524","src/appkit/printing/mod.rs":"a5b503d53bef6e764517cdb2e9dc99b4230aae412e4a7ba1d271884f1c2b2e12","src/appkit/printing/settings.rs":"4ac26a3b43330e0aa2cec300c14525106bbd48e6526a3d49ca550da405048625","src/appkit/segmentedcontrol.rs":"3ae7aa10cfe7ad581ac5b369eabf4cfeaad587d28165434c21fdd9855f5a2142","src/appkit/toolbar/class.rs":"a1548881850eb9296875ca7e775f970a12062e496e32b2de77d80209810e7a50","src/appkit/toolbar/enums.rs":"d674acb1a47b0d1143f8d9cb21e5db7af70509fa60430ac4413e2449f7902eb7","src/appkit/toolbar/item.rs":"0981bbb970eaf980ec89e360343978163769335aa220098ba35d669d77a99305","src/appkit/toolbar/mod.rs":"80a971b34655f0b7e5c3596f8768498aaf2ca9d896b772316dbcf2604690086d","src/appkit/toolbar/traits.rs":"ce430be116e040465d9a3b7384ca299175f36db10e06f2399cb69e9a8eddb5cd","src/appkit/window/class.rs":"f4e17478e95263602cb4adceb49160b8d2e3de696efd583a312da7030a8f423a","src/appkit/window/config.rs":"9484d3e9e2ba7479f5815e7d24a29a85c5ac65e3b7e8f8a034a83a02638dc2d2","src/appkit/window/controller/class.rs":"5b11d4ec0069ef95aa41fe966fd3e72b26475a9cefd3afab8175a0e9a9af7a19","src/appkit/window/controller/mod.rs":"d22a44b1b743d612dc40c9bd91c11a8c879556ce708033f3c1c2ba7f714dc7cc","src/appkit/window/enums.rs":"e927e0d190c04c9828490c6acf7124be258c54d26ad017cb20a953cf4a19538b","src/appkit/window/mod.rs":"71b506a5d3c282866b130098baf3e63d7d1646c2ae8d149087cec9aa64e2c960","src/appkit/window/traits.rs":"f5baadff644e4be57b78bee095fdb33ef7a4a5e32709a753192b3e39e45d768b","src/bundle.rs":"2ef207c5966ccec354ea7f07d90785f8a42aace5a89a22143bcc2ea97af2c65a","src/button/enums.rs":"68443c73c29b21ddebd8263f877838a8b0bfbaf12a3469f00d11e0bfafc4c860","src/button/mod.rs":"008dfea813c20b4387d3e8cda941ae595ffd39c44116eda17df5876e83e12754","src/cloudkit/mod.rs":"221789e3427bd5c231687cce1dbb58556e553ec0d01d8985bea130a2e47b6511","src/cloudkit/share.rs":"b730a9fd1bb851c6e8144de3bae7f734e11dc8873ecb409dfa71857d855f813c","src/color/appkit_dynamic_color.rs":"38928959d4bd82db04c577151c871a6c87e34a5dad81a8baa3288ee7fc9c7d9a","src/color/mod.rs":"267de823563c882f1a237a4f15a5b47dcb04fdc78317a87adc9fb932e94cdc44","src/control/mod.rs":"a3d88c83f6d74a15482bc28587018d0e53d85f076272e997e5b13e4595d63b57","src/defaults/mod.rs":"9bb0f1f0bb8eaae0a667c7ba4d36614367ccbbdb08863dfb192f1535012f5880","src/defaults/value.rs":"93ce0d6a1db8b20e7c33542261832a6c710c79dc9695d5630e4212fe2c305629","src/dragdrop.rs":"ea13639b47821b136e4f9e5f0359e2b45951d7fb5816101a5aa7865983838ece","src/error.rs":"86568dfaf5d957f7b32e004101cf41f8996e7f81485c5c5fbdfd982bc08fb342","src/events.rs":"c778f37b6fba5f18e7059b924dda81bd78854d24a1376264a0eabc025414a7a6","src/filesystem/enums.rs":"6f5edb43110f5d98d5858a466c1a7b4e3996c702b858ae7b61dec65c7f466b8f","src/filesystem/manager.rs":"8b067d9a1fc13cd91dd2a0a6606bf1bc939f0495f9c11f149f042155e348e7fa","src/filesystem/mod.rs":"95fa730d74173513047a3ae74a2b7fe8cb54e4c4a186de70c7703eb132b189e8","src/filesystem/save.rs":"9b4f7dfd1b15daffc8b7d0d67475dd00e16a7a9c303c2339a9c2353c9bfee1be","src/filesystem/select.rs":"497778bb9aa6b731aa41f6e35b28cb4663e40071689e3abbe1d849ff4f902d0c","src/filesystem/traits.rs":"edae89fdb761ef9dfdcc6162fcb4a9557df45817def96c77d41f1dc1838de666","src/foundation/array.rs":"eb611f583803278f3f2664ed0623cc5d8106820aeb4f3e861e383a7079d3ab0b","src/foundation/autoreleasepool.rs":"1b52d59704165185a9e0f3517a3d0c43a19e437b864c35d7f7be0400bcad2044","src/foundation/class.rs":"42ff213f5610571c33b7ebfaf4f4c0c99b431768b505aa13541fc3e6841d8f3d","src/foundation/data.rs":"6c5316771d595f6c2cc0dddafbe954834cef81cfde25ac06fa6e2c8b7ed28c09","src/foundation/dictionary.rs":"74015e0d544da9bab45c2cfe02bb82f1635731ae532ba6bb4dfba3190ad9c75a","src/foundation/mod.rs":"91e5dbeadb9cec72cb597ac06915bfa33399ccaa89a25f7871c107705d1e65ec","src/foundation/number.rs":"5e36523fc42ae34b6680f51445d902a109ada86a278a3a448a7dfd23621618fa","src/foundation/string.rs":"5f5044f524308f1ce776b6a858a807f30570750015652482132924eec3307393","src/foundation/urls/bookmark_options.rs":"193ffa45303d887c6b0c2e1ddce119a9e2237a57def16b0c5b52903dd0022c52","src/foundation/urls/mod.rs":"9256503b4f42f78dd980e273ea5891298bf025d4c8df32dcb429819d7d103e45","src/foundation/urls/resource_keys.rs":"e9b8b201d042134a82fe0641145c256e09c12bf28d2dc4ce490f723a31464e10","src/geometry.rs":"b2502e78539c30731e94c82ef3ce495e7f4e03624a7688c6ecd8fa89adb447bd","src/image/appkit.rs":"00bfa80d18abc9a38628b359cbe48e467cb54317e974ea72b94f570df915c8dc","src/image/handle.rs":"7d543e6c1d47224d45d4614540d28818c089275ef0280efa0c1a74770c80e13b","src/image/icons.rs":"91aa9f40f9ff98a49a3bc0de3b009ae53c7d3a7a448f247950a234a9788ec46a","src/image/image.rs":"4a2c0d67de71f68fcc0abfa4c95312b851b086d53f88844ca66ccf5265679a14","src/image/mod.rs":"1a04c92c4ab385e75253b805ac5ca3780e3bd97c7246a1ad8321fc68c99f79c6","src/image/traits.rs":"2002edab7c10fb6523e8f196da28ded616fb8c79c07b79539f54d294cdd845af","src/image/uikit.rs":"7e921b1714a24ceea359bbedf3ebc10a232d3cbf17dfb8ee06cf737374e94844","src/input/appkit.rs":"82534008ca6e3a02f98bddc276624c6b4f7ef020696fd7ce2f610b27885b58f6","src/input/mod.rs":"54055d23c94a694c5276fe86941276e1cf86c40a95022325b79af050dd95e3f6","src/input/traits.rs":"81bdfbd583663ea9c2e56f30827464470188f1f294967518229930eac215e556","src/input/uikit.rs":"8f923bc823c47145dc714978c0cf9a6b087740653dd9d46882d4d25373bbb8b5","src/invoker.rs":"695eafcaa9bc96c4ecb397bd89b995f85f7b04d30106b5839cf2ad0e7324f9a4","src/keys.rs":"f67e00d14d6ed22faa8d11fea687aaea9d5928c14bda8529047d624306229c9f","src/layer/mod.rs":"06e19d01e4644e55306a80798fff195a53b3972ff25001583a3c7ec2188e819f","src/layout/animator.rs":"1a1dc416965d2a0b6b935a4d3b42fb0c009de85198fc71be9904d2cecd6f9593","src/layout/attributes.rs":"ff4e17ff6b6a545e640e9195f26f6aa1798380e8c4de26248491278edcb0bf18","src/layout/constraint.rs":"7142c48d6097b40f94f16fd5625620e603d2ebccbd3301e76f0a915efdb53385","src/layout/dimension.rs":"d33424336b01787bc3d1abfdc5edb1293fce82b38b2a1895d905529dbd9b2c60","src/layout/horizontal.rs":"db8e7cb6b938792d5bcfe04fbfa4baffe382dd2e3039a768ca1d1d6e0ae8a374","src/layout/mod.rs":"5a1e2147f7ad1167dfb3aeaee45ce827102b769541132550504fe5a433bc5848","src/layout/safe_guide.rs":"143ded9792c0a55b14b7dc7ca3a8763ceb3c70f5b736c38ec29f4dd9d025c80c","src/layout/traits.rs":"755d89772fd6dd6c043d32e154a1313cdf485667abe30a398443967955f037a1","src/layout/vertical.rs":"d180326432dea2eee27f69e1ee52d91dc67e2ef5dd55292291aa360e7d3b1508","src/lib.rs":"e037bf0308a033f463abcd20afcc8f5c9a1af1791d7b187d59d1c2e35f2c6639","src/listview/actions.rs":"2321d09e8f5ab421aa84c97c7b5008a2cf896d00b904a1ff96a99b12474f3b18","src/listview/appkit.rs":"830790643f30f36e4e09705a6430c22a1e27756e13648fbad387934961f93fb3","src/listview/enums.rs":"a2e68eeb0ce5e1278a2ad4066cbd324b895ea5dad138dc2fc39d2b97d003499e","src/listview/mod.rs":"68c0cf0ade2d40181bd90a65ed2c5be307023cd63ce59ff2ec608b3fa44f7ce2","src/listview/row/appkit.rs":"f602faf92aa5c18ead7ff6baf1ec6a598390df64238c02da3ddc32082cee3116","src/listview/row/mod.rs":"d6cbcd24431bb97ad4873dc2d6ff0489150edd55d593692f0d2c75acdd1724b1","src/listview/row/uikit.rs":"64da6fea0148a142da20ea3f4ea3176d0b648a28f73d448661320c592c7160b5","src/listview/traits.rs":"dd1f9a65967b4e6bd071b52fe4114e3493900dab439ca2b6d5150e38db58f0f3","src/networking/mod.rs":"d04d382f9aeab04f15958de25066ba471580e29beb7ed691cf54d9513ea3f607","src/notification_center/mod.rs":"ca81dbe22c48af6e8a6c2675b1bd1281f534d34f6b4c2c0acad75766ce81408e","src/notification_center/name.rs":"f8731aa2802e32423d4a4bdcd7f664f77ff7c21824cad1144ec8d31a258541c5","src/notification_center/traits.rs":"819d77b7f2b72bfed927dbd48704b1c3cf2d0040872271a97fdc810ad5383b90","src/objc_access.rs":"5e51e61b24bb9fb8e5e7615a29e46d0b368ea9de31d23e00a23a36a0055bf2f0","src/pasteboard/mod.rs":"7c5a79e3804984776abcefcb78e702e8a00fb62d716a34378851e777dadd0a27","src/pasteboard/types.rs":"92c8503643f3984436e4eadc937759680f7d6c0678ec6f5c36f51bcaa057facb","src/progress/enums.rs":"6a0a686bd99d8f740fccc132ef4be799dd1ec3bcf75b92f92ffeb89a7f0f8bdd","src/progress/mod.rs":"20f68e021518c5db9adcf37d9a4a79861ac07e6cd87c8af1aefd885fb9af820f","src/quicklook/config.rs":"08c3bf7c3236679721a469c003a6e204ed50329ebb907c4b7f29a5fe4bb71369","src/quicklook/mod.rs":"d006597b6938a62003bebf8f4a2dc30a4c503cf6f239b1d518995066dc483c02","src/scrollview/appkit.rs":"29acdd52be3d6a3c949a2979e3617448e37bb3e53f5432ebf2676212ed3a8eca","src/scrollview/mod.rs":"18836fc27ccab729356b2b3c0aaa6786ded16e01d1059b02d3e9984d412b2297","src/scrollview/traits.rs":"788ef5cef04ed85b2cfe8ce4b18bdb05d79a08e157d8fb0bf67a1881c943dc5b","src/scrollview/uikit.rs":"9f6827d2e880bc85a6fba874e1f0f3f4328b168b705b5aede79b6839db711c16","src/select/mod.rs":"86473434b04365d5f301746d33a8d5ce708f45b928f788da5d0242960e8f5dc1","src/switch.rs":"d3fcd80e236897da30da3f038280a36c1e38be6e7dba17e44289f9543853049c","src/text/attributed_string.rs":"8fcfc3b1939d6b143e4fd5140fcf17bad8acf7759da6f5e57b96b2915b3e7101","src/text/enums.rs":"51fa6374eed63fce74538f6354bb00afab20006820e20d3737e19c6c53a9a158","src/text/font.rs":"a95daaab249ed3b3889805ce5ff745e28a20f71c61f7e86f760d9356a1146c57","src/text/label/appkit.rs":"81d706c3f4d14d9e61be2924cb631dd489735d184549b690db39a88eb46151dc","src/text/label/mod.rs":"eb08d69be73b6940dfa7b89e11a732494b6d9539693dea80f4ccd6bb1cae3ee1","src/text/label/traits.rs":"a1035e7fe2a10f8f34b0f0ed6a34ff8ec77973316533f1cca3e152127491c4b8","src/text/label/uikit.rs":"16f3c9a5fbe04efe3a86f94e6a5aecd7664a820f8fea584dd7923ea550ba0b1e","src/text/mod.rs":"2cf0b15b3d5e4029fac255983e567073b4f42febce256f376c1129fd4ad6e599","src/uikit/app/class.rs":"ac30d61d4aaf1fd1095e4ceab60812bac4a2e2d340531d1ec79c8cc82c8a57bc","src/uikit/app/delegate.rs":"a55130c86bcf6bb2df20bff1de7182d40fad8a5b5784d51ea37fa1d4e3f2d6db","src/uikit/app/enums.rs":"2ffa547cbb71f2dc50b5e233038e0bf56b206cbf69494700271a7b6055e9539c","src/uikit/app/mod.rs":"f14da301f768d39eb44081384b9cce954c3676b813c9477ec6b8ff41d59d6b24","src/uikit/app/traits.rs":"c91d72556644c36f72a332942f239a73614842185637d521de4e54327d4fe801","src/uikit/mod.rs":"c553a1ffec3eb621c3004575bdc7e4faea4e577280ae2c388e141cc8665ceab5","src/uikit/scene/config.rs":"07268f6fa5dd9791041958f709207d2ba7f6252d4320135a585a5637606fa51c","src/uikit/scene/delegate.rs":"875ce8885357ca234660e75fedc163e8c935f922feff7eb220e10e4c1d198e08","src/uikit/scene/enums.rs":"386c29134404c7173eed889db3b98477d0edc846631336e109c03aef5aa21686","src/uikit/scene/mod.rs":"7c41ea7fa1cbe1ca785cfd05adfd5607c8612583cc0a0150f103b025edf769be","src/uikit/scene/options.rs":"db769e674d6db225d39e273e44ac5eb0fad7d5ca73e434a28d6ea2d9c878decc","src/uikit/scene/session.rs":"8e255b22f2d397d257b4effed836933f9b41e18fcbf80039217609f9a2766ae2","src/uikit/scene/traits.rs":"9eabeef0d5b0e90bf802e4dcd49c87bc435e38946c5278130c6ae8c883d3e2c3","src/uikit/window/mod.rs":"22ff637546a2fe5f3c634c22dec99832511ce98b73abf747201ba873bbd4f4bc","src/user_activity.rs":"44da74d18d8074771def5a732f5a88e5379561ee7dcdc16237a505857ecc54e3","src/user_notifications/enums.rs":"c32c268cbffe18735c52fcad88fcc472e9a578a7b7ed1909c712db0e39130c87","src/user_notifications/mod.rs":"1922ce70fd15cb9984e1e4c9796c09be2280cbf5314fca515d75103de68c890b","src/user_notifications/notifications.rs":"a1e19edd0c176ba42a9cfc93eb66bfd0008a7168088ec60cb0061287a029efb5","src/utils/cell_factory.rs":"73ecc7c8acf2d0446bbebcb6c3484ed65c81281380d7b808e254d682b598d53a","src/utils/mod.rs":"8ca397271c56a459a68e5340267464627aece6fa700d89b51a55654af5372049","src/utils/os.rs":"024fe51977375c4e5133129c4c550ee1e3e266810b4837fc643b7dfbe6dc8b41","src/utils/properties.rs":"ed34428332976f3af9eae09ac2d4707f5d93bf27bc0e11e21040ba66faffd283","src/view/animator.rs":"65be7805285d877a535fbffd9e6d6a686d0603d18583eb4daa8077df738e4ceb","src/view/appkit.rs":"6e9dde088ad1bb64948e680b7acd6b4bc9c518a0e4807cf408ab5c6f6e65959c","src/view/controller/appkit.rs":"50a1aeee4e982e603054b99ad6f9ccaff6254566ecef3742bd76399327dab6d8","src/view/controller/mod.rs":"b9edffc3392ab2308081b4b5b2a1656a17ea907814bd5a332d04b9339e7e2aea","src/view/controller/uikit.rs":"d6d5dbb9347b4373ce9b0dc34c54534639d3c5da2d3fedf80522a38aa9169cc7","src/view/mod.rs":"b028d7611fba8738b14f69df7396bd114b35418c4389d3ca44562aaba03b2d2b","src/view/popover/mod.rs":"737d95767b7640f35d2b2c30ad8521969aa94cb90865f5675642714a83b32338","src/view/splitviewcontroller/ios.rs":"84a1e161f89fb67b021cc91aa7c5cb2583b21fd2a7532aae8776a3326b4b48ce","src/view/splitviewcontroller/macos.rs":"f87ae0b647a5c851627f2a23ccd98175c5dca9d467d602e820a660747658bde6","src/view/splitviewcontroller/mod.rs":"15c68327e757e66b5fb270d2cb9749511c6b13b072cc4aba1201bf836b737f9d","src/view/traits.rs":"88a744a1498195e45a5b5aad9410384e4bb101a6533a1c143ec1b26c7e75a7e6","src/view/uikit.rs":"e70fbb46793808009e771f7467e9025d3e3a4b070616ab278f5dfd85a4ce8d2e","src/webview/actions.rs":"a34caa993529e76b5721e325bebc1f7c5771fc97c16e5ae2de2d4ed8957dffeb","src/webview/class.rs":"ec4778dff901ecf3b0a1ffcdfb526abb95a4c10d384e9ddb4386df30d9cff1d0","src/webview/config.rs":"b8cbc5a1e20aa4c0e850dbaece473161f084446802c5cf59fcb1b13c6986784e","src/webview/enums.rs":"26bae0851f1a6db283ff4a46d51d8451be82eb4bb6f56290b659f04ff3c2b052","src/webview/mimetype.rs":"c5d40a650c4f24746a29da6041384a9c95f32a0dfa19f3150cc06c10b3961c5f","src/webview/mod.rs":"afda05a21ef803a9a5e4926aae724c9b24007a58bfda9ff8745bf611cd331649","src/webview/process_pool.rs":"ba86455f741d20630c8b8a9cdebf2fe988ecd75ecf46d005830dd7754c2f331c","src/webview/traits.rs":"6ee99ac94dedc3ce2963a10fc4e641f08aec7c5995701f32dca77bf26c4786d2","test-data/favicon.ico":"494ac6d053574138c3a7826ae67a00b0f894b83871d75e15e2ae8fea0d107bd4"},"package":null} \ No newline at end of file diff --git a/third_party/cacao/.github/dependabot.yml b/third_party/cacao/.github/dependabot.yml new file mode 100644 index 00000000000..3b47d2dfe20 --- /dev/null +++ b/third_party/cacao/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-patch"] # ignore patch updates + + # Maintain dependencies for Cargo + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-patch"] # ignore patch updates diff --git a/third_party/cacao/.github/workflows/ci.yml b/third_party/cacao/.github/workflows/ci.yml new file mode 100644 index 00000000000..dff7753232d --- /dev/null +++ b/third_party/cacao/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + push: + branches: [master, trunk] + pull_request: + +jobs: + fmt: + name: Check formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + components: rustfmt + override: true + - name: Check formatting + uses: actions-rs/cargo@v1 + with: + command: fmt + args: -- --check + test: + name: Check that examples build + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - uses: actions-rs/cargo@v1 + with: + command: test + build: + name: Check that the code builds + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - uses: actions-rs/cargo@v1 + with: + command: build + examples: + name: Check that examples build + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - uses: actions-rs/cargo@v1 + with: + command: build + args: --features webview --example webview_custom_protocol + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + target: x86_64-apple-ios + # Since it's all Objective-C message passing under the hood, we're + # really just looking for whether we've broken the iOS build. It is likely + # that more robust tests/checking infrastructure should exist for this side + # of things as the iOS portion gets iterated on. + # + # (e.g, this at the moment will not catch invalid selector calls, like if an appkit-specific + # selector is used for something on iOS) + - uses: actions-rs/cargo@v1 + with: + command: build + args: --target x86_64-apple-ios --example ios-beta --no-default-features --features uikit,autolayout + + ios: + name: Check that iOS tests pass via dinghy. + runs-on: macos-latest + steps: + + - name: Install cargo-dinghy + uses: baptiste0928/cargo-install@v2 + with: + crate: cargo-dinghy + + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + target: x86_64-apple-ios + + - name: Launch XCode Simulator and prepare Dinghy + run: | + # Get system info + xcrun simctl list runtimes + # Launch the simulator + RUNTIME_ID=$(xcrun simctl list runtimes | grep iOS | cut -d ' ' -f 7 | tail -1) + SIM_ID=$(xcrun simctl create My-iphone-se com.apple.CoreSimulator.SimDeviceType.iPhone-14 $RUNTIME_ID) + xcrun simctl boot $SIM_ID + + - name: Dinghy test + run: | + cargo dinghy --platform auto-ios-x86_64 test --no-default-features --features uikit,autolayout diff --git a/third_party/cacao/ARCHITECTURE.md b/third_party/cacao/ARCHITECTURE.md new file mode 100644 index 00000000000..766677bbec2 --- /dev/null +++ b/third_party/cacao/ARCHITECTURE.md @@ -0,0 +1,424 @@ +# Cacao Architecture +Cacao is a library to interface with AppKit (macOS) or UIKit (iOS/iPadOS/tvOS). It uses the Objective-C runtime to +handle calling into these frameworks. + +Said frameworks typically use an Object Oriented style of programming (subclasses, etc), which can be tricky to +handle with the way that Rust works with regards to ownership. Thankfully, AppKit & UIKit often also use a +delegate pattern - objects registered to receive callbacks. With some creative assumptions, we can get somewhat close +to expected conventions. + +This document outlines some of the thinking surrounding the architectural patterns used in this framework. Consider it +to be a somewhat living document - things may change or bend as far as rules go, but hopefully this guide makes looking +through the framework easier. + +In general, a tl;dr: + +**macOS architecture** +``` +App -> Window(s) -> Core Controls +``` + +**iOS architecture** +``` +App -> Window(s) -> UIScene(s) -> Core Controls +``` + +## Control Setup +A typical control in Cacao has 2-3 main pieces: + +- The core control, which can take an optional delegate. More on this below. +- iOS/macOS bridges, which inject a subclass into the Objective-C runtime that forwards methods/callbacks/etc to the Rust + side of things. +- Extra delegates, which are similar to the above bridges (some classes need this to avoid issues). + +## Core Control Contract +A core control is just the Rust interface. It should adhere to the following "contract". + +### It should always expose an `objc` field, which holds the underlying Objective-C object. +This is important, as the underlying frameworks can differ in how they handle things, and they get frequent-ish updates +each year. There should _always_ be an escape hatch to make life easier for the end-user. + +### Controls should always expose the underlying Layer. +This is technically not "correct" on macOS but I don't care. We explicitly expect there to be a layer on controls, and modern +macOS wants things to be layer backed anyway. + +For example, the `View` type has the following field: + +### Controls should always expose AutoLayout Anchors. +AutoLayout is the preferred layout engine for Apple's frameworks. Users who need frame-based layouts get them for free as +long as the control also implements the `Layout` trait. + +### Interior Mutability makes life easier. +I anticipate this being somewhat divisive, maybe. Not sure. Point is, Rust's model is already hard enough to reason about with +UI frameworks - we can try to ease this with interior mutability on controls. + +`utils::properties::ObjcProperty` is a handy wrapper for this, which should ideally be used - it will handle retain counts while +simultaneously making the borrow model feel "correct" on the Rust side. + +## Control Example +Let's walk through the `View` type to better understand this architecture. + +### Core Control +Since this is our Rust type, we can mostly jump right in. Let's start with the struct definition: + +``` rust +#[derive(Debug)] +pub struct View { + /// An internal flag for whether an instance of a View is a handle. Typically, there's only + /// one instance that should have this set to `false` - if that one drops, we need to know to + /// do some extra cleanup. + pub is_handle: bool, + + /// A pointer to the Objective-C runtime view controller. + pub objc: ObjcProperty, + + /// References the underlying layer. This is consistent across macOS, iOS and tvOS - on macOS + /// we explicitly opt in to layer backed views. + pub layer: Layer, + + /// A pointer to the delegate for this view. + pub delegate: Option>, + + /// A pointer to the Objective-C runtime top layout constraint. + pub top: LayoutAnchorY, + + /// A pointer to the Objective-C runtime leading layout constraint. + pub leading: LayoutAnchorX, + + /// A pointer to the Objective-C runtime left layout constraint. + pub left: LayoutAnchorX, + + /// A pointer to the Objective-C runtime trailing layout constraint. + pub trailing: LayoutAnchorX, + + /// A pointer to the Objective-C runtime right layout constraint. + pub right: LayoutAnchorX, + + /// A pointer to the Objective-C runtime bottom layout constraint. + pub bottom: LayoutAnchorY, + + /// A pointer to the Objective-C runtime width layout constraint. + pub width: LayoutAnchorDimension, + + /// A pointer to the Objective-C runtime height layout constraint. + pub height: LayoutAnchorDimension, + + /// A pointer to the Objective-C runtime center X layout constraint. + pub center_x: LayoutAnchorX, + + /// A pointer to the Objective-C runtime center Y layout constraint. + pub center_y: LayoutAnchorY +} +``` + +A few things to note here! + +#### `T` is optional. +We want a user to be able to just slap a `View` onto the screen if they want - they're essential building blocks, after all. We default +this to `()` and provide a designated initializer (see below) for cases where you _want_ a delegate set. + +#### `is_handle` +We want to be able to run cleanup on `Drop` of the Rust struct, because we want Rust programmers to be able to think in their assumed lifecycle, +not Objective-C's. We need to be able to clone this into a possible delegate to enable customizing as if it were a class, though; enter `is_handle`. + +Essentially, `is_handle` should only be false for the "originating" `View`. Clones should always have `is_handle` set to true; this guard is checked +on Drop, and if it's false, we know we're dropping the original and can clean up. + +#### `objc` +This stores the underlying Objective-C object (e.g, `NSView` or `UIView`). + +#### Layer backing +Controls should expose a `layer` property. On macOS this technically should be optional, but I'm making the BDFL decision to enforce it being there, because +outside of a shrinking set of cases, you want it there. + +#### `delegate` +A `delegate` is our Rust trait impl that receives callbacks from the core control. + +For instance, you might have a `View` that calls are forwarded to. It'd look something like the following: + +``` rust +pub struct DragAndDrop; + +impl cacao::view::ViewDelegate for DragAndDrop { + const NAME: &'static str = "DragAndDropView"; + + fn did_load(&mut self, view: cacao::view::View) { + // Customize View in here, persist it or something + } + + // implement various drag and drop handlers +} +``` + +And the `View` would be constructed like so: + +``` rust +let dnd_view = View::with(DragAndDrop); +``` + +#### AutoLayout Abound +The various Layout Anchors in here are used for AutoLayout (positioning/sizing on the screen). They should always be set. + + +#### Default +Not every control needs a `Default` impl, but View defaulting is convenient for deep initialization, so we offer it. + +``` rust +impl Default for View { + /// Returns a stock view, for... well, whatever you want. + fn default() -> Self { + View::new() + } +} + +``` + +### Base Initializers +So next on the list is `View::new()`. We also have an internal `init(view)` method to collect some logic that we need in two places. + +`register_view_class()` is located in `view/macos.rs` on macOS, and `view/ios.rs` on iOS; this is a _bridge_ that handles class setup. +We'll look at this more in-depth below, but the general idea here is that the method returns a `Class *` that can be used to create a +new Objective-C object. + +``` rust +impl View { + /// An internal initializer method for very common things that we need to do, regardless of + /// what type the end user is creating. + /// + /// This handles grabbing autolayout anchor pointers, as well as things related to layering and + /// so on. It returns a generic `View`, which the caller can then customize as needed. + pub(crate) fn init(view: id) -> View { + unsafe { + let _: () = msg_send![view, setTranslatesAutoresizingMaskIntoConstraints:NO]; + + #[cfg(target_os = "macos")] + let _: () = msg_send![view, setWantsLayer:YES]; + } + + View { + is_handle: false, + delegate: None, + top: LayoutAnchorY::top(view), + left: LayoutAnchorX::left(view), + leading: LayoutAnchorX::leading(view), + right: LayoutAnchorX::right(view), + trailing: LayoutAnchorX::trailing(view), + bottom: LayoutAnchorY::bottom(view), + width: LayoutAnchorDimension::width(view), + height: LayoutAnchorDimension::height(view), + center_x: LayoutAnchorX::center(view), + center_y: LayoutAnchorY::center(view), + + layer: Layer::from_id(unsafe { msg_send_id![view, layer] }), + + objc: ObjcProperty::retain(view), + } + } + + /// Returns a default `View`, suitable for customizing and displaying. + pub fn new() -> Self { + View::init(unsafe { + msg_send![register_view_class(), new] + }) + } +} +``` + +### Delegate Initializer +For types that accept a delegate, the common pattern we use is for the initializer to be named `with()`. Below, we implement `with()` for `View`: + +``` rust +impl View where T: ViewDelegate + 'static { + /// Initializes a new View with a given `ViewDelegate`. This enables you to respond to events + /// and customize the view as a module, similar to class-based systems. + pub fn with(delegate: T) -> View { + let class = register_view_class_with_delegate(&delegate); + let mut delegate = Box::new(delegate); + + let view = unsafe { + let view: id = msg_send![class, new]; + let ptr = Box::into_raw(delegate); + (&mut *view).set_ivar(VIEW_DELEGATE_PTR, ptr as usize); + delegate = Box::from_raw(ptr); + view + }; + + let mut view = View::init(view); + (&mut delegate).did_load(view.clone_as_handle()); + view.delegate = Some(delegate); + view + } +} +``` + +Note that we use a second view class registration function here, as it performs some extra work to ensure that a unique subclass is created per-Rust-type. We dive +into some `unsafe` here, as we need to set a pointer to our trait object `T` so that the callbacks are able to load and call when coming around from the Objective-C +side - this is explained more in the _bridge_ sections below. + +### Drawing the Owl +As this block is likely to still grow, we'll be somewhat brief here - below, we implement a `clone_as_handle()` method, which returns a bland clone of this type (a _handle_). +Notably, this does not have a _delegate_ reference, and `is_handle` is set to `true`. This is passed to the trait implementation in `did_load()`, to enable the trait having access +to the containing Objective-C type. + +``` rust +impl View { + /// An internal method that returns a clone of this object, sans references to the delegate or + /// callback pointer. We use this in calling `did_load()` - implementing delegates get a way to + /// reference, customize and use the view but without the trickery of holding pieces of the + /// delegate - the `View` is the only true holder of those. + pub(crate) fn clone_as_handle(&self) -> View { + View { + delegate: None, + is_handle: true, + layer: self.layer.clone(), + top: self.top.clone(), + leading: self.leading.clone(), + left: self.left.clone(), + trailing: self.trailing.clone(), + right: self.right.clone(), + bottom: self.bottom.clone(), + width: self.width.clone(), + height: self.height.clone(), + center_x: self.center_x.clone(), + center_y: self.center_y.clone(), + objc: self.objc.clone() + } + } + + /// Call this to set the background color for the backing layer. + pub fn set_background_color>(&self, color: C) { + let color: id = color.as_ref().into(); + + #[cfg(target_os = "macos")] + self.objc.with_mut(|obj| unsafe { + // TODO: Fix this unnecessary retain! + (&mut *obj).set_ivar::(BACKGROUND_COLOR, msg_send![color, retain]); + }); + + #[cfg(target_os = "ios")] + self.objc.with_mut(|obj| unsafe { + let _: () = msg_send![&*obj, setBackgroundColor:color]; + }); + } +} +``` + +We also see a `set_background_color`, which performs different calls depending on the target OS environment: iOS supports background colors by default, and macOS... well, we store it as an `ivar`, and then rely on the +layer painting itself in the _bridge_ implementation. You might see some cases online where code simply just does `layer.backgroundColor.cgColor = ...`, but this doesn't work properly for dark mode support. + +### Layout Support +The `layout::Layout` trait implements a slew of commonly needed functions, such as setting frames, handling view adding/removing, hiding and showing, and so on. Controls need only implement one or two `Layout` trait methods +to get most of this for free: + +``` rust +impl Layout for View { + fn with_backing_node(&self, handler: F) { + self.objc.with_mut(handler); + } + + fn get_from_backing_node R, R>(&self, handler: F) -> R { + self.objc.get(handler) + } +} +``` + +Here, we simply pass handlers into the `objc` field calls. With this setup, we're able to offer relatively sound checks for borrowing the underlying types, and most other `Layout` methods "just work". + +### Dropping +Here, we simply check if the dropping item is a handle or not. If it's not (i.e, if it's the top-level original), we can ensure it's removed from the Objective-V view heirarchy and be on our way. + +``` rust +impl Drop for View { + /// If the instance being dropped is _not_ a handle, then we want to go ahead and explicitly + /// remove it from any super views. + /// + /// Why do we do this? It's to try and match Rust's ownership model/semantics. If a Rust value + /// drops, it (theoretically) makes sense that the View would drop... and not be visible, etc. + /// + /// If you're venturing into unsafe code for the sake of custom behavior via the Objective-C + /// runtime, you can consider flagging your instance as a handle - it will avoid the drop logic here. + fn drop(&mut self) { + if !self.is_handle { + self.remove_from_superview(); + } + } +} +``` + +## Bridges(s) +We'll step through an example (abridged) `View` bridge below, for macOS. You should consult the full implementation in `view/` to learn more after reading this. + +For our basic `View` type, we want to just map to the corresponding class on the Objective-C side (in this case, `NSView`), and maybe do a bit of tweaking for sanity reasons. + +``` rust +pub(crate) fn register_view_class() -> &'static Class { + static mut VIEW_CLASS: Option<'static Class> = None; + static INIT: Once = Once::new(); + + INIT.call_once(|| unsafe { + let superclass = class!(NSView); + let mut decl = ClassDecl::new("RSTView", superclass).unwrap(); + + decl.add_method(sel!(isFlipped), enforce_normalcy as extern "C" fn(_, _) -> _); + + decl.add_ivar::(BACKGROUND_COLOR); + + VIEW_CLASS = Some(decl.register()); + }); + + unsafe { VIEW_CLASS.unwrap() } +} +``` + +This function (called inside `View::new()`) creates one reusable `View` subclass, and returns the type on subsequent calls. We're able to add methods to it (`add_method`) which match +Objective-C method signatures, as well as provision space for variable storage (`add_ivar`). + +For our _delegate_ types, we need a different class creation method - one that creates a subclass per-unique-type: + +``` rust +pub(crate) fn register_view_class_with_delegate(instance: &T) -> &'static Class { + load_or_register_class("NSView", instance.subclass_name(), |decl| unsafe { + decl.add_ivar::(VIEW_DELEGATE_PTR); + decl.add_ivar::(BACKGROUND_COLOR); + + decl.add_method( + sel!(isFlipped), + enforce_normalcy as extern "C" fn(_, _) -> _, + ); + + decl.add_method( + sel!(draggingEntered:), + dragging_entered:: as extern "C" fn (_, _, _) -> _, + ); + }) +} +``` + +Here, we add a method that only makes sense if you're using a delegate (notifying about a drag-enter event). We also provision an extra storage slot, which contains a pointer +to the Rust `ViewDelegate` implementation. + +The methods we're setting up can range from simple to complex - take `isFlipped`: + +``` rust +extern "C" fn is_flipped(_: &Object, _: Sel) -> Bool { + return Bool::YES; +} +``` + +Here, we just want to tell `NSView` to use top,left as the origin point, so we need to respond `Bool::YES` in this subclass method. + +``` rust +extern "C" fn dragging_entered(this: &mut Object, _: Sel, info: id) -> NSUInteger { + let view = utils::load::(this, VIEW_DELEGATE_PTR); + view.dragging_entered(DragInfo { + info: unsafe { Id::retain(info).unwrap() } + }).into() +} +``` + +This is an example of a more complex method: we load the `ViewDelegate` type from the pointer set on the object, and forward the information +into the `dragging_entered` trait method. + +## Conclusion +Hopefully this helps newcomers understand the design choices and architecture found in this repository. It can feel odd at first, but ends up lending itself well to UI patterns, and provides +some structure for how things should work. diff --git a/third_party/cacao/CONTRIBUTING.md b/third_party/cacao/CONTRIBUTING.md new file mode 100644 index 00000000000..dc0808b3f73 --- /dev/null +++ b/third_party/cacao/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing + +Thanks for your interest in contributing to this project! Suggestions, bug reports, and pull requests and so on are cool, but keep in mind this is open source - there's currently no guarantee this project does much. + +*Note:* Anyone who interacts with this project in any space, including but not +limited to this GitHub repository, must follow the [code of +conduct](https://github.com/ryanmcgrath/cacao/blob/trunk/code_of_conduct.md). + + +## Submitting bug reports + +Have a look at the [issue tracker](https://github.com/ryanmcgrath/cacao/issues). If you can't find an issue (open or closed) +describing your problem (or a very similar one) there, please open a new issue with +the following details: + +- Which versions of Rust and Cacao (and macOS/iOS build/device) are you using? +- Which feature flags are you using? +- What are you trying to accomplish? +- What is the full error you are seeing? +- How can this be reproduced? + - Please quote as much of your code as needed to reproduce (best link to a + public repository or [Gist]) + - Please post as much of your database schema as is relevant to your error + +[issue tracker]: https://github.com/ryanmcgrath/cacao/issues +[Gist]: https://gist.github.com + +Thank you! + + +## Submitting feature requests + +If you can't find an issue (open or closed) describing your idea on the [issue +tracker], open an issue. Adding answers to the following +questions in your description is +1: + +- What do you want to do, and how do you expect Cacao to support you with that? +- How might this be added to Cacao? +- What are possible alternatives? +- Are there any disadvantages? + +Thank you! + + +## Contribute code to Cacao + +### Setting up Cacao locally + +1. Install Rust. Stable should be fine. +2. Clone this repository and open it in your favorite editor. +3. `cargo build`, or link it via your `Cargo.toml` to mess with it. + +### Coding Style + +Generally follow the [Rust Style Guide](https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md), enforced using [rustfmt](https://github.com/rust-lang-nursery/rustfmt). +In a few cases, though, it's fine to deviate - a good example is branching match trees. + +To run rustfmt tests locally: + +1. Use rustup to set Rust toolchain to the latest stable version of Rust. + +2. Install the rustfmt and clippy by running + ``` + rustup component add rustfmt-preview + rustup component add clippy-preview + ``` + +3. Run clippy nightly using cargo from the root of your Cacao repo. + ``` + cargo +nightly clippy + ``` + Each PR needs to compile without warning. + +4. Run rustfmt nightly using cargo from the root of your Cacao repo. + + To see changes that need to be made, run + + ``` + cargo +nightly fmt --all -- --check + ``` + + If all code is properly formatted (e.g. if you have not made any changes), + this should run without error or output. + If your code needs to be reformatted, + you will see a diff between your code and properly formatted code. + If you see code here that you didn't make any changes to + then you are probably running the wrong version of rustfmt. + Once you are ready to apply the formatting changes, run + + ``` + cargo +nightly fmt --all + ``` + + You won't see any output, but all your files will be corrected. + +You can also use rustfmt to make corrections or highlight issues in your editor. +Check out [their README](https://github.com/rust-lang/rustfmt) for details. + + +### Notes +This project prefers verbose naming, to a certain degree - UI code is read more often than written, so it's +worthwhile to ensure that it scans well. It also maps well to existing Cocoa/Cacao idioms and is generally preferred. diff --git a/third_party/cacao/Cargo.toml b/third_party/cacao/Cargo.toml new file mode 100644 index 00000000000..e868e7cad42 --- /dev/null +++ b/third_party/cacao/Cargo.toml @@ -0,0 +1,193 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2018" +name = "cacao" +version = "0.4.0-beta2" +authors = ["Ryan McGrath "] +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Rust bindings for AppKit (macOS/Airyx/GNUStep, beta) and UIKit (iOS/tvOS, alpha)." +readme = "README.md" +keywords = [ + "gui", + "macos", + "ios", + "appkit", + "uikit", +] +categories = [ + "gui", + "os::macos-apis", + "os::ios-apis", +] +license = "MIT OR MPL-2.0+" +repository = "https://github.com/ryanmcgrath/cacao" + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-apple-darwin" +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[package.metadata.bundle.example.ios-beta] +name = "ios-beta" +identifier = "com.cacao.ios-test" +category = "Developer Tool" +short_description = "An example Cacao iOS app." +long_description = "An example Cacao iOS app." + +[badges.maintenance] +status = "actively-developed" + +[features] +appkit = ["core-foundation/mac_os_10_8_features"] +autolayout = [] +cloudkit = [] +color_fallbacks = [] +default = [ + "appkit", + "autolayout", +] +quicklook = [] +uikit = [] +user-notifications = ["uuid"] +webview = ["infer"] +webview-downloading-macos = [] + +[lib] +name = "cacao" +path = "src/lib.rs" + +[[example]] +name = "animation" +path = "examples/animation.rs" +required-features = ["appkit"] + +[[example]] +name = "autolayout" +path = "examples/autolayout.rs" +required-features = ["appkit"] + +[[example]] +name = "browser" +path = "examples/browser/main.rs" +required-features = ["webview"] + +[[example]] +name = "calculator" +path = "examples/calculator/main.rs" +required-features = ["appkit"] + +[[example]] +name = "custom_image_drawing" +path = "examples/custom_image_drawing.rs" +required-features = ["appkit"] + +[[example]] +name = "defaults" +path = "examples/defaults.rs" +required-features = ["appkit"] + +[[example]] +name = "frame_layout" +path = "examples/frame_layout.rs" +required-features = ["appkit"] + +[[example]] +name = "ios-beta" +path = "examples/ios-beta/main.rs" +required-features = [ + "uikit", + "autolayout", +] + +[[example]] +name = "popover" +path = "examples/popover.rs" +required-features = ["appkit"] + +[[example]] +name = "safe_area" +path = "examples/safe_area.rs" +required-features = ["appkit"] + +[[example]] +name = "text_input" +path = "examples/text_input.rs" +required-features = ["appkit"] + +[[example]] +name = "todos_list" +path = "examples/todos_list/main.rs" +required-features = ["appkit"] + +[[example]] +name = "webview_custom_protocol" +path = "examples/webview_custom_protocol.rs" +required-features = ["webview"] + +[[example]] +name = "window" +path = "examples/window.rs" +required-features = ["appkit"] + +[[example]] +name = "window_controller" +path = "examples/window_controller.rs" +required-features = ["appkit"] + +[[example]] +name = "window_delegate" +path = "examples/window_delegate.rs" +required-features = ["appkit"] + +[dependencies] +bitmask-enum = "2.2.1" +dispatch = "0.2.0" +lazy_static = "1.4.0" +libc = "0.2" +os_info = "3.0.1" +percent-encoding = "2.3.0" +url = "2.1.1" + +[dependencies.block] +version = "=0.2.0-alpha.6" +package = "block2" + +[dependencies.core-foundation] +path = "../core-foundation-0.9.3" + +[dependencies.core-graphics] +path = "../core-graphics-0.23.1" + +[dependencies.infer] +version = "0.15" +optional = true + +[dependencies.objc] +version = "=0.3.0-beta.2" +package = "objc2" + +[dependencies.uuid] +version = "1.1" +features = ["v4"] +optional = true + +[dev-dependencies] +eval = "0.4" diff --git a/third_party/cacao/LICENSE-MIT.md b/third_party/cacao/LICENSE-MIT.md new file mode 100644 index 00000000000..e0daa3068f8 --- /dev/null +++ b/third_party/cacao/LICENSE-MIT.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Ryan McGrath. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/cacao/LICENSE-MPL.md b/third_party/cacao/LICENSE-MPL.md new file mode 100644 index 00000000000..cd44203cd98 --- /dev/null +++ b/third_party/cacao/LICENSE-MPL.md @@ -0,0 +1,355 @@ +Mozilla Public License Version 2.0 +================================== + +### 1. Definitions + +**1.1. “Contributor”** + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +**1.2. “Contributor Version”** + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +**1.3. “Contribution”** + means Covered Software of a particular Contributor. + +**1.4. “Covered Software”** + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +**1.5. “Incompatible With Secondary Licenses”** + means + +* **(a)** that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or +* **(b)** that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +**1.6. “Executable Form”** + means any form of the work other than Source Code Form. + +**1.7. “Larger Work”** + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +**1.8. “License”** + means this document. + +**1.9. “Licensable”** + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +**1.10. “Modifications”** + means any of the following: + +* **(a)** any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or +* **(b)** any new file in Source Code Form that contains any Covered + Software. + +**1.11. “Patent Claims” of a Contributor** + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +**1.12. “Secondary License”** + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +**1.13. “Source Code Form”** + means the form of the work preferred for making modifications. + +**1.14. “You” (or “Your”)** + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, “control” means **(a)** the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or **(b)** ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + + +### 2. License Grants and Conditions + +#### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +* **(a)** under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and +* **(b)** under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +#### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +#### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +* **(a)** for any code that a Contributor has removed from Covered Software; + or +* **(b)** for infringements caused by: **(i)** Your and any other third party's + modifications of Covered Software, or **(ii)** the combination of its + Contributions with other software (except as part of its Contributor + Version); or +* **(c)** under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +#### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +#### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +#### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +#### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + + +### 3. Responsibilities + +#### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +#### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +* **(a)** such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +* **(b)** You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +#### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +#### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +#### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + + +### 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: **(a)** comply with +the terms of this License to the maximum extent possible; and **(b)** +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + + +### 5. Termination + +**5.1.** The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated **(a)** provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and **(b)** on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +**5.2.** If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +**5.3.** In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +### 6. Disclaimer of Warranty + +> Covered Software is provided under this License on an “as is” +> basis, without warranty of any kind, either expressed, implied, or +> statutory, including, without limitation, warranties that the +> Covered Software is free of defects, merchantable, fit for a +> particular purpose or non-infringing. The entire risk as to the +> quality and performance of the Covered Software is with You. +> Should any Covered Software prove defective in any respect, You +> (not any Contributor) assume the cost of any necessary servicing, +> repair, or correction. This disclaimer of warranty constitutes an +> essential part of this License. No use of any Covered Software is +> authorized under this License except under this disclaimer. + +### 7. Limitation of Liability + +> Under no circumstances and under no legal theory, whether tort +> (including negligence), contract, or otherwise, shall any +> Contributor, or anyone who distributes Covered Software as +> permitted above, be liable to You for any direct, indirect, +> special, incidental, or consequential damages of any character +> including, without limitation, damages for lost profits, loss of +> goodwill, work stoppage, computer failure or malfunction, or any +> and all other commercial damages or losses, even if such party +> shall have been informed of the possibility of such damages. This +> limitation of liability shall not apply to liability for death or +> personal injury resulting from such party's negligence to the +> extent applicable law prohibits such limitation. Some +> jurisdictions do not allow the exclusion or limitation of +> incidental or consequential damages, so this exclusion and +> limitation may not apply to You. + + +### 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + + +### 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + + +### 10. Versions of the License + +#### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +#### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +#### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +#### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/cacao/README.md b/third_party/cacao/README.md new file mode 100644 index 00000000000..9a7d04f0d07 --- /dev/null +++ b/third_party/cacao/README.md @@ -0,0 +1,157 @@ +# Cacao + +This library provides safe Rust bindings for `AppKit` on macOS (beta quality, fairly usable) and `UIKit` on iOS/tvOS (alpha quality, see repo). +It tries to do so in a way that, if you've done programming for the framework before (in Swift or +Objective-C), will feel familiar. This is tricky in Rust due to the ownership model, but some +creative coding and assumptions can get us pretty far. + +This exists on crates.io in part to enable the project to see wider usage, which can +inform development. That said, this library is currently early stages and may have bugs - your usage of it is at +your own risk. However, provided you follow the rules (regarding memory/ownership) it's +already fine for some apps. The core repository has a wealth of examples to help you get started. + +> **Important** +> +> If you are migrating from 0.2 to 0.3, you should elect either `appkit` or `uikit` as a feature in your `Cargo.toml`. This change was made to +> support platforms that aren't just macOS/iOS/tvOS (e.g, gnustep, airyx). One of these features is required to work; `appkit` is defaulted for +> ease of development. + +>_Note that this crate relies on the Objective-C runtime. Interfacing with the runtime **requires** +unsafe blocks; this crate handles those unsafe interactions for you and provides a safe wrapper, +but by using this crate you understand that usage of `unsafe` is a given and will be somewhat +rampant for wrapped controls. This does **not** mean you can't assess, review, or question unsafe +usage - just know it's happening, and in large part it's not going away. Issues pertaining to the mere +existence of unsafe will be closed without comment._ + +If you're looking to build the docs for this on your local machine, you'll want the following due to the way feature flags work +with `cargo doc`: + +`RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --open` + +# Hello World + +```rust +use cacao::appkit::{App, AppDelegate}; +use cacao::appkit::window::Window; + +#[derive(Default)] +struct BasicApp { + window: Window +} + +impl AppDelegate for BasicApp { + fn did_finish_launching(&self) { + self.window.set_minimum_content_size(400., 400.); + self.window.set_title("Hello World!"); + self.window.show(); + } +} + +fn main() { + App::new("com.hello.world", BasicApp::default()).run(); +} +``` + +For more thorough examples, check the `examples/` folder. + +If you're interested in a more "kitchen sink" example, check out the todos_list with: + +``` sh +cargo run --example todos_list +``` + +## Initialization +Due to the way that AppKit and UIKit programs typically work, you're encouraged to do the bulk +of your work starting from the `did_finish_launching()` method of your `AppDelegate`. This +ensures the application has had time to initialize and do any housekeeping necessary behind the +scenes. + +## Currently Supported +In terms of mostly working pieces, the table below showcases the level of support for varying features. This list is not exhaustive just by virtue of documentation updating being hell - so you're encouraged to check out the code-built documentation for more info: + +Note that while iOS has green checkmarks, some components still aren't as well defined (e.g, Views/ViewControllers are still very alpha there). + +Non-Apple platforms that shim or provide a form of AppKit may be able to use a good chunk of the AppKit support in this library. + +| Component | Description | AppKit | iOS | tvOS | +| ------------------- | ----------------------------------- | ------ | --- | ---- | +| App | Initialization & events | ✅ | ✅ | ❌ | +| Window | Construction, handling, events | ✅ | ✅ | ❌ | +| View | Construction, styling, events | ✅ | ✅ | ❌ | +| ViewController | Construction, lifecycle events | ✅ | ✅ | ❌ | +| Color | System-backed colors, theming | ✅ | ✅ | ❌ | +| ListView | Reusable list w/ cached rows | ✅ | ❌ | ❌ | +| Button | Styling, events, toolbar support | ✅ | ❌ | ❌ | +| Label/TextField | Text rendering & input | ✅ | ❌ | ❌ | +| Image/ImageView | Loading, drawing, etc | ✅ | ✅ | ❌ | +| Toolbar | Basic native toolbar | ✅ | ❌ | ❌ | +| SplitViewController | Split views (Big Sur friendly) | ✅ | ❌ | ❌ | +| WebView | Wrapper for WKWebView | ✅ | ❌ | ❌ | +| UserDefaults | Persisting small data | ✅ | ✅ | ❌ | +| Autolayout | View layout for varying screens | ✅ | ✅ | ❌ | + +## Optional Features + +The following are a list of [Cargo features][cargo-features] that can be enabled or disabled. + +- `appkit`: Links `AppKit.framework`. +- `uikit`: Links `UIKit.framework` (iOS/tvOS only). +- `cloudkit`: Links `CloudKit.framework` and provides some wrappers around CloudKit functionality. Currently not feature complete. +- `color_fallbacks`: Provides fallback colors for older systems where `systemColor` types don't exist. This feature is very uncommon and you probably don't need it. +- `quicklook`: Links `QuickLook.framework` and offers methods for generating preview images for files. +- `user-notifications`: Links `UserNotifications.framework` and provides functionality for emitting notifications on macOS and iOS. Note that this _requires_ your application be code-signed, and will not work without it. +- `webview`: Links `WebKit.framework` and provides a `WebView` control backed by `WKWebView`. This feature is not supported on tvOS, as the platform has no webview control. This feature is also potentially only supported for macOS/iOS due to the WKWebView control and varying support on non-Apple platforms. +- `webview-downloading-macos`: Enables downloading files from the `WebView` via a private interface. This is not an App-Store-safe feature, so be aware of that before enabling. This feature is not supported on iOS (a user would handle downloads very differently) or tvOS (there's no web browser there at all). + +[cargo-features]: https://doc.rust-lang.org/stable/cargo/reference/manifest.html#the-features-section + +## General Notes +**Why not extend the existing cocoa-rs crate?** +A good question. At the end of the day, that crate (I believe, and someone can correct me if I'm wrong) is somewhat tied to Servo, and I wanted to experiment with what the best approach for representing the Cocoa UI model in Rust was. This crate doesn't ignore their work entirely, either - `core_foundation` and `core_graphics` are used internally and re-exported for general use. + +**Why should I write in Rust, rather than X language?** +In _my_ case, I want to be able to write native applications for my devices (and the platform I like to build products for) without being locked in to writing in Apple-specific languages... and without writing in C/C++ or JavaScript (note: the _toolchain_, not the language - ES6/Typescript are fine). I want to do this because I'm tired of hitting a mountain of work when I want to port my applications to other ecosystems. I think that Rust offers a (growing, but significant) viable model for sharing code across platforms and ecosystems without sacrificing performance. + +_(This is the part where the internet lights up and rants about some combination of Electron, Qt, and so on - we're not bothering here as it's beaten to death elsewhere)_ + +This crate is useful for people who don't need to go all-in on the Apple ecosystem, but want to port their work there with some relative ease. It's not expected that everyone will suddenly want to rewrite their macOS/iOS/tvOS apps in Rust. + +**Isn't Objective-C dead?** +Yes, and no. + +It's true that Apple definitely favors Swift, and for good reason (and I say this as an unabashed lover of Objective-C). With that said, I would be surprised if we didn't have another ~5+ years of support; Apple is quick to deprecate, but removing the Objective-C runtime would require a ton of time and effort. Maybe SwiftUI kills it, who knows. A wrapper around this stuff should conceivably make it easier to swap out the underlying UI backend whenever it comes time. + +One thing to note is that Apple _has_ started releasing Swift-only frameworks. For cases where you need those, it should be possible to do some combination of linking and bridging - which would inform how swapping out the underlying UI backend would happen at some point. + +Some might also decry Objective-C as slow. To that, I'd note the following: + +- Your UI engine is probably not the bottleneck. +- Swift is generally better as it fixes a class of bugs that Objective-C doesn't catch; for the most part it still sits on top of the existing Cocoa frameworks anyway (though this statement will not age well~). +- Message dispatching in Objective-C is more optimized than significant chunks of the code you'll write, and is fast enough for most things. + +**tl;dr** it's probably fine, and you have Rust for your performance needs. + +**Why not just wrap UIKit, and then rely on Catalyst?** +I have yet to see a single application where Catalyst felt good. The goal is good, though, and if it got to a point where that just seemed like the way forward (e.g, Apple just kills AppKit) then it's certainly an option. + +**You can't possibly wrap all platform-specific behavior here...** +Correct! Each UI control contains a `objc` field, which you can use as an escape hatch - if the control doesn't support something, you're free to drop to the Objective-C runtime yourself and handle it. + +**Why don't you use bindings to automatically generate this stuff?** +For initial exploration purposes I've done most of this by hand, as I wanted to find an approach that fit well in the Rust model before committing to binding generation. This is something I'll likely focus on next now that I've got things "working" well enough. + +**Is this related to Cacao, the Swift project?** +No. The project referred to in this question aimed to map portions of Cocoa and UIKit over to run on Linux, but hasn't seen activity in some time (it was really cool, too!). + +Open source project naming in 2020 is like trying to buy a `.com` domain: everything good is taken. Luckily, multiple projects can share a name... so that's what's going to happen here. + +**Isn't this kind of cheating the Rust object model?** +Depends on how you look at it. I personally don't care too much - the GUI layer for these platforms is a hard requirement to support for certain classes of products, and giving them up also means giving up battle-tested tools for things like Accessibility and deeper OS integration. With that said, internally there are efforts to try and make things respect Rust's model of how things should work. + +You can think of this as similar to gtk-rs. If you want to support or try a more _pure_ model, go check out Druid or something. :) + +## License +Dual licensed under an MIT/MPL-2.0 license. See the appropriate files in this repository for more information. Apple, AppKit, UIKit, Cocoa, and other trademarks are copyright Apple, Inc. + +## Questions, Comments, etc +You can follow me over on [twitter](https://twitter.com/ryanmcgrath/) or [email me](mailto:ryan@rymc.io) with questions that don't fit as an issue here. diff --git a/third_party/cacao/build.rs b/third_party/cacao/build.rs new file mode 100644 index 00000000000..f4358fdc563 --- /dev/null +++ b/third_party/cacao/build.rs @@ -0,0 +1,27 @@ +//! Emits linker flags depending on platforms and features. + +fn main() { + println!("cargo:rustc-link-lib=framework=Foundation"); + + #[cfg(feature = "appkit")] + println!("cargo:rustc-link-lib=framework=AppKit"); + + #[cfg(feature = "uikit")] + println!("cargo:rustc-link-lib=framework=UIKit"); + + println!("cargo:rustc-link-lib=framework=CoreGraphics"); + println!("cargo:rustc-link-lib=framework=QuartzCore"); + println!("cargo:rustc-link-lib=framework=Security"); + + #[cfg(feature = "webview")] + println!("cargo:rustc-link-lib=framework=WebKit"); + + #[cfg(feature = "cloudkit")] + println!("cargo:rustc-link-lib=framework=CloudKit"); + + #[cfg(feature = "user-notifications")] + println!("cargo:rustc-link-lib=framework=UserNotifications"); + + #[cfg(feature = "quicklook")] + println!("cargo:rustc-link-lib=framework=QuickLook"); +} diff --git a/third_party/cacao/clippy.toml b/third_party/cacao/clippy.toml new file mode 100644 index 00000000000..9fa08ad4b71 --- /dev/null +++ b/third_party/cacao/clippy.toml @@ -0,0 +1,6 @@ +cyclomatic-complexity-threshold = 30 +doc-valid-idents = [ + "MiB", "GiB", "TiB", "PiB", "EiB", + "DirectX", "OpenGL", "TrueType", + "GitHub" +] diff --git a/third_party/cacao/code_of_conduct.md b/third_party/cacao/code_of_conduct.md new file mode 100644 index 00000000000..e015c534ab8 --- /dev/null +++ b/third_party/cacao/code_of_conduct.md @@ -0,0 +1,78 @@ +# Contributor Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting a project maintainer at: + +* Ryan McGrath + +All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. The project team +is obligated to maintain confidentiality with regard to the reporter of an +incident. Further details of specific enforcement policies may be posted +separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [https://contributor-covenant.org/version/1/4][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/4/ diff --git a/third_party/cacao/examples/animation.rs b/third_party/cacao/examples/animation.rs new file mode 100644 index 00000000000..010a652c97a --- /dev/null +++ b/third_party/cacao/examples/animation.rs @@ -0,0 +1,188 @@ +//! This example builds on the AutoLayout example, but adds in animation +//! via `AnimationContext`. Views and layout anchors have special proxy objects that can be cloned +//! into handlers, enabling basic animation support within `AnimationContext`. +//! +//! This one is a bit kludgier than some other examples, but the comments throughout this should +//! clarify why that is. + +use cacao::color::Color; +use cacao::layout::{Layout, LayoutConstraint, LayoutConstraintAnimatorProxy}; +use cacao::view::{LayerContentsRedrawPolicy, View, ViewAnimatorProxy}; + +use cacao::appkit::menu::Menu; +use cacao::appkit::window::{Window, WindowConfig, WindowDelegate}; +use cacao::appkit::{AnimationContext, App, AppDelegate}; +use cacao::appkit::{Event, EventMask, EventMonitor}; + +struct BasicApp { + window: Window +} + +impl AppDelegate for BasicApp { + fn did_finish_launching(&self) { + App::set_menu(Menu::standard()); + App::activate(); + + self.window.show(); + } + + fn should_terminate_after_last_window_closed(&self) -> bool { + true + } +} + +/// This map is the four different animation frames that we display, per view type. +/// Why do we have this here? +/// +/// Well, it's because there's no random number generator in the standard library, and I really +/// dislike when examples need crates attached to 'em. +/// +/// The basic mapping logic is this: each entry is a view's frame(s), and each frame is an array +/// of: +/// +/// [top, left, width, height, alpha] +/// +/// We then treat each frame index as follows: +/// +/// w: 0 +/// a: 1 +/// s: 2 +/// d: 3 +const ANIMATIONS: [[[f64; 5]; 4]; 3] = [ + // Blue + [ + [44., 16., 100., 100., 1.], + [128., 84., 144., 124., 1.], + [32., 32., 44., 44., 0.7], + [328., 157., 200., 200., 0.7] + ], + // Red + [ + [44., 132., 100., 100., 1.], + [40., 47., 80., 64., 0.7], + [84., 220., 600., 109., 1.0], + [48., 600., 340., 44., 0.7] + ], + // Green + [ + [44., 248., 100., 100., 1.], + [420., 232., 420., 244., 0.7], + [310., 440., 150., 238., 0.7], + [32., 32., 44., 44., 1.] + ] +]; + +/// A helper method for generating frame constraints that we want to be animating. +fn apply_styles(view: &View, parent: &View, background_color: Color, animation_table_index: usize) -> [LayoutConstraint; 4] { + view.set_background_color(background_color); + view.layer.set_corner_radius(16.); + parent.add_subview(view); + + let animation = ANIMATIONS[animation_table_index][0]; + + [ + view.top.constraint_equal_to(&parent.top).offset(animation[0]), + view.left.constraint_equal_to(&parent.left).offset(animation[1]), + view.width.constraint_equal_to_constant(animation[2]), + view.height.constraint_equal_to_constant(animation[3]) + ] +} + +#[derive(Default)] +struct AppWindow { + content: View, + blue: View, + red: View, + green: View, + key_monitor: Option +} + +impl WindowDelegate for AppWindow { + const NAME: &'static str = "WindowDelegate"; + + fn did_load(&mut self, window: Window) { + window.set_title("Animation Example (Use W/A/S/D to change state!)"); + window.set_minimum_content_size(300., 300.); + + self.blue + .set_contents_redraw_policy(LayerContentsRedrawPolicy::OnSetNeedsDisplay); + self.red + .set_contents_redraw_policy(LayerContentsRedrawPolicy::OnSetNeedsDisplay); + self.green + .set_contents_redraw_policy(LayerContentsRedrawPolicy::OnSetNeedsDisplay); + self.content + .set_contents_redraw_policy(LayerContentsRedrawPolicy::OnSetNeedsDisplay); + + window.set_content_view(&self.content); + + self.content.set_can_draw_subviews_into_layer(true); + + let blue_frame = apply_styles(&self.blue, &self.content, Color::SystemBlue, 0); + let red_frame = apply_styles(&self.red, &self.content, Color::SystemRed, 1); + let green_frame = apply_styles(&self.green, &self.content, Color::SystemGreen, 2); + + let alpha_animators = [&self.blue, &self.red, &self.green] + .iter() + .map(|view| view.animator.clone()) + .collect::>(); + + let constraint_animators = [blue_frame, red_frame, green_frame] + .iter() + .map(|frame| { + LayoutConstraint::activate(frame); + + vec![ + frame[0].animator.clone(), + frame[1].animator.clone(), + frame[2].animator.clone(), + frame[3].animator.clone(), + ] + }) + .collect::>>(); + + // Monitor key change events for w/a/s/d, and then animate each view to their correct + // frame and alpha value. + self.key_monitor = Some(Event::local_monitor(EventMask::KeyDown, move |evt| { + let characters = evt.characters(); + + let animation_index = match characters.as_ref() { + "w" => 0, + "a" => 1, + "s" => 2, + "d" => 3, + _ => 4 + }; + + if animation_index == 4 { + return None; + } + + let alpha_animators = alpha_animators.clone(); + let constraint_animators = constraint_animators.clone(); + + AnimationContext::run(move |_ctx| { + alpha_animators.iter().enumerate().for_each(move |(index, view)| { + let animation = ANIMATIONS[index][animation_index]; + view.set_alpha(animation[4]); + }); + + constraint_animators.iter().enumerate().for_each(move |(index, frame)| { + let animation = ANIMATIONS[index][animation_index]; + frame[0].set_offset(animation[0]); + frame[1].set_offset(animation[1]); + frame[2].set_offset(animation[2]); + frame[3].set_offset(animation[3]); + }); + }); + + None + })); + } +} + +fn main() { + App::new("com.test.window", BasicApp { + window: Window::with(WindowConfig::default(), AppWindow::default()) + }) + .run(); +} diff --git a/third_party/cacao/examples/autolayout.rs b/third_party/cacao/examples/autolayout.rs new file mode 100644 index 00000000000..6bcdecdc357 --- /dev/null +++ b/third_party/cacao/examples/autolayout.rs @@ -0,0 +1,102 @@ +//! This example showcases setting up a basic application and window, setting up some views to +//! work with autolayout, and some basic ways to handle colors. + +use cacao::color::{Color, Theme}; +use cacao::layout::{Layout, LayoutConstraint}; +use cacao::view::View; + +use cacao::appkit::menu::{Menu, MenuItem}; +use cacao::appkit::window::{Window, WindowConfig, WindowDelegate}; +use cacao::appkit::{App, AppDelegate}; + +struct BasicApp { + window: Window +} + +impl AppDelegate for BasicApp { + fn did_finish_launching(&self) { + App::set_menu(vec![ + Menu::new("", vec![ + MenuItem::Services, + MenuItem::Separator, + MenuItem::Hide, + MenuItem::HideOthers, + MenuItem::ShowAll, + MenuItem::Separator, + MenuItem::Quit, + ]), + Menu::new("File", vec![MenuItem::CloseWindow]), + Menu::new("View", vec![MenuItem::EnterFullScreen]), + Menu::new("Window", vec![ + MenuItem::Minimize, + MenuItem::Zoom, + MenuItem::Separator, + MenuItem::new("Bring All to Front"), + ]), + ]); + + App::activate(); + + self.window.show(); + } + + fn should_terminate_after_last_window_closed(&self) -> bool { + true + } +} + +#[derive(Default)] +struct AppWindow { + content: View, + blue: View, + red: View, + green: View +} + +impl WindowDelegate for AppWindow { + const NAME: &'static str = "WindowDelegate"; + + fn did_load(&mut self, window: Window) { + window.set_title("AutoLayout Example"); + window.set_minimum_content_size(300., 300.); + + let dynamic = Color::dynamic(|style| match (style.theme, style.contrast) { + (Theme::Dark, _) => Color::SystemGreen, + _ => Color::SystemRed + }); + + self.blue.set_background_color(Color::SystemBlue); + self.blue.layer.set_corner_radius(16.); + self.content.add_subview(&self.blue); + + self.red.set_background_color(Color::SystemRed); + self.content.add_subview(&self.red); + + self.green.set_background_color(dynamic); + self.content.add_subview(&self.green); + + window.set_content_view(&self.content); + + LayoutConstraint::activate(&[ + self.blue.top.constraint_equal_to(&self.content.top).offset(46.), + self.blue.leading.constraint_equal_to(&self.content.leading).offset(16.), + self.blue.bottom.constraint_equal_to(&self.content.bottom).offset(-16.), + self.blue.width.constraint_equal_to_constant(100.), + self.red.top.constraint_equal_to(&self.content.top).offset(46.), + self.red.leading.constraint_equal_to(&self.blue.trailing).offset(16.), + self.red.bottom.constraint_equal_to(&self.content.bottom).offset(-16.), + self.green.top.constraint_equal_to(&self.content.top).offset(46.), + self.green.leading.constraint_equal_to(&self.red.trailing).offset(16.), + self.green.trailing.constraint_equal_to(&self.content.trailing).offset(-16.), + self.green.bottom.constraint_equal_to(&self.content.bottom).offset(-16.), + self.green.width.constraint_equal_to_constant(100.) + ]); + } +} + +fn main() { + App::new("com.test.window", BasicApp { + window: Window::with(WindowConfig::default(), AppWindow::default()) + }) + .run(); +} diff --git a/third_party/cacao/examples/browser/main.rs b/third_party/cacao/examples/browser/main.rs new file mode 100644 index 00000000000..803336073ba --- /dev/null +++ b/third_party/cacao/examples/browser/main.rs @@ -0,0 +1,147 @@ +//! This example showcases setting up a basic application and window, setting up some views to +//! work with autolayout, and some basic ways to handle colors. + +use cacao::notification_center::Dispatcher; +use cacao::webview::{WebView, WebViewConfig, WebViewDelegate}; + +use cacao::appkit::menu::{Menu, MenuItem}; +use cacao::appkit::toolbar::Toolbar; +use cacao::appkit::window::{Window, WindowConfig, WindowDelegate, WindowToolbarStyle}; +use cacao::appkit::{App, AppDelegate}; + +mod toolbar; +use toolbar::BrowserToolbar; + +#[derive(Debug)] +pub enum Action { + Back, + Forwards, + Load(String) +} + +impl Action { + pub fn dispatch(self) { + App::::dispatch_main(self); + } +} + +struct BasicApp { + window: Window +} + +impl AppDelegate for BasicApp { + fn did_finish_launching(&self) { + App::set_menu(vec![ + Menu::new("", vec![ + MenuItem::Services, + MenuItem::Separator, + MenuItem::Hide, + MenuItem::HideOthers, + MenuItem::ShowAll, + MenuItem::Separator, + MenuItem::Quit, + ]), + Menu::new("File", vec![MenuItem::CloseWindow]), + Menu::new("Edit", vec![ + MenuItem::Undo, + MenuItem::Redo, + MenuItem::Separator, + MenuItem::Cut, + MenuItem::Copy, + MenuItem::Paste, + MenuItem::Separator, + MenuItem::SelectAll, + ]), + Menu::new("View", vec![MenuItem::EnterFullScreen]), + Menu::new("Window", vec![ + MenuItem::Minimize, + MenuItem::Zoom, + MenuItem::Separator, + MenuItem::new("Bring All to Front"), + ]), + Menu::new("Help", vec![]), + ]); + + App::activate(); + self.window.show(); + } +} + +impl Dispatcher for BasicApp { + type Message = Action; + + fn on_ui_message(&self, message: Self::Message) { + let window = self.window.delegate.as_ref().unwrap(); + let webview = &window.content; + + match message { + Action::Back => { + webview.go_back(); + }, + Action::Forwards => { + webview.go_forward(); + }, + Action::Load(url) => { + window.load_url(&url); + } + } + } +} + +#[derive(Default)] +pub struct WebViewInstance; + +impl WebViewDelegate for WebViewInstance { + const NAME: &'static str = "BrowserWebViewDelegate"; +} + +struct AppWindow { + toolbar: Toolbar, + content: WebView +} + +impl AppWindow { + pub fn new() -> Self { + AppWindow { + toolbar: Toolbar::new("com.example.BrowserToolbar", BrowserToolbar::new()), + content: WebView::with(WebViewConfig::default(), WebViewInstance::default()) + } + } + + pub fn load_url(&self, url: &str) { + self.toolbar.delegate.as_ref().unwrap().set_url(url); + self.content.load_url(url); + } +} + +impl WindowDelegate for AppWindow { + const NAME: &'static str = "WindowDelegate"; + + fn did_load(&mut self, window: Window) { + window.set_title("Browser Example"); + window.set_autosave_name("CacaoBrowserExample"); + window.set_minimum_content_size(400., 400.); + + window.set_toolbar(&self.toolbar); + window.set_content_view(&self.content); + + self.load_url("https://www.duckduckgo.com/"); + } +} + +fn main() { + App::new("com.test.window", BasicApp { + window: Window::with( + { + let mut config = WindowConfig::default(); + + // This flag is necessary for Big Sur to use the correct toolbar style. + config.toolbar_style = WindowToolbarStyle::Expanded; + + config + }, + AppWindow::new() + ) + }) + .run(); +} diff --git a/third_party/cacao/examples/browser/toolbar.rs b/third_party/cacao/examples/browser/toolbar.rs new file mode 100644 index 00000000000..ce49e3d91c6 --- /dev/null +++ b/third_party/cacao/examples/browser/toolbar.rs @@ -0,0 +1,106 @@ +use cacao::objc::{msg_send, sel}; + +use cacao::button::Button; +use cacao::input::{TextField, TextFieldDelegate}; + +use cacao::appkit::toolbar::{ItemIdentifier, Toolbar, ToolbarDelegate, ToolbarDisplayMode, ToolbarItem}; + +use super::Action; + +const BACK_BUTTON: &str = "BackButton"; +const FWDS_BUTTON: &str = "FwdsButton"; +const URL_BAR: &str = "URLBar"; + +#[derive(Debug)] +pub struct URLBar; + +impl TextFieldDelegate for URLBar { + const NAME: &'static str = "URLBar"; + + fn text_did_end_editing(&self, value: &str) { + Action::Load(value.to_string()).dispatch(); + } +} + +#[derive(Debug)] +pub struct BrowserToolbar { + back_item: ToolbarItem, + forwards_item: ToolbarItem, + url_bar: TextField, + url_bar_item: ToolbarItem +} + +impl BrowserToolbar { + pub fn new() -> Self { + let back_button = Button::new("Back"); + let mut back_item = ToolbarItem::new(BACK_BUTTON); + back_item.set_button(back_button); + back_item.set_action(|_| Action::Back.dispatch()); + + let forwards_button = Button::new("Forwards"); + let mut forwards_item = ToolbarItem::new(FWDS_BUTTON); + forwards_item.set_button(forwards_button); + forwards_item.set_action(|_| Action::Forwards.dispatch()); + + let url_bar = TextField::with(URLBar); + let url_bar_item = ToolbarItem::new(URL_BAR); + + // We cheat for now to link these, as there's no API for Toolbar yet + // to support arbitrary view types. The framework is designed to support this kind of + // cheating, though: it's not outlandish to need to just manage things yourself when it + // comes to Objective-C/AppKit sometimes. + // + // As long as we keep hold of things here and they all drop together, it's relatively safe. + url_bar.objc.with_mut(|obj| unsafe { + let _: () = msg_send![&*url_bar_item.objc, setView:&*obj]; + }); + + BrowserToolbar { + back_item, + forwards_item, + url_bar, + url_bar_item + } + } + + pub fn set_url(&self, url: &str) { + self.url_bar.set_text(url); + } + + fn item_identifiers(&self) -> Vec { + vec![ + ItemIdentifier::Custom(BACK_BUTTON), + ItemIdentifier::Custom(FWDS_BUTTON), + ItemIdentifier::Space, + ItemIdentifier::Custom(URL_BAR), + ItemIdentifier::Space, + ] + } +} + +impl ToolbarDelegate for BrowserToolbar { + const NAME: &'static str = "BrowserToolbar"; + + fn did_load(&mut self, toolbar: Toolbar) { + toolbar.set_display_mode(ToolbarDisplayMode::IconOnly); + } + + fn allowed_item_identifiers(&self) -> Vec { + self.item_identifiers() + } + + fn default_item_identifiers(&self) -> Vec { + self.item_identifiers() + } + + fn item_for(&self, identifier: &str) -> &ToolbarItem { + match identifier { + BACK_BUTTON => &self.back_item, + FWDS_BUTTON => &self.forwards_item, + URL_BAR => &self.url_bar_item, + _ => { + std::unreachable!(); + } + } + } +} diff --git a/third_party/cacao/examples/calculator/button_row.rs b/third_party/cacao/examples/calculator/button_row.rs new file mode 100644 index 00000000000..053c9bb1e40 --- /dev/null +++ b/third_party/cacao/examples/calculator/button_row.rs @@ -0,0 +1,79 @@ +use cacao::button::Button; +use cacao::color::Color; +use cacao::layout::{Layout, LayoutConstraint}; +use cacao::view::View; + +use crate::calculator::Msg; +use crate::content_view::{button, BUTTON_HEIGHT, BUTTON_WIDTH}; + +pub struct ButtonRow { + pub view: View, + pub buttons: Vec + +
+ + + +
+
+ + + + + + \ No newline at end of file diff --git a/third_party/nokhwa/examples/jscam/src/index.js b/third_party/nokhwa/examples/jscam/src/index.js new file mode 100644 index 00000000000..d2898f2d07f --- /dev/null +++ b/third_party/nokhwa/examples/jscam/src/index.js @@ -0,0 +1,120 @@ +import init, { requestPermissions, queryConstraints, queryCameras, NokhwaCamera, CameraConstraints, CameraConstraintsBuilder, CameraFacingMode, CameraResizeMode } from 'nokhwa'; + +async function start() { + await init(); +} +start(); + +const requestStatus = document.getElementById("requestStatus"); +const requestButton = document.getElementById("requestButton"); + +requestButton.addEventListener("click", function (event) { + requestPermissions().then( + ok => { + requestStatus.innerHTML = "Granted :D"; + }, + err => { + requestStatus.innerHTML = "Denied :( due to " + err.toString(); + } + ) +}); + +const constraintList = document.getElementById("constraintList"); +const constraintButton = document.getElementById("constraintButton"); + +constraintButton.addEventListener("click", function (event) { + constraintList.innerHTML = ""; + queryConstraints().forEach((element) => { + var new_list_element = document.createElement("li"); + new_list_element.innerHTML = element.toString(); + constraintList.appendChild(new_list_element); + }) +}); + +const deviceLabel = document.getElementById("deviceLabel"); +const deviceList = document.getElementById("deviceList"); +const deviceButton = document.getElementById("deviceButton"); +const deviceDropdown = document.getElementById("deviceDropdown"); + +deviceButton.addEventListener("click", function (event) { + deviceList.innerHTML = ""; + deviceDropdown.innerHTML = ""; + queryCameras().then( + ok => { + ok.forEach((element) => { + var new_list_element = document.createElement("li"); + new_list_element.innerHTML = "Name: " + element.HumanReadableName; + deviceList.appendChild(new_list_element); + + var new_option = document.createElement("option"); + new_option.value = element.MiscString; + new_option.innerHTML = element.HumanReadableName; + deviceDropdown.appendChild(new_option); + }) + }, + err => { + deviceLabel.innerHTML = "device list: error: " + err.toString(); + } + ) +}); + +const deviceOpenButton = document.getElementById("deviceOpenButton"); +const streamPlayLabel = document.getElementById("streamPlayLabel"); +const streamPlayArea = document.getElementById("streamPlayArea"); +var nokhwaCamera = undefined; + +deviceOpenButton.addEventListener("click", function(event) { + streamPlayArea.innerHTML = ""; + let constraints = (new CameraConstraintsBuilder()).buildCameraConstraints(); + nokhwaCamera = new NokhwaCamera(constraints).catch((err) => {console.error(err); return}); + nokhwaCamera = nokhwaCamera.then( + (ok) => { + nokhwaCamera = ok; + nokhwaCamera.attachToElement("streamPlayArea", true); + streamPlayLabel.innerHTML = "Stream Get!"; + }, + (err) => { + console.error(err); + streamPlayLabel.innerHTML = "Error: " + err.toString(); + return; + } + ); +}); + +const streamStopButton = document.getElementById("streamStopButton"); + +streamStopButton.addEventListener("click", function(event) { + if (nokhwaCamera !== undefined) { + nokhwaCamera.detachCamera().then( + (ok) => { + nokhwaCamera = undefined; + streamPlayArea.innerHTML = ""; + streamPlayLabel.innerHTML = "Stream Detached..."; + }, + (err) => { + console.error(err); + streamPlayLabel.innerHTML = "Error: " + err.toString(); + return; + } + ) + } +}); + +const streamCaptureImageButton = document.getElementById("streamCaptureImageButton"); + +function downloadURI(uri, name) { + var link = document.createElement("a"); + link.download = name; + link.href = uri; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + + +streamCaptureImageButton.addEventListener("click", function(event) { + if (nokhwaCamera !== undefined) { + let uri = nokhwaCamera.captureImageURI("image/jpeg", 0.75); + downloadURI(uri, "capture.jpg"); + } +}); diff --git a/third_party/nokhwa/flake.lock b/third_party/nokhwa/flake.lock new file mode 100644 index 00000000000..02611d742de --- /dev/null +++ b/third_party/nokhwa/flake.lock @@ -0,0 +1,96 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1730958623, + "narHash": "sha256-JwQZIGSYnRNOgDDoIgqKITrPVil+RMWHsZH1eE1VGN0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "85f7e662eda4fa3a995556527c87b2524b691933", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1728538411, + "narHash": "sha256-f0SBJz1eZ2yOuKUr5CA9BHULGXVSn6miBuUWdTyhUhU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1731292155, + "narHash": "sha256-fYVoUUtSadbOrH0z0epVQDsStBDS/S/fAK//0ECQAAI=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "7c4cd99ed7604b79e8cb721099ac99c66f656b3a", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/third_party/nokhwa/flake.nix b/third_party/nokhwa/flake.nix new file mode 100644 index 00000000000..d6f5bb079fd --- /dev/null +++ b/third_party/nokhwa/flake.nix @@ -0,0 +1,55 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + in + { + devShells.default = pkgs.mkShell { + #LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; + #BINDGEN_EXTRA_CLANG_ARGS = "-isystem ${pkgs.libclang.lib}/lib/clang/${flake-utils.lib.getVersion pkgs.clang}/include"; + + buildInputs = with pkgs; [ + rust-bin.stable.latest.default + rust-bin.stable.latest.rustfmt + rust-bin.stable.latest.clippy + ]; + nativeBuildInputs = [ + pkgs.pkg-config + pkgs.cmake + pkgs.vcpkg + ]; + packages = with pkgs; [ + rust-analyzer + pkg-config + opencv + alsa-lib + systemdLibs + cmake + fontconfig + linuxHeaders + rustPlatform.bindgenHook + llvmPackages.libclang.lib + llvmPackages.clang + libv4l + v4l-utils + ]; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + shellHook = '' + export LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + cargo version + ''; + + }; + } + ); +} diff --git a/third_party/nokhwa/make-npm.sh b/third_party/nokhwa/make-npm.sh new file mode 100755 index 00000000000..6c2ba77e846 --- /dev/null +++ b/third_party/nokhwa/make-npm.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# +# Copyright 2022 l1npengtul / The Nokhwa Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +wasm-pack build --release --target web -- --features "input-jscam, output-wasm, small-wasm, test-fail-warning" --no-default-features +mv pkg/nokhwa* nokhwajs/ diff --git a/third_party/nokhwa/publish.sh b/third_party/nokhwa/publish.sh new file mode 100644 index 00000000000..c6d25f975b2 --- /dev/null +++ b/third_party/nokhwa/publish.sh @@ -0,0 +1,10 @@ +cd nokhwa-core || exit +cargo publish +cd ../nokhwa-bindings-linux || exit +cargo publish +cd ../nokhwa-bindings-macos || exit +cargo publish +cd ../nokhwa-bindings-windows || exit +cargo publish +cd .. || exit +cargo publish diff --git a/third_party/nokhwa/src/backends/capture/avfoundation.rs b/third_party/nokhwa/src/backends/capture/avfoundation.rs new file mode 100644 index 00000000000..d0bf0fc7c75 --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/avfoundation.rs @@ -0,0 +1,498 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#[cfg(target_os = "macos")] +use flume::{Receiver, Sender}; +#[cfg(target_os = "macos")] +use nokhwa_bindings_macos::{ + AVCaptureDevice, AVCaptureDeviceInput, AVCaptureSession, AVCaptureVideoCallback, + AVCaptureVideoDataOutput, +}; +use nokhwa_core::{ + buffer::Buffer, + error::NokhwaError, + pixel_format::RgbFormat, + traits::CaptureBackendTrait, + types::{ + ApiBackend, CameraControl, CameraFormat, CameraIndex, CameraInfo, ControlValueSetter, + FrameFormat, KnownCameraControl, RequestedFormat, RequestedFormatType, Resolution, + }, +}; +#[cfg(target_os = "macos")] +use std::{ffi::CString, sync::Arc}; + +use std::{borrow::Cow, collections::HashMap}; + +/// The backend struct that interfaces with V4L2. +/// To see what this does, please see [`CaptureBackendTrait`]. +/// # Quirks +/// - While working with `iOS` is allowed, it is not officially supported and may not work. +/// - You **must** call [`nokhwa_initialize`](crate::nokhwa_initialize) **before** doing anything with `AVFoundation`. +/// - This only works on 64 bit platforms. +/// - FPS adjustment does not work. +/// - If permission has not been granted and you call `init()` it will error. +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-avfoundation")))] +#[cfg(target_os = "macos")] +pub struct AVFoundationCaptureDevice { + device: AVCaptureDevice, + dev_input: Option, + session: Option, + data_out: Option, + data_collect: Option, + info: CameraInfo, + buffer_name: CString, + format: CameraFormat, + frame_buffer_receiver: Arc, FrameFormat)>>, + fbufsnd: Arc, FrameFormat)>>, +} + +#[cfg(target_os = "macos")] +impl AVFoundationCaptureDevice { + /// Creates a new capture device using the `AVFoundation` backend. Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// + /// If `camera_format` is `None`, it will be spawned with with 640x480@15 FPS, MJPEG [`CameraFormat`] default. + /// # Errors + /// This function will error if the camera is currently busy or if `AVFoundation` can't read device information, or permission was not given by the user. + pub fn new(index: &CameraIndex, req_fmt: RequestedFormat) -> Result { + let mut device = AVCaptureDevice::new(index)?; + + // device.lock()?; + let formats = device.supported_formats()?; + let camera_fmt = req_fmt.fulfill(&formats).ok_or_else(|| { + NokhwaError::OpenDeviceError("Cannot fulfill request".to_string(), req_fmt.to_string()) + })?; + device.set_all(camera_fmt)?; + + let device_descriptor = device.info().clone(); + let buffername = + CString::new(format!("{}_INDEX{}_", device_descriptor, index)).map_err(|why| { + NokhwaError::StructureError { + structure: "CString Buffername".to_string(), + error: why.to_string(), + } + })?; + + let (send, recv) = flume::unbounded(); + Ok(AVFoundationCaptureDevice { + device, + dev_input: None, + session: None, + data_out: None, + data_collect: None, + info: device_descriptor, + buffer_name: buffername, + format: camera_fmt, + frame_buffer_receiver: Arc::new(recv), + fbufsnd: Arc::new(send), + }) + } + + /// Creates a new capture device using the `AVFoundation` backend with desired settings. + /// + /// # Errors + /// This function will error if the camera is currently busy or if `AVFoundation` can't read device information, or permission was not given by the user. + #[deprecated(since = "0.10.0", note = "please use `new` instead.")] + #[allow(clippy::cast_possible_truncation)] + pub fn new_with( + index: usize, + width: u32, + height: u32, + fps: u32, + fourcc: FrameFormat, + ) -> Result { + let camera_format = CameraFormat::new_from(width, height, fourcc, fps); + AVFoundationCaptureDevice::new( + &CameraIndex::Index(index as u32), + RequestedFormat::new::(RequestedFormatType::Exact(camera_format)), + ) + } +} + +#[cfg(target_os = "macos")] +impl CaptureBackendTrait for AVFoundationCaptureDevice { + fn backend(&self) -> ApiBackend { + ApiBackend::AVFoundation + } + + fn camera_info(&self) -> &CameraInfo { + &self.info + } + + fn refresh_camera_format(&mut self) -> Result<(), NokhwaError> { + self.format = self.device.active_format()?; + Ok(()) + } + + fn camera_format(&self) -> CameraFormat { + self.format + } + + fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + self.device.set_all(new_fmt)?; + self.format = new_fmt; + Ok(()) + } + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + fn compatible_list_by_resolution( + &mut self, + fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + let supported_cfmt = self + .device + .supported_formats()? + .into_iter() + .filter(|x| x.format() != fourcc); + let mut res_list = HashMap::new(); + for format in supported_cfmt { + match res_list.get_mut(&format.resolution()) { + Some(fpses) => Vec::push(fpses, format.frame_rate()), + None => { + res_list.insert(format.resolution(), vec![format.frame_rate()]); + } + } + } + Ok(res_list) + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + let mut formats = self + .device + .supported_formats()? + .into_iter() + .map(|fmt| fmt.format()) + .collect::>(); + formats.sort(); + formats.dedup(); + Ok(formats) + } + + fn resolution(&self) -> Resolution { + self.camera_format().resolution() + } + + fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + let mut format = self.camera_format(); + format.set_resolution(new_res); + self.set_camera_format(format) + } + + fn frame_rate(&self) -> u32 { + self.camera_format().frame_rate() + } + + fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + let mut format = self.camera_format(); + format.set_frame_rate(new_fps); + self.set_camera_format(format) + } + + fn frame_format(&self) -> FrameFormat { + self.camera_format().format() + } + + fn set_frame_format(&mut self, fourcc: FrameFormat) -> Result<(), NokhwaError> { + let mut format = self.camera_format(); + format.set_format(fourcc); + self.set_camera_format(format) + } + + fn camera_control(&self, control: KnownCameraControl) -> Result { + for ctrl in self.device.get_controls()? { + if ctrl.control() == control { + return Ok(ctrl); + } + } + + Err(NokhwaError::GetPropertyError { + property: control.to_string(), + error: "Not Found".to_string(), + }) + } + + fn camera_controls(&self) -> Result, NokhwaError> { + self.device.get_controls() + } + + fn set_camera_control( + &mut self, + id: KnownCameraControl, + value: ControlValueSetter, + ) -> Result<(), NokhwaError> { + self.device.lock()?; + let res = self.device.set_control(id, value); + self.device.unlock(); + res + } + + fn open_stream(&mut self) -> Result<(), NokhwaError> { + self.refresh_camera_format()?; + + let input = AVCaptureDeviceInput::new(&self.device)?; + let session = AVCaptureSession::new(); + session.begin_configuration(); + session.add_input(&input)?; + + self.device.set_all(self.format)?; // hurr durr im an apple api and im fucking dumb hurr durr + + let bufname = &self.buffer_name; + let videocallback = AVCaptureVideoCallback::new(bufname, &self.fbufsnd)?; + let output = AVCaptureVideoDataOutput::new(); + output.add_delegate(&videocallback)?; + output.set_frame_format(self.camera_format().format())?; + session.add_output(&output)?; + session.commit_configuration(); + session.start()?; + + self.dev_input = Some(input); + self.session = Some(session); + self.data_collect = Some(videocallback); + self.data_out = Some(output); + Ok(()) + } + + fn is_stream_open(&self) -> bool { + if self.session.is_some() + && self.data_out.is_some() + && self.data_collect.is_some() + && self.dev_input.is_some() + { + return true; + } + match &self.session { + Some(session) => (!session.is_interrupted()) && session.is_running(), + None => false, + } + } + + fn frame(&mut self) -> Result { + self.refresh_camera_format()?; + let cfmt = self.camera_format(); + let b = self.frame_raw()?; + let buffer = Buffer::new(cfmt.resolution(), b.as_ref(), cfmt.format()); + let _ = self.frame_buffer_receiver.drain(); + Ok(buffer) + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + let result = match self.frame_buffer_receiver.recv() { + Ok(recv) => Ok(Cow::from(recv.0)), + Err(why) => Err(NokhwaError::ReadFrameError(why.to_string())), + }; + result + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + if !self.is_stream_open() { + return Ok(()); + } + + let session = match &self.session { + Some(session) => session, + None => { + return Err(NokhwaError::GetPropertyError { + property: "AVCaptureSession".to_string(), + error: "Doesnt Exist".to_string(), + }) + } + }; + + let output = match &self.data_out { + Some(output) => output, + None => { + return Err(NokhwaError::GetPropertyError { + property: "AVCaptureVideoDataOutput".to_string(), + error: "Doesnt Exist".to_string(), + }) + } + }; + + let input = match &self.dev_input { + Some(input) => input, + None => { + return Err(NokhwaError::GetPropertyError { + property: "AVCaptureDeviceInput".to_string(), + error: "Doesnt Exist".to_string(), + }) + } + }; + + session.remove_output(output); + session.remove_input(input); + session.stop(); + + self.frame_buffer_receiver.try_iter(); + self.dev_input = None; + self.session = None; + self.data_collect = None; + self.data_out = None; + + Ok(()) + } +} + +#[cfg(target_os = "macos")] +impl Drop for AVFoundationCaptureDevice { + fn drop(&mut self) { + if self.stop_stream().is_err() {} + self.device.unlock(); + } +} + +/// The backend struct that interfaces with V4L2. +/// To see what this does, please see [`CaptureBackendTrait`]. +/// # Quirks +/// - While working with `iOS` is allowed, it is not officially supported and may not work. +/// - You **must** call [`nokhwa_initialize`](crate::nokhwa_initialize) **before** doing anything with `AVFoundation`. +/// - This only works on 64 bit platforms. +/// - FPS adjustment does not work. +/// - If permission has not been granted and you call `init()` it will error. +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-avfoundation")))] +#[cfg(not(target_os = "macos"))] +pub struct AVFoundationCaptureDevice {} + +#[cfg(not(target_os = "macos"))] +#[allow(unused_variables)] +#[allow(unreachable_code)] +impl AVFoundationCaptureDevice { + /// Creates a new capture device using the `AVFoundation` backend. Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// + /// If `camera_format` is `None`, it will be spawned with with 640x480@15 FPS, MJPEG [`CameraFormat`] default. + /// # Errors + /// This function will error if the camera is currently busy or if `AVFoundation` can't read device information, or permission was not given by the user. + pub fn new(index: &CameraIndex, req_fmt: RequestedFormat) -> Result { + todo!() + } + + /// Creates a new capture device using the `AVFoundation` backend with desired settings. + /// + /// # Errors + /// This function will error if the camera is currently busy or if `AVFoundation` can't read device information, or permission was not given by the user. + #[deprecated(since = "0.10.0", note = "please use `new` instead.")] + #[allow(clippy::cast_possible_truncation)] + pub fn new_with( + index: usize, + width: u32, + height: u32, + fps: u32, + fourcc: FrameFormat, + ) -> Result { + todo!() + } +} + +#[cfg(not(target_os = "macos"))] +#[allow(unreachable_code)] +impl CaptureBackendTrait for AVFoundationCaptureDevice { + fn backend(&self) -> ApiBackend { + todo!() + } + + fn camera_info(&self) -> &CameraInfo { + todo!() + } + + fn refresh_camera_format(&mut self) -> Result<(), NokhwaError> { + todo!() + } + + fn camera_format(&self) -> CameraFormat { + todo!() + } + + fn set_camera_format(&mut self, _: CameraFormat) -> Result<(), NokhwaError> { + todo!() + } + + fn compatible_list_by_resolution( + &mut self, + _: FrameFormat, + ) -> Result>, NokhwaError> { + todo!() + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + todo!() + } + + fn resolution(&self) -> Resolution { + todo!() + } + + fn set_resolution(&mut self, _: Resolution) -> Result<(), NokhwaError> { + todo!() + } + + fn frame_rate(&self) -> u32 { + todo!() + } + + fn set_frame_rate(&mut self, _: u32) -> Result<(), NokhwaError> { + todo!() + } + + fn frame_format(&self) -> FrameFormat { + todo!() + } + + fn set_frame_format(&mut self, _: FrameFormat) -> Result<(), NokhwaError> { + todo!() + } + + fn camera_control(&self, _: KnownCameraControl) -> Result { + todo!() + } + + fn camera_controls(&self) -> Result, NokhwaError> { + todo!() + } + + fn set_camera_control( + &mut self, + _: KnownCameraControl, + _: ControlValueSetter, + ) -> Result<(), NokhwaError> { + todo!() + } + + fn open_stream(&mut self) -> Result<(), NokhwaError> { + todo!() + } + + fn is_stream_open(&self) -> bool { + todo!() + } + + fn frame(&mut self) -> Result { + todo!() + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + todo!() + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + todo!() + } +} + +#[cfg(not(target_os = "macos"))] +#[allow(unreachable_code)] +impl Drop for AVFoundationCaptureDevice { + fn drop(&mut self) { + todo!() + } +} diff --git a/third_party/nokhwa/src/backends/capture/gst_backend.rs b/third_party/nokhwa/src/backends/capture/gst_backend.rs new file mode 100644 index 00000000000..85911ba8e0e --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/gst_backend.rs @@ -0,0 +1,839 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use crate::{ + mjpeg_to_rgb, yuyv422_to_rgb, ApiBackend, CameraControl, CameraFormat, CameraInfo, + CaptureBackendTrait, FrameFormat, KnownCameraControl, NokhwaError, Resolution, +}; +use glib::Quark; +use gstreamer::{ + element_error, + glib::Cast, + prelude::{DeviceExt, DeviceMonitorExt, DeviceMonitorExtManual, ElementExt, GstBinExt}, + Bin, Caps, ClockTime, DeviceMonitor, Element, FlowError, FlowSuccess, MessageView, + ResourceError, State, +}; +use gstreamer_app::{AppSink, AppSinkCallbacks}; +use gstreamer_video::{VideoFormat, VideoInfo}; +use image::{ImageBuffer, Rgb}; +use parking_lot::Mutex; +use regex::Regex; +use std::{any::Any, borrow::Cow, collections::HashMap, str::FromStr, sync::Arc}; + +type PipelineGenRet = (Element, AppSink, Arc, Vec>>>); + +/// The backend struct that interfaces with `GStreamer`. +/// To see what this does, please see [`CaptureBackendTrait`]. +/// # Quirks +/// - `Drop`-ing this may cause a `panic`. +/// - Setting controls is not supported. +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-gst")))] +#[deprecated( + since = "0.10", + note = "Use one of the native backends instead(V4L, AVF, MSMF) or OpenCV" +)] +pub struct GStreamerCaptureDevice { + pipeline: Element, + app_sink: AppSink, + camera_format: CameraFormat, + camera_info: CameraInfo, + image_lock: Arc, Vec>>>, + caps: Option, +} + +impl GStreamerCaptureDevice { + /// Creates a new capture device using the `GStreamer` backend. Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// + /// `GStreamer` uses `v4l2src` on linux, `ksvideosrc` on windows, and `autovideosrc` on mac. + /// + /// If `camera_format` is `None`, it will be spawned with with 640x480@15 FPS, MJPEG [`CameraFormat`] default. + /// # Errors + /// This function will error if the camera is currently busy or if `GStreamer` can't read device information. + pub fn new(index: usize, cam_fmt: Option) -> Result { + let camera_format = match cam_fmt { + Some(fmt) => fmt, + None => CameraFormat::default(), + }; + + let index = index.as_index()?; + + if let Err(why) = gstreamer::init() { + return Err(NokhwaError::InitializeError { + backend: ApiBackend::GStreamer, + error: why.to_string(), + }); + } + + let (camera_info, caps) = { + let device_monitor = DeviceMonitor::new(); + let video_caps = match Caps::from_str("video/x-raw") { + Ok(cap) => cap, + Err(why) => { + return Err(NokhwaError::GeneralError(format!( + "Failed to generate caps: {}", + why + ))) + } + }; + let _video_filter_id = + match device_monitor.add_filter(Some("Video/Source"), Some(&video_caps)) { + Some(id) => id, + None => { + return Err(NokhwaError::StructureError { + structure: "Video Filter ID Video/Source".to_string(), + error: "Null".to_string(), + }) + } + }; + if let Err(why) = device_monitor.start() { + return Err(NokhwaError::StructureError { + structure: "Device Monitor".to_string(), + error: format!("Not started, {}", why), + }); + } + let device = match device_monitor.devices().get(index as usize) { + Some(dev) => dev.clone(), + None => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + "No device".to_string(), + )) + } + }; + device_monitor.stop(); + let caps = device.caps(); + ( + CameraInfo::new( + &DeviceExt::display_name(&device), + &DeviceExt::device_class(&device), + &"", + index, + ), + caps, + ) + }; + + let (pipeline, app_sink, receiver) = generate_pipeline(camera_format, index as usize)?; + + Ok(GStreamerCaptureDevice { + pipeline, + app_sink, + camera_format, + camera_info, + image_lock: receiver, + caps, + }) + } + + /// Creates a new capture device using the `GStreamer` backend. Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// + /// `GStreamer` uses `v4l2src` on linux, `ksvideosrc` on windows, and `autovideosrc` on mac. + /// # Errors + /// This function will error if the camera is currently busy or if `GStreamer` can't read device information. + pub fn new_with(index: usize, width: u32, height: u32, fps: u32) -> Result { + let cam_fmt = CameraFormat::new(Resolution::new(width, height)); + GStreamerCaptureDevice::new(index, Some(cam_fmt)) + } +} + +impl GStreamerCaptureDevice { + fn backend(&self) -> ApiBackend { + ApiBackend::GStreamer + } + + fn camera_info(&self) -> &CameraInfo { + &self.camera_info + } + + fn camera_format(&self) -> CameraFormat { + self.camera_format + } + + fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + let mut reopen = false; + if self.is_stream_open() { + self.stop_stream()?; + reopen = true; + } + let (pipeline, app_sink, receiver) = + generate_pipeline(new_fmt, self.camera_info.index_num()? as usize)?; + self.pipeline = pipeline; + self.app_sink = app_sink; + self.image_lock = receiver; + if reopen { + self.open_stream()?; + } + self.camera_format = new_fmt; + Ok(()) + } + + #[allow(clippy::too_many_lines)] + #[allow(clippy::cast_sign_loss)] + fn compatible_list_by_resolution( + &mut self, + fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + let mut resolution_map = HashMap::new(); + + let frame_regex = Regex::new(r"(\d+/1)|((\d+/\d)+(\d/1)*)").unwrap(); + + match self.caps.clone() { + Some(c) => { + for capability in c.iter() { + match fourcc { + FrameFormat::MJPEG => { + if capability.name() == "image/jpeg" { + let mut fps_vec = vec![]; + + let width = match capability.get::("width") { + Ok(w) => w, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Capibilities by Resolution: Width" + .to_string(), + error: why.to_string(), + }) + } + }; + let height = match capability.get::("height") { + Ok(w) => w, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Capibilities by Resolution: Height" + .to_string(), + error: why.to_string(), + }) + } + }; + let value = match capability + .value_by_quark(Quark::from_string("framerate")) + { + Ok(v) => match v.transform::() { + Ok(s) => { + format!("{:?}", s) + } + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: format!( + "Failed to make framerates into string: {}", + why + ), + }); + } + }, + Err(_) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: "Failed to get framerates: doesnt exist!" + .to_string(), + }) + } + }; + + for m in frame_regex.find_iter(&value) { + let fraction_string: Vec<&str> = + m.as_str().split('/').collect(); + if fraction_string.len() != 2 { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: format!("Fraction framerate had more than one demoninator: {:?}", fraction_string) }); + } + + if let Some(v) = fraction_string.get(1) { + if *v != "1" { + continue; // swallow error + } + } else { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: "No framerate denominator? Shouldn't happen, please report!".to_string() }); + } + + if let Some(numerator) = fraction_string.get(0) { + match numerator.parse::() { + Ok(fps) => fps_vec.push(fps), + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: format!( + "Failed to parse numerator: {}", + why + ), + }); + } + } + } else { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: "No framerate numerator? Shouldn't happen, please report!".to_string() }); + } + } + resolution_map + .insert(Resolution::new(width as u32, height as u32), fps_vec); + } + } + FrameFormat::YUYV => { + if capability.name() == "video/x-raw" + && capability.get::("format").unwrap_or_default() == *"YUY2" + { + let mut fps_vec = vec![]; + + let width = match capability.get::("width") { + Ok(w) => w, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Capibilities by Resolution: Width" + .to_string(), + error: why.to_string(), + }) + } + }; + let height = match capability.get::("height") { + Ok(w) => w, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Capibilities by Resolution: Height" + .to_string(), + error: why.to_string(), + }) + } + }; + let value = match capability + .value_by_quark(Quark::from_string("framerate")) + { + Ok(v) => match v.transform::() { + Ok(s) => { + format!("{:?}", s) + } + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: format!( + "Failed to make framerates into string: {}", + why + ), + }); + } + }, + Err(_) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: "Failed to get framerates: doesnt exist!" + .to_string(), + }) + } + }; + + for m in frame_regex.find_iter(&value) { + let fraction_string: Vec<&str> = + m.as_str().split('/').collect(); + if fraction_string.len() != 2 { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: format!("Fraction framerate had more than one demoninator: {:?}", fraction_string) }); + } + + if let Some(v) = fraction_string.get(1) { + if *v != "1" { + continue; // swallow error + } + } else { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: "No framerate denominator? Shouldn't happen, please report!".to_string() }); + } + + if let Some(numerator) = fraction_string.get(0) { + match numerator.parse::() { + Ok(fps) => fps_vec.push(fps), + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Framerates".to_string(), + error: format!( + "Failed to parse numerator: {}", + why + ), + }); + } + } + } else { + return Err(NokhwaError::GetPropertyError { property: "Framerates".to_string(), error: "No framerate numerator? Shouldn't happen, please report!".to_string() }); + } + } + resolution_map + .insert(Resolution::new(width as u32, height as u32), fps_vec); + } + } + unsupported => { + return Err(NokhwaError::NotImplementedError(format!( + "Not supported frame format {unsupported:?}" + ))) + } + } + } + } + None => { + return Err(NokhwaError::GetPropertyError { + property: "Device Caps".to_string(), + error: "No device caps!".to_string(), + }) + } + } + + Ok(resolution_map) + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + let mut format_vec = vec![]; + match self.caps.clone() { + Some(c) => { + for capability in c.iter() { + if capability.name() == "image/jpeg" { + format_vec.push(FrameFormat::MJPEG); + } else if capability.name() == "video/x-raw" + && capability.get::("format").unwrap_or_default() == *"YUY2" + { + format_vec.push(FrameFormat::YUYV); + } + } + } + None => { + return Err(NokhwaError::GetPropertyError { + property: "Device Caps".to_string(), + error: "No device caps!".to_string(), + }) + } + } + format_vec.sort(); + format_vec.dedup(); + Ok(format_vec) + } + + fn resolution(&self) -> Resolution { + self.camera_format.resolution() + } + + fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + let mut new_fmt = self.camera_format; + new_fmt.set_resolution(new_res); + self.set_camera_format(new_fmt) + } + + fn frame_rate(&self) -> u32 { + self.camera_format.frame_rate() + } + + fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + let mut new_fmt = self.camera_format; + new_fmt.set_frame_rate(new_fps); + self.set_camera_format(new_fmt) + } + + fn frame_format(&self) -> FrameFormat { + self.camera_format.format() + } + + fn set_frame_format(&mut self, _fourcc: FrameFormat) -> Result<(), NokhwaError> { + Err(NokhwaError::UnsupportedOperationError( + ApiBackend::GStreamer, + )) + } + + fn supported_camera_controls(&self) -> Result, NokhwaError> { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn camera_control(&self, _control: KnownCameraControl) -> Result { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn set_camera_control(&mut self, _control: CameraControl) -> Result<(), NokhwaError> { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn raw_supported_camera_controls(&self) -> Result>, NokhwaError> { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn raw_camera_control(&self, _control: &dyn Any) -> Result, NokhwaError> { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn set_raw_camera_control( + &mut self, + _control: &dyn Any, + _value: &dyn Any, + ) -> Result<(), NokhwaError> { + Err(NokhwaError::NotImplementedError( + ApiBackend::GStreamer.to_string(), + )) + } + + fn open_stream(&mut self) -> Result<(), NokhwaError> { + if let Err(why) = self.pipeline.set_state(State::Playing) { + return Err(NokhwaError::OpenStreamError(format!( + "Failed to set appsink to playing: {}", + why + ))); + } + Ok(()) + } + + // TODO: someone validate this + fn is_stream_open(&self) -> bool { + let (res, state_from, state_to) = self.pipeline.state(ClockTime::from_mseconds(16)); + if res.is_ok() { + if state_to == State::Playing { + return true; + } + false + } else { + if state_from == State::Playing { + return true; + } + false + } + } + + fn frame(&mut self) -> Result, Vec>, NokhwaError> { + let cam_fmt = self.camera_format; + let image_data = self.frame_raw()?; + let imagebuf = + match ImageBuffer::from_vec(cam_fmt.width(), cam_fmt.height(), image_data.to_vec()) { + Some(buf) => { + let rgbbuf: ImageBuffer, Vec> = buf; + rgbbuf + } + None => return Err(NokhwaError::ReadFrameError( + "Imagebuffer is not large enough! This is probably a bug, please report it!" + .to_string(), + )), + }; + Ok(imagebuf) + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + let bus = match self.pipeline.bus() { + Some(bus) => bus, + None => { + return Err(NokhwaError::ReadFrameError( + "The pipeline has no bus!".to_string(), + )) + } + }; + + if let Some(message) = bus.timed_pop(ClockTime::from_seconds(0)) { + match message.view() { + MessageView::Eos(..) => { + return Err(NokhwaError::ReadFrameError("Stream is ended!".to_string())) + } + MessageView::Error(err) => { + return Err(NokhwaError::ReadFrameError(format!( + "Bus error: {}", + err.error() + ))); + } + _ => {} + } + } + + Ok(Cow::from(self.image_lock.lock().to_vec())) + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + if let Err(why) = self.pipeline.set_state(State::Null) { + return Err(NokhwaError::StreamShutdownError(format!( + "Could not change state: {}", + why + ))); + } + Ok(()) + } +} + +impl Drop for GStreamerCaptureDevice { + fn drop(&mut self) { + let _ = self.pipeline.set_state(State::Null); + } +} + +#[cfg(target_os = "macos")] +fn webcam_pipeline(device: &str, camera_format: CameraFormat) -> String { + match camera_format.format() { + FrameFormat::MJPEG => { + format!("autovideosrc location=/dev/video{} ! image/jpeg,width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + FrameFormat::YUYV => { + format!("autovideosrc location=/dev/video{} ! video/x-raw,format=YUY2,width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + _ => { + format!("unsupproted! if you see this, switch to something else!") + } + } +} + +#[cfg(target_os = "linux")] +fn webcam_pipeline(device: &str, camera_format: CameraFormat) -> String { + match camera_format.format() { + FrameFormat::MJPEG => { + format!("v4l2src device=/dev/video{} ! image/jpeg, width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + FrameFormat::YUYV => { + format!("v4l2src device=/dev/video{} ! video/x-raw,format=YUY2,width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + _ => { + format!("unsupproted! if you see this, switch to something else!") + } + } +} + +#[cfg(target_os = "windows")] +fn webcam_pipeline(device: &str, camera_format: CameraFormat) -> String { + match camera_format.format() { + FrameFormat::MJPEG => { + format!("ksvideosrc device_index={} ! image/jpeg, width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + FrameFormat::YUYV => { + format!("ksvideosrc device_index={} ! video/x-raw,format=YUY2,width={},height={},framerate={}/1 ! appsink name=appsink async=false sync=false", device, camera_format.width(), camera_format.height(), camera_format.frame_rate()) + } + _ => { + format!("unsupproted! if you see this, switch to something else!") + } + } +} + +#[allow(clippy::too_many_lines)] +#[allow(clippy::let_and_return)] +fn generate_pipeline(fmt: CameraFormat, index: usize) -> Result { + let pipeline = + match gstreamer::parse_launch(webcam_pipeline(format!("{}", index).as_str(), fmt).as_str()) + { + Ok(p) => p, + Err(why) => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + format!( + "Failed to open pipeline with args {}: {}", + webcam_pipeline(format!("{}", index).as_str(), fmt), + why + ), + )) + } + }; + + let sink = match pipeline + .clone() + .dynamic_cast::() + .unwrap() + .by_name("appsink") + { + Some(s) => s, + None => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + "Failed to get sink element!".to_string(), + )) + } + }; + + let appsink = match sink.dynamic_cast::() { + Ok(aps) => aps, + Err(_) => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + "Failed to get sink element as appsink".to_string(), + )) + } + }; + + pipeline.set_state(State::Playing).unwrap(); + + let image_lock = Arc::new(Mutex::new(ImageBuffer::default())); + let img_lck_clone = image_lock.clone(); + + appsink.set_callbacks( + AppSinkCallbacks::builder() + .new_sample(move |appsink| { + let sample = appsink.pull_sample().map_err(|_| FlowError::Eos)?; + let sample_caps = if let Some(c) = sample.caps() { + c + } else { + element_error!( + appsink, + ResourceError::Failed, + ("Failed to get caps of sample") + ); + return Err(FlowError::Error); + }; + + let video_info = match VideoInfo::from_caps(sample_caps) { + Ok(vi) => vi, // help let me outtttttt + Err(why) => { + element_error!( + appsink, + ResourceError::Failed, + (format!("Failed to get videoinfo from caps: {}", why).as_str()) + ); + + return Err(FlowError::Error); + } + }; + + let buffer = if let Some(buf) = sample.buffer() { + buf + } else { + element_error!( + appsink, + ResourceError::Failed, + ("Failed to get buffer from sample") + ); + return Err(FlowError::Error); + }; + + let buffer_map = match buffer.map_readable() { + Ok(m) => m, + Err(why) => { + element_error!( + appsink, + ResourceError::Failed, + (format!("Failed to map buffer to readablemap: {}", why).as_str()) + ); + + return Err(FlowError::Error); + } + }; + + let channels = if video_info.has_alpha() { 4 } else { 3 }; + + let image_buffer = match video_info.format() { + VideoFormat::Yuy2 => { + let mut decoded_buffer = match yuyv422_to_rgb(&buffer_map, false) { + Ok(buf) => buf, + Err(why) => { + element_error!( + appsink, + ResourceError::Failed, + (format!("Failed to make yuy2 into rgb888: {}", why).as_str()) + ); + + return Err(FlowError::Error); + } + }; + + decoded_buffer.resize( + (video_info.width() * video_info.height() * channels) as usize, + 0_u8, + ); + + let image = if let Some(i) = ImageBuffer::from_vec( + video_info.width(), + video_info.height(), + decoded_buffer, + ) { + let rgb: ImageBuffer, Vec> = i; + rgb + } else { + element_error!( + appsink, + ResourceError::Failed, + ("Failed to make rgb buffer into imagebuffer") + ); + + return Err(FlowError::Error); + }; + image + } + VideoFormat::Rgb => { + let mut decoded_buffer = buffer_map.as_slice().to_vec(); + decoded_buffer.resize( + (video_info.width() * video_info.height() * channels) as usize, + 0_u8, + ); + let image = if let Some(i) = ImageBuffer::from_vec( + video_info.width(), + video_info.height(), + decoded_buffer, + ) { + let rgb: ImageBuffer, Vec> = i; + rgb + } else { + element_error!( + appsink, + ResourceError::Failed, + ("Failed to make rgb buffer into imagebuffer") + ); + + return Err(FlowError::Error); + }; + image + } + // MJPEG + VideoFormat::Encoded => { + let mut decoded_buffer = match mjpeg_to_rgb(&buffer_map, false) { + Ok(buf) => buf, + Err(why) => { + element_error!( + appsink, + ResourceError::Failed, + (format!("Failed to make yuy2 into rgb888: {}", why).as_str()) + ); + + return Err(FlowError::Error); + } + }; + + decoded_buffer.resize( + (video_info.width() * video_info.height() * channels) as usize, + 0_u8, + ); + + let image = if let Some(i) = ImageBuffer::from_vec( + video_info.width(), + video_info.height(), + decoded_buffer, + ) { + let rgb: ImageBuffer, Vec> = i; + rgb + } else { + element_error!( + appsink, + ResourceError::Failed, + ("Failed to make rgb buffer into imagebuffer") + ); + + return Err(FlowError::Error); + }; + image + } + _ => { + element_error!( + appsink, + ResourceError::Failed, + ("Unsupported video format") + ); + return Err(FlowError::Error); + } + }; + + *img_lck_clone.lock() = image_buffer; + + Ok(FlowSuccess::Ok) + }) + .build(), + ); + Ok((pipeline, appsink, image_lock)) +} diff --git a/third_party/nokhwa/src/backends/capture/mod.rs b/third_party/nokhwa/src/backends/capture/mod.rs new file mode 100644 index 00000000000..0722e69dd92 --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/mod.rs @@ -0,0 +1,83 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#[cfg(all(feature = "input-v4l", target_os = "linux"))] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-v4l")))] +pub use nokhwa_bindings_linux::V4LCaptureDevice; +#[cfg(any( + all(feature = "input-msmf", target_os = "windows"), + all(feature = "docs-only", feature = "docs-nolink", feature = "input-msmf") +))] +mod msmf_backend; +#[cfg(any( + all(feature = "input-msmf", target_os = "windows"), + all(feature = "docs-only", feature = "docs-nolink", feature = "input-msmf") +))] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-msmf")))] +pub use msmf_backend::MediaFoundationCaptureDevice; +#[cfg(any( + all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") + ), + all( + feature = "docs-only", + feature = "docs-nolink", + feature = "input-avfoundation" + ) +))] +mod avfoundation; +#[cfg(any( + all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") + ), + all( + feature = "docs-only", + feature = "docs-nolink", + feature = "input-avfoundation" + ) +))] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-avfoundation")))] +pub use avfoundation::AVFoundationCaptureDevice; +// FIXME: Fix Lifetime Issues +// #[cfg(feature = "input-uvc")] +// mod uvc_backend; +// #[cfg(feature = "input-uvc")] +// #[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-uvc")))] +// pub use uvc_backend::UVCCaptureDevice; +// #[cfg(feature = "input-gst")] +// mod gst_backend; +// #[cfg(feature = "input-gst")] +// #[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-gst")))] +// pub use gst_backend::GStreamerCaptureDevice; +// #[cfg(feature = "input-jscam")] +// mod browser_backend; +// #[cfg(feature = "input-jscam")] +// #[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-jscam")))] +// pub use browser_backend::BrowserCaptureDevice; +/// A camera that uses `OpenCV` to access IP (rtsp/http) on the local network +// #[cfg(feature = "input-ipcam")] +// #[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-ipcam")))] +// mod network_camera; +// #[cfg(feature = "input-ipcam")] +// #[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-ipcam")))] +// pub use network_camera::NetworkCamera; +#[cfg(feature = "input-opencv")] +mod opencv_backend; +#[cfg(feature = "input-opencv")] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-opencv")))] +pub use opencv_backend::OpenCvCaptureDevice; diff --git a/third_party/nokhwa/src/backends/capture/msmf_backend.rs b/third_party/nokhwa/src/backends/capture/msmf_backend.rs new file mode 100644 index 00000000000..0a8597bc1cc --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/msmf_backend.rs @@ -0,0 +1,262 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +use nokhwa_bindings_windows::wmf::MediaFoundationDevice; +use nokhwa_core::{ + buffer::Buffer, + error::NokhwaError, + pixel_format::RgbFormat, + traits::CaptureBackendTrait, + types::{ + all_known_camera_controls, ApiBackend, CameraControl, CameraFormat, CameraIndex, + CameraInfo, ControlValueSetter, FrameFormat, KnownCameraControl, RequestedFormat, + RequestedFormatType, Resolution, + }, +}; +use std::{borrow::Cow, collections::HashMap}; + +/// The backend that deals with Media Foundation on Windows. +/// To see what this does, please see [`CaptureBackendTrait`]. +/// +/// Note: This requires Windows 7 or newer to work. +/// # Quirks +/// - This does build on non-windows platforms, however when you do the backend will be empty and will return an error for any given operation. +/// - Please check [`nokhwa-bindings-windows`](https://github.com/l1npengtul/nokhwa/tree/senpai/nokhwa-bindings-windows) source code to see the internal raw interface. +/// - The symbolic link for the device is listed in the `misc` attribute of the [`CameraInfo`]. +/// - The names may contain invalid characters since they were converted from UTF16. +/// - When you call new or drop the struct, `initialize`/`de_initialize` will automatically be called. +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-msmf")))] +pub struct MediaFoundationCaptureDevice { + inner: MediaFoundationDevice, + info: CameraInfo, +} + +impl MediaFoundationCaptureDevice { + /// Creates a new capture device using the Media Foundation backend. Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// # Errors + /// This function will error if Media Foundation fails to get the device. + pub fn new(index: &CameraIndex, camera_fmt: RequestedFormat) -> Result { + let mut mf_device = MediaFoundationDevice::new(index.clone())?; + + let info = CameraInfo::new( + &mf_device.name(), + "MediaFoundation Camera Device", + &mf_device.symlink(), + index.clone(), + ); + + let availible = mf_device.compatible_format_list()?; + + let desired = camera_fmt + .fulfill(&availible) + .ok_or(NokhwaError::InitializeError { + backend: ApiBackend::MediaFoundation, + error: "Failed to fulfill requested format".to_string(), + })?; + + mf_device.set_format(desired)?; + + let mut new_cam = MediaFoundationCaptureDevice { + inner: mf_device, + info, + }; + new_cam.refresh_camera_format()?; + Ok(new_cam) + } + + /// Create a new Media Foundation Device with desired settings. + /// # Errors + /// This function will error if Media Foundation fails to get the device. + #[deprecated(since = "0.10.0", note = "please use `new` instead.")] + pub fn new_with( + index: &CameraIndex, + width: u32, + height: u32, + fps: u32, + fourcc: FrameFormat, + ) -> Result { + let camera_format = RequestedFormat::new::(RequestedFormatType::Exact( + CameraFormat::new_from(width, height, fourcc, fps), + )); + MediaFoundationCaptureDevice::new(index, camera_format) + } + + /// Gets the list of supported [`KnownCameraControl`]s + /// # Errors + /// May error if there is an error from `MediaFoundation`. + pub fn supported_camera_controls(&self) -> Vec { + let mut supported_camera_controls: Vec = vec![]; + + for camera_control in all_known_camera_controls() { + if let Ok(supported) = self.inner.control(camera_control) { + supported_camera_controls.push(supported.control()); + } + } + supported_camera_controls + } +} + +impl CaptureBackendTrait for MediaFoundationCaptureDevice { + fn backend(&self) -> ApiBackend { + ApiBackend::MediaFoundation + } + + fn camera_info(&self) -> &CameraInfo { + &self.info + } + + fn refresh_camera_format(&mut self) -> Result<(), NokhwaError> { + let _ = self.inner.format_refreshed()?; + Ok(()) + } + + fn camera_format(&self) -> CameraFormat { + self.inner.format() + } + + fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + self.inner.set_format(new_fmt) + } + + fn compatible_list_by_resolution( + &mut self, + fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + let mf_camera_format_list = self.inner.compatible_format_list()?; + let mut resolution_map: HashMap> = HashMap::new(); + + for camera_format in mf_camera_format_list { + // check fcc + if camera_format.format() != fourcc { + continue; + } + + match resolution_map.get_mut(&camera_format.resolution()) { + Some(fps_list) => { + fps_list.push(camera_format.frame_rate()); + } + None => { + if let Some(mut wtf_why_we_here_list) = resolution_map + .insert(camera_format.resolution(), vec![camera_format.frame_rate()]) + { + wtf_why_we_here_list.push(camera_format.frame_rate()); + resolution_map.insert(camera_format.resolution(), wtf_why_we_here_list); + } + } + } + } + Ok(resolution_map) + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + let mf_camera_format_list = self.inner.compatible_format_list()?; + let mut frame_format_list = vec![]; + + for camera_format in mf_camera_format_list { + if !frame_format_list.contains(&camera_format.format()) { + frame_format_list.push(camera_format.format()); + } + + // TODO: Update as we get more frame formats! + if frame_format_list.len() == 2 { + break; + } + } + Ok(frame_format_list) + } + + fn resolution(&self) -> Resolution { + self.camera_format().resolution() + } + + fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + let mut new_format = self.camera_format(); + new_format.set_resolution(new_res); + self.set_camera_format(new_format) + } + + fn frame_rate(&self) -> u32 { + self.camera_format().frame_rate() + } + + fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + let mut new_format = self.camera_format(); + new_format.set_frame_rate(new_fps); + self.set_camera_format(new_format) + } + + fn frame_format(&self) -> FrameFormat { + self.camera_format().format() + } + + fn set_frame_format(&mut self, fourcc: FrameFormat) -> Result<(), NokhwaError> { + let mut new_format = self.camera_format(); + new_format.set_format(fourcc); + self.set_camera_format(new_format) + } + + fn camera_control(&self, control: KnownCameraControl) -> Result { + self.inner.control(control) + } + + fn camera_controls(&self) -> Result, NokhwaError> { + let mut camera_ctrls = Vec::with_capacity(15); + for ctrl_id in all_known_camera_controls() { + let ctrl = match self.camera_control(ctrl_id) { + Ok(v) => v, + Err(_) => continue, + }; + + camera_ctrls.push(ctrl); + } + camera_ctrls.shrink_to_fit(); + Ok(camera_ctrls) + } + + fn set_camera_control( + &mut self, + id: KnownCameraControl, + value: ControlValueSetter, + ) -> Result<(), NokhwaError> { + self.inner.set_control(id, value) + } + + fn open_stream(&mut self) -> Result<(), NokhwaError> { + self.inner.start_stream() + } + + fn is_stream_open(&self) -> bool { + self.inner.is_stream_open() + } + + fn frame(&mut self) -> Result { + self.refresh_camera_format()?; + let self_ctrl = self.camera_format(); + Ok(Buffer::new( + self_ctrl.resolution(), + &self.inner.raw_bytes()?, + self_ctrl.format(), + )) + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + self.inner.raw_bytes() + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + self.inner.stop_stream(); + Ok(()) + } +} diff --git a/third_party/nokhwa/src/backends/capture/network_camera.rs b/third_party/nokhwa/src/backends/capture/network_camera.rs new file mode 100644 index 00000000000..5f0fa7fdabc --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/network_camera.rs @@ -0,0 +1,173 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use crate::backends::capture::OpenCvCaptureDevice; +use image::{buffer::ConvertBuffer, ImageBuffer, Rgb, RgbaImage}; +use nokhwa_core::{error::NokhwaError, traits::CaptureBackendTrait}; +use std::{borrow::Cow, cell::RefCell, collections::HashMap}; +#[cfg(feature = "output-wgpu")] +use wgpu::{ + Device as WgpuDevice, Extent3d, ImageCopyTexture, ImageDataLayout, Queue as WgpuQueue, + Texture as WgpuTexture, TextureAspect, TextureDescriptor, TextureDimension, TextureFormat, + TextureUsages, +}; + +/// A struct that supports IP Cameras via the `OpenCV` backend. +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-ipcam")))] +#[deprecated( + since = "0.10.0", + note = "please use `Camera` with `CameraIndex::String` and `input-opencv` enabled." +)] +pub struct NetworkCamera { + ip: String, + opencv_backend: RefCell, +} + +impl NetworkCamera { + /// Creates a new [`NetworkCamera`] from an IP. + /// # Errors + /// If the IP is invalid or `OpenCV` fails to open the IP, this will error + pub fn new(ip: String) -> Result { + let opencv_camera = OpenCvCaptureDevice::new_ip_camera(ip.clone())?; + Ok(NetworkCamera { + ip, + opencv_backend: RefCell::new(opencv_camera), + }) + } + + /// Gets the IP string + pub fn ip(&self) -> String { + self.ip.clone() + } + + /// Sets the IP. Will restart stream if already started. + /// # Errors + /// If the IP is invalid or `OpenCV` fails to open the IP, this will error + pub fn set_ip(&mut self, ip: String) -> Result<(), NokhwaError> { + *self.opencv_backend.borrow_mut() = OpenCvCaptureDevice::new_ip_camera(ip.clone())?; + self.ip = ip; + Ok(()) + } + + /// Opens stream. + /// # Errors + /// If the backend fails to capture the stream this will error + fn open_stream(&self) -> Result<(), NokhwaError> { + self.opencv_backend.borrow_mut().open_stream() + } + + /// Gets the frame decoded as a RGB24 frame + /// # Errors + /// If the backend fails to capture the stream, or if the decoding fails this will error + fn frame(&self) -> Result, Vec>, NokhwaError> { + self.opencv_backend.borrow_mut().frame() + } + + /// The minimum buffer size needed to write the current frame (RGB24). If `rgba` is true, it will instead return the minimum size of the RGBA buffer needed. + fn min_buffer_size(&self, rgba: bool) -> usize { + let resolution = self.opencv_backend.borrow().resolution(); + if rgba { + return (resolution.width() * resolution.height() * 4) as usize; + } + (resolution.width() * resolution.height() * 3) as usize + } + /// Directly writes the current frame(RGB24) into said `buffer`. If `convert_rgba` is true, the buffer written will be written as an RGBA frame instead of a RGB frame. Returns the amount of bytes written on successful capture. + /// # Errors + /// If the backend fails to get the frame (e.g. already taken, busy, doesn't exist anymore), or [`open_stream()`](CaptureBackendTrait::open_stream()) has not been called yet, this will error. + fn frame_to_buffer(&self, buffer: &mut [u8], convert_rgba: bool) -> Result { + let frame = self.frame()?; + let mut frame_data = frame.to_vec(); + if convert_rgba { + let rgba_image: RgbaImage = frame.convert(); + frame_data = rgba_image.to_vec(); + } + let bytes = frame_data.len(); + buffer.copy_from_slice(&frame_data); + Ok(bytes) + } + + #[cfg(feature = "output-wgpu")] + /// Directly copies a frame to a Wgpu texture. This will automatically convert the frame into a RGBA frame. + /// # Errors + /// If the frame cannot be captured or the resolution is 0 on any axis, this will error. + fn frame_texture<'a>( + &mut self, + device: &WgpuDevice, + queue: &WgpuQueue, + label: Option<&'a str>, + ) -> Result { + use std::num::NonZeroU32; + let frame = self.frame()?; + let rgba_frame: RgbaImage = frame.convert(); + + let texture_size = Extent3d { + width: frame.width(), + height: frame.height(), + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&TextureDescriptor { + label, + size: texture_size, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba8UnormSrgb, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + }); + + let width_nonzero = match NonZeroU32::try_from(4 * rgba_frame.width()) { + Ok(w) => Some(w), + Err(why) => return Err(NokhwaError::ReadFrameError(why.to_string())), + }; + + let height_nonzero = match NonZeroU32::try_from(rgba_frame.height()) { + Ok(h) => Some(h), + Err(why) => return Err(NokhwaError::ReadFrameError(why.to_string())), + }; + + queue.write_texture( + ImageCopyTexture { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: TextureAspect::All, + }, + &rgba_frame.to_vec(), + ImageDataLayout { + offset: 0, + bytes_per_row: width_nonzero, + rows_per_image: height_nonzero, + }, + texture_size, + ); + + Ok(texture) + } + + /// Will drop the stream. + /// # Errors + /// Please check the `Quirks` section of each backend. + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + self.opencv_backend.borrow_mut().stop_stream() + } +} + +impl Drop for NetworkCamera { + fn drop(&mut self) { + let _stop_stream_err = self.stop_stream(); + } +} diff --git a/third_party/nokhwa/src/backends/capture/opencv_backend.rs b/third_party/nokhwa/src/backends/capture/opencv_backend.rs new file mode 100644 index 00000000000..6cca3326110 --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/opencv_backend.rs @@ -0,0 +1,612 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use nokhwa_core::types::RequestedFormatType; +use nokhwa_core::{ + buffer::Buffer, + error::NokhwaError, + traits::CaptureBackendTrait, + types::{ + ApiBackend, CameraControl, CameraFormat, CameraIndex, CameraInfo, ControlValueDescription, + ControlValueSetter, FrameFormat, KnownCameraControl, RequestedFormat, Resolution, + }, +}; +use opencv::{ + core::{Mat, MatTraitConst, MatTraitConstManual, Vec3b}, + videoio::{ + VideoCapture, VideoCaptureProperties, VideoCaptureTrait, VideoCaptureTraitConst, CAP_ANY, + CAP_AVFOUNDATION, CAP_MSMF, CAP_PROP_FPS, CAP_PROP_FRAME_HEIGHT, CAP_PROP_FRAME_WIDTH, + CAP_V4L2, + }, +}; +use std::{borrow::Cow, collections::HashMap}; + +/// Attempts to convert a [`KnownCameraControl`] into a `OpenCV` video capture property. +/// If the associated control is not found, this will return `Err` +pub fn known_camera_control_to_video_capture_property( + ctrl: KnownCameraControl, +) -> Result { + match ctrl { + KnownCameraControl::Brightness => Ok(VideoCaptureProperties::CAP_PROP_BRIGHTNESS), + KnownCameraControl::Contrast => Ok(VideoCaptureProperties::CAP_PROP_CONTRAST), + KnownCameraControl::Hue => Ok(VideoCaptureProperties::CAP_PROP_HUE), + KnownCameraControl::Saturation => Ok(VideoCaptureProperties::CAP_PROP_SATURATION), + KnownCameraControl::Sharpness => Ok(VideoCaptureProperties::CAP_PROP_SHARPNESS), + KnownCameraControl::Gamma => Ok(VideoCaptureProperties::CAP_PROP_GAMMA), + KnownCameraControl::BacklightComp => Ok(VideoCaptureProperties::CAP_PROP_BACKLIGHT), + KnownCameraControl::Gain => Ok(VideoCaptureProperties::CAP_PROP_GAIN), + KnownCameraControl::Pan => Ok(VideoCaptureProperties::CAP_PROP_PAN), + KnownCameraControl::Tilt => Ok(VideoCaptureProperties::CAP_PROP_TILT), + KnownCameraControl::Zoom => Ok(VideoCaptureProperties::CAP_PROP_ZOOM), + KnownCameraControl::Exposure => Ok(VideoCaptureProperties::CAP_PROP_EXPOSURE), + KnownCameraControl::Iris => Ok(VideoCaptureProperties::CAP_PROP_IRIS), + KnownCameraControl::Focus => Ok(VideoCaptureProperties::CAP_PROP_FOCUS), + _ => Err(NokhwaError::UnsupportedOperationError(ApiBackend::OpenCv)), + } +} + +/// The backend struct that interfaces with `OpenCV`. Note that an `opencv` matching the version that this was either compiled on must be present on the user's machine. (usually 4.5.2 or greater) +/// For more information, please see [`opencv-rust`](https://github.com/twistedfall/opencv-rust) and [`OpenCV VideoCapture Docs`](https://docs.opencv.org/4.5.2/d8/dfe/classcv_1_1VideoCapture.html). +/// +/// To see what this does, please see [`CaptureBackendTrait`] +/// # Quirks +/// - **Some features don't work properly on this backend (yet)! Setting [`Resolution`], FPS, [`FrameFormat`] does not work and will default to 640x480 30FPS. This is being worked on.** +/// - This is a **cross-platform** backend. This means that it will work on most platforms given that `OpenCV` is present. +/// - This backend can also do IP Camera input. +/// - The backend's backend will default to system level APIs on Linux(V4L2), Mac(AVFoundation), and Windows(Media Foundation). Otherwise, it will decide for itself. +/// - If the [`OpenCvCaptureDevice`] is initialized as a `IPCamera`, the [`CameraFormat`]'s `index` value will be [`u32::MAX`](std::u32::MAX) (4294967295). +/// - `OpenCV` does not support camera querying. Camera Name and Camera supported resolution/fps/fourcc is a [`UnsupportedOperationError`](NokhwaError::UnsupportedOperationError). +/// Note: [`resolution()`](crate::camera_traits::CaptureBackendTrait::resolution()), [`frame_format()`](crate::camera_traits::CaptureBackendTrait::frame_format()), and [`frame_rate()`](crate::camera_traits::CaptureBackendTrait::frame_rate()) is not affected. +/// - [`CameraInfo`]'s human name will be "`OpenCV` Capture Device {location}" +/// - [`CameraInfo`]'s description will contain the Camera's Index or IP. +/// - The API Preference order is the native OS API (linux => `v4l2`, mac => `AVFoundation`, windows => `MSMF`) than [`CAP_AUTO`](https://docs.opencv.org/4.5.2/d4/d15/group__videoio__flags__base.html#gga023786be1ee68a9105bf2e48c700294da77ab1fe260fd182f8ec7655fab27a31d) +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-opencv")))] +pub struct OpenCvCaptureDevice { + camera_format: CameraFormat, + camera_location: CameraIndex, + camera_info: CameraInfo, + api_preference: i32, + video_capture: VideoCapture, +} + +#[allow(clippy::must_use_candidate)] +impl OpenCvCaptureDevice { + /// Creates a new capture device using the `OpenCV` backend. + /// + /// Indexes are gives to devices by the OS, and usually numbered by order of discovery. + /// + /// `IPCameras` follow the format + /// ```.ignore + /// ://:/ + /// ``` + /// , but please refer to the manufacturer for the actual IP format. + /// + /// # Errors + /// If the backend fails to open the camera (e.g. Device does not exist at specified index/ip), Camera does not support specified [`CameraFormat`], and/or other `OpenCV` Error, this will error. + /// # Panics + /// If the API u32 -> i32 fails this will error + #[allow(clippy::cast_possible_wrap)] + pub fn new(index: &CameraIndex, cam_fmt: RequestedFormat) -> Result { + let api_pref = if index.is_string() { + CAP_ANY + } else { + get_api_pref_int() + }; + + let mut video_capture = match &index { + CameraIndex::Index(idx) => VideoCapture::new(*idx as i32, api_pref), + CameraIndex::String(ip) => VideoCapture::from_file(ip.as_str(), api_pref), + } + .map_err(|why| { + NokhwaError::OpenDeviceError(format!("Failed to open {index}"), why.to_string()) + })?; + + let camera_format = + if let RequestedFormatType::Exact(exact) = cam_fmt.requested_format_type() { + exact + } else { + return Err(NokhwaError::UnsupportedOperationError(ApiBackend::OpenCv)); + }; + + set_properties(&mut video_capture, camera_format)?; + + let camera_info = CameraInfo::new( + format!("OpenCV Capture Device {index}").as_str(), + index.to_string().as_str(), + "", + index.clone(), + ); + + Ok(OpenCvCaptureDevice { + camera_format, + camera_location: index.clone(), + camera_info, + api_preference: api_pref, + video_capture, + }) + } + + /// Gets weather said capture device is an `IPCamera`. + pub fn is_ip_camera(&self) -> bool { + match self.camera_location { + CameraIndex::Index(_) => false, + CameraIndex::String(_) => true, + } + } + + /// Gets weather said capture device is an OS-based indexed camera. + pub fn is_index_camera(&self) -> bool { + match self.camera_location { + CameraIndex::Index(_) => true, + CameraIndex::String(_) => false, + } + } + + /// Gets the camera location + pub fn camera_location(&self) -> &CameraIndex { + &self.camera_location + } + + /// Gets the `OpenCV` API Preference number. Please refer to [`OpenCV VideoCapture Flag Docs`](https://docs.opencv.org/4.5.2/d4/d15/group__videoio__flags__base.html). + pub fn opencv_preference(&self) -> i32 { + self.api_preference + } + + /// Gets the RGB24 frame directly read from `OpenCV` without any additional processing. + /// # Errors + /// If the frame is failed to be read, this will error. + #[allow(clippy::cast_sign_loss)] + pub fn raw_frame_vec(&mut self) -> Result, NokhwaError> { + if !self.is_stream_open() { + return Err(NokhwaError::ReadFrameError( + "Stream is not open!".to_string(), + )); + } + + let mut frame = Mat::default(); + match self.video_capture.read(&mut frame) { + Ok(a) => { + if !a { + return Err(NokhwaError::ReadFrameError( + "Failed to read frame from videocapture: OpenCV return false, camera disconnected?".to_string(), + )); + } + } + Err(why) => { + return Err(NokhwaError::ReadFrameError(format!( + "Failed to read frame from videocapture: {}", + why + ))) + } + } + + if frame.empty() { + return Err(NokhwaError::ReadFrameError("Frame Empty!".to_string())); + } + + match frame.size() { + Ok(size) => { + if size.width > 0 { + return if frame.is_continuous() { + let mut raw_vec: Vec = Vec::new(); + + let frame_data_vec = match Mat::data_typed::(&frame) { + Ok(v) => v, + Err(why) => { + return Err(NokhwaError::ReadFrameError(format!( + "Failed to convert frame into raw Vec3b: {}", + why + ))) + } + }; + + for pixel in frame_data_vec.iter() { + let pixel_slice: &[u8; 3] = pixel; + raw_vec.push(pixel_slice[2]); + raw_vec.push(pixel_slice[1]); + raw_vec.push(pixel_slice[0]); + } + + Ok(Cow::from(raw_vec)) + } else { + Err(NokhwaError::ReadFrameError( + "Failed to read frame from videocapture: not cont".to_string(), + )) + }; + } + Err(NokhwaError::ReadFrameError( + "Frame width is less than zero!".to_string(), + )) + } + Err(why) => Err(NokhwaError::ReadFrameError(format!( + "Failed to read frame from videocapture: failed to read size: {}", + why + ))), + } + } + + /// Gets the resolution raw as read by `OpenCV`. + /// # Errors + /// If the resolution is failed to be read (e.g. invalid or not supported), this will error. + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn raw_resolution(&self) -> Result { + let width = match self.video_capture.get(CAP_PROP_FRAME_WIDTH) { + Ok(width) => width as u32, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Width".to_string(), + error: why.to_string(), + }) + } + }; + + let height = match self.video_capture.get(CAP_PROP_FRAME_HEIGHT) { + Ok(height) => height as u32, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Height".to_string(), + error: why.to_string(), + }) + } + }; + + Ok(Resolution::new(width, height)) + } + + /// Gets the framerate raw as read by `OpenCV`. + /// # Errors + /// If the framerate is failed to be read (e.g. invalid or not supported), this will error. + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn raw_framerate(&self) -> Result { + match self.video_capture.get(CAP_PROP_FPS) { + Ok(fps) => Ok(fps as u32), + Err(why) => Err(NokhwaError::GetPropertyError { + property: "Framerate".to_string(), + error: why.to_string(), + }), + } + } +} + +impl CaptureBackendTrait for OpenCvCaptureDevice { + fn backend(&self) -> ApiBackend { + ApiBackend::OpenCv + } + + fn camera_info(&self) -> &CameraInfo { + &self.camera_info + } + + #[allow(clippy::cast_lossless)] + fn refresh_camera_format(&mut self) -> Result<(), NokhwaError> { + let width = u32::from( + self.video_capture + .set(CAP_PROP_FRAME_WIDTH, self.camera_format.width() as f64) + .map_err(|why| NokhwaError::SetPropertyError { + property: "Resolution Width".to_string(), + value: self.camera_format.to_string(), + error: why.to_string(), + })?, + ); + let height = self + .video_capture + .set(CAP_PROP_FRAME_HEIGHT, self.camera_format.height() as f64) + .map_err(|why| NokhwaError::SetPropertyError { + property: "Resolution Height".to_string(), + value: self.camera_format.to_string(), + error: why.to_string(), + })? as u32; + let fps = self + .video_capture + .set(CAP_PROP_FPS, self.camera_format.frame_rate() as f64) + .map_err(|why| NokhwaError::SetPropertyError { + property: "FPS".to_string(), + value: self.camera_format.to_string(), + error: why.to_string(), + })? as u32; + + let ffmt = self.frame_format(); + self.set_camera_format(CameraFormat::new_from(width, height, ffmt, fps))?; + + Ok(()) + } + + fn camera_format(&self) -> CameraFormat { + self.camera_format + } + + fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + let current_format = self.camera_format; + let is_opened = match self.video_capture.is_opened() { + Ok(opened) => opened, + Err(why) => { + return Err(NokhwaError::GetPropertyError { + property: "Is Stream Open".to_string(), + error: why.to_string(), + }) + } + }; + + self.camera_format = new_fmt; + + if let Err(why) = set_properties(&mut self.video_capture, new_fmt) { + self.camera_format = current_format; + return Err(why); + } + if is_opened { + self.stop_stream()?; + if let Err(why) = self.open_stream() { + return Err(NokhwaError::OpenDeviceError( + self.camera_location.to_string(), + why.to_string(), + )); + } + } + Ok(()) + } + + fn compatible_list_by_resolution( + &mut self, + _fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + Err(NokhwaError::UnsupportedOperationError(ApiBackend::OpenCv)) + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + Err(NokhwaError::UnsupportedOperationError(ApiBackend::OpenCv)) + } + + fn resolution(&self) -> Resolution { + self.raw_resolution() + .unwrap_or_else(|_| Resolution::new(640, 480)) + } + + fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + let mut current_fmt = self.camera_format; + current_fmt.set_resolution(new_res); + self.set_camera_format(current_fmt) + } + + fn frame_rate(&self) -> u32 { + self.raw_framerate().unwrap_or(30) + } + + fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + let mut current_fmt = self.camera_format; + current_fmt.set_frame_rate(new_fps); + self.set_camera_format(current_fmt) + } + + fn frame_format(&self) -> FrameFormat { + self.camera_format.format() + } + + fn set_frame_format(&mut self, fourcc: FrameFormat) -> Result<(), NokhwaError> { + let mut current_fmt = self.camera_format; + current_fmt.set_format(fourcc); + self.set_camera_format(current_fmt) + } + + fn camera_control(&self, control: KnownCameraControl) -> Result { + let id = known_camera_control_to_video_capture_property(control)? as i32; + let current = self + .video_capture + .get(id) + .map_err(|why| NokhwaError::GetPropertyError { + property: id.to_string(), + error: why.to_string(), + })?; + Ok(CameraControl::new( + control, + id.to_string(), + ControlValueDescription::Float { + value: current, + default: 0.0, + step: 0.0, + }, + vec![], + true, + )) + } + + fn camera_controls(&self) -> Result, NokhwaError> { + Err(NokhwaError::UnsupportedOperationError(ApiBackend::OpenCv)) + } + + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_lossless)] + fn set_camera_control( + &mut self, + id: KnownCameraControl, + value: ControlValueSetter, + ) -> Result<(), NokhwaError> { + let control_val = match value { + ControlValueSetter::Integer(i) => i as f64, + ControlValueSetter::Float(f) => f, + ControlValueSetter::Boolean(b) => u8::from(b) as f64, + val => { + return Err(NokhwaError::SetPropertyError { + property: "Camera Control".to_string(), + value: val.to_string(), + error: "unsupported value".to_string(), + }) + } + }; + + if !self + .video_capture + .set( + known_camera_control_to_video_capture_property(id)? as i32, + control_val, + ) + .map_err(|why| NokhwaError::SetPropertyError { + property: "Camera Control".to_string(), + value: control_val.to_string(), + error: why.to_string(), + })? + { + return Err(NokhwaError::SetPropertyError { + property: "Camera Control".to_string(), + value: control_val.to_string(), + error: "false".to_string(), + }); + } + + let set_value = self.camera_control(id)?.value(); + if set_value != value { + return Err(NokhwaError::SetPropertyError { + property: "Camera Control".to_string(), + value: control_val.to_string(), + error: "failed to set value: rejected".to_string(), + }); + } + + Ok(()) + } + + #[allow(clippy::cast_possible_wrap)] + fn open_stream(&mut self) -> Result<(), NokhwaError> { + match self.camera_location.clone() { + CameraIndex::Index(idx) => { + match self.video_capture.open(idx as i32, get_api_pref_int()) { + Ok(open) => { + if open { + return Ok(()); + } + Err(NokhwaError::OpenStreamError( + "Stream is not opened after stream open attempt opencv".to_string(), + )) + } + Err(why) => Err(NokhwaError::OpenDeviceError( + idx.to_string(), + format!("Failed to open device: {why}"), + )), + } + } + CameraIndex::String(_) => Err(NokhwaError::OpenDeviceError( + "Cannot open".to_string(), + "String index not supported (try NetworkCamera instead)".to_string(), + )), + }?; + + match self.video_capture.is_opened() { + Ok(open) => { + if open { + return Ok(()); + } + Err(NokhwaError::OpenStreamError( + "Stream is not opened after stream open attempt opencv".to_string(), + )) + } + Err(why) => Err(NokhwaError::GetPropertyError { + property: "Is Stream Open After Open Stream".to_string(), + error: why.to_string(), + }), + } + } + + fn is_stream_open(&self) -> bool { + self.video_capture.is_opened().unwrap_or(false) + } + + fn frame(&mut self) -> Result { + let camera_resolution = self.camera_format.resolution(); + let image_data = { + let mut data = self.frame_raw()?.to_vec(); + data.resize( + (camera_resolution.width() * camera_resolution.height() * 3) as usize, + 0_u8, + ); + data + }; + Ok(Buffer::new( + camera_resolution, + &image_data, + self.camera_format.format(), + )) + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + let cow = self.raw_frame_vec()?; + Ok(cow) + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + match self.video_capture.release() { + Ok(_) => Ok(()), + Err(why) => Err(NokhwaError::StreamShutdownError(why.to_string())), + } + } +} + +fn get_api_pref_int() -> i32 { + match std::env::consts::OS { + "linux" => CAP_V4L2, + "windows" => CAP_MSMF, + "mac" => CAP_AVFOUNDATION, + &_ => CAP_ANY, + } +} + +// I'm done. This stupid POS refuses to actually do anything useful with camera settings +// If anyone else wants to tackle this monster, please do. +fn set_properties(vc: &mut VideoCapture, camera_format: CameraFormat) -> Result<(), NokhwaError> { + if !vc + .set(CAP_PROP_FRAME_WIDTH, f64::from(camera_format.width())) + .map_err(|why| NokhwaError::SetPropertyError { + property: "Resolution Width".to_string(), + value: camera_format.to_string(), + error: why.to_string(), + })? + { + return Err(NokhwaError::SetPropertyError { + property: "Resolution Width".to_string(), + value: camera_format.to_string(), + error: "false".to_string(), + }); + } + if !vc + .set(CAP_PROP_FRAME_HEIGHT, f64::from(camera_format.height())) + .map_err(|why| NokhwaError::SetPropertyError { + property: "Resolution Height".to_string(), + value: camera_format.to_string(), + error: why.to_string(), + })? + { + return Err(NokhwaError::SetPropertyError { + property: "Resolution Height".to_string(), + value: camera_format.to_string(), + error: "false".to_string(), + }); + } + if !vc + .set(CAP_PROP_FPS, f64::from(camera_format.frame_rate())) + .map_err(|why| NokhwaError::SetPropertyError { + property: "FPS".to_string(), + value: camera_format.to_string(), + error: why.to_string(), + })? + { + return Err(NokhwaError::SetPropertyError { + property: "FPS".to_string(), + value: camera_format.to_string(), + error: "false".to_string(), + }); + } + Ok(()) +} diff --git a/third_party/nokhwa/src/backends/capture/uvc_backend.rs b/third_party/nokhwa/src/backends/capture/uvc_backend.rs new file mode 100644 index 00000000000..b86ca180853 --- /dev/null +++ b/third_party/nokhwa/src/backends/capture/uvc_backend.rs @@ -0,0 +1,565 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#![allow(clippy::too_many_arguments)] + +use crate::{ + ApiBackend, CameraControl, CameraFormat, CameraInfo, CaptureBackendTrait, FrameFormat, + KnownCameraControl, KnownCameraControlFlag, NokhwaError, Resolution, +}; +use flume::{Receiver, Sender}; +use image::{ImageBuffer, Rgb}; +use ouroboros::self_referencing; +use std::{ + any::Any, + borrow::Cow, + cell::{Cell, RefCell}, + collections::HashMap, + mem::MaybeUninit, + sync::{atomic::AtomicUsize, Arc}, +}; +use uvc::{ + ActiveStream, Context, DescriptionSubtype, Device, DeviceHandle, StreamFormat, StreamHandle, +}; + +// ignore the IDE, this compiles +/// The backend struct that interfaces with `libuvc`. +/// To see what this does, please see [`CaptureBackendTrait`] +/// # Quirks +/// - You may need administrator/superuser privileges to access a UVC device. +/// - The indexing for this backend is based off of `libuvc`'s device ordering, not the OS. +/// - You must call [create()](UVCCaptureDevice::create()) instead `new()`, some methods are auto-generated by the self-referencer and are not meant to be used. +/// - The [create()](UVCCaptureDevice::create()) method will open the device twice. +/// - Calling [`set_resolution()`](CaptureBackendTrait::set_resolution()), [`set_frame_rate()`](crate::CaptureBackendTrait::set_frame_rate()), or [`set_frame_format()`](crate::CaptureBackendTrait::set_frame_format()) each internally calls [`set_camera_format()`](crate::CaptureBackendTrait::set_camera_format()). +/// - [`frame_raw()`](crate::CaptureBackendTrait::frame_raw()) returns the same raw data as [`get_frame()`](crate::CaptureBackendTrait::frame()), a.k.a. no custom decoding required, all data is automatically RGB +/// - The [`frame_raw()`](crate::CaptureBackendTrait::frame_raw()) and by extension [`frame()`](crate::CaptureBackendTrait::frame()) functions block. +/// - Setting controls is not supported. +/// - This backend, once stream is open, will constantly collect frames. When you call [`frame()`](crate::CaptureBackendTrait::frame()) or one of its variants, it will only give you the latest frame. +/// # Safety +/// This backend requires use of `unsafe` due to the self-referencing structs involved. +/// - If [`open_stream()`](crate::CaptureBackendTrait::open_stream()) and [`frame()`](crate::CaptureBackendTrait::frame()) are called in the wrong order this will cause undefined behaviour. +/// - If internal variables `stream_handle_init` and `active_stream_init` become de-synchronized with the true reality (weather streamhandle/activestream is init or not) this will cause undefined behaviour. +#[self_referencing] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-uvc")))] +#[deprecated( + since = "0.10", + note = "Use one of the native backends instead(V4L, AVF, MSMF) or OpenCV" +)] +pub struct UVCCaptureDevice<'a> { + camera_format: CameraFormat, + camera_info: CameraInfo<'a>, + frame_receiver: Receiver>, + frame_sender: Sender>, + stream_handle_init: Cell, + active_stream_init: Cell, + context: Context<'a>, + #[not_covariant] + #[borrows(context)] + device: Device<'this>, + #[not_covariant] + #[borrows(device)] + device_handle: DeviceHandle<'this>, + stream_handle: RefCell>>, + active_stream: RefCell>>>, +} + +impl<'a> UVCCaptureDevice<'a> { + /// Creates a UVC Camera device with optional [`CameraFormat`]. + /// If `camera_format` is `None`, it will be spawned with with 640x480@15 FPS, MJPEG [`CameraFormat`] default. + /// # Panics + /// This operation may panic! If the UVC Context fails to retrieve the device from the gotten IDs, this operation will panic. + /// # Errors + /// This may error when the `libuvc` backend fails to retrieve the device or its data. + pub fn create(index: usize, cam_fmt: Option) -> Result { + let context = match Context::new() { + Ok(ctx) => ctx, + Err(why) => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + why.to_string(), + )) + } + }; + + let (camera_info, frame_receiver, frame_sender) = { + let device_list = match context.devices() { + Ok(device_list) => device_list, + Err(why) => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + why.to_string(), + )) + } + }; + + let device = match device_list.into_iter().nth(index) { + Some(d) => d, + None => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + "Not Found".to_string(), + )) + } + }; + + let device_desc = match device.description() { + Ok(desc) => desc, + Err(why) => { + return Err(NokhwaError::OpenDeviceError( + index.to_string(), + why.to_string(), + )) + } + }; + + let device_name = match (device_desc.manufacturer, device_desc.product) { + (Some(manu), Some(prod)) => { + format!("{} {}", manu, prod) + } + (_, Some(prod)) => prod, + (Some(manu), _) => { + format!( + "{}:{} {}", + device_desc.vendor_id, device_desc.product_id, manu + ) + } + (_, _) => { + format!("{}:{}", device_desc.vendor_id, device_desc.product_id) + } + }; + + let camera_info = CameraInfo::new( + device_name, + "".to_string(), + format!("{}:{}", device_desc.vendor_id, device_desc.product_id), + index, + ); + + let (frame_sender, frame_receiver) = { + let (a, b) = flume::unbounded::>(); + (a, b) + }; + (camera_info, frame_receiver, frame_sender) + }; + + let camera_format = match cam_fmt { + Some(cfmt) => cfmt, + None => CameraFormat::default(), + }; + + Ok(UVCCaptureDeviceBuilder { + camera_format, + camera_info, + frame_receiver, + frame_sender, + context, + stream_handle_init: Cell::new(false), + active_stream_init: Cell::new(false), + device_builder: |context_builder| { + context_builder + .devices() + .unwrap() + .into_iter() + .nth(index) + .unwrap() + }, + device_handle_builder: |device_builder| device_builder.open().unwrap(), + stream_handle: RefCell::new(MaybeUninit::uninit()), + active_stream: RefCell::new(MaybeUninit::uninit()), + } + .build()) + } + + /// Create a UVC Camera with desired settings. + /// # Panics + /// This operation may panic! If the UVC Context fails to retrieve the device from the gotten IDs, this operation will panic. + /// # Errors + /// This may error when the `libuvc` backend fails to retrieve the device or its data. + pub fn create_with( + index: usize, + width: u32, + height: u32, + fps: u32, + fourcc: FrameFormat, + ) -> Result { + let camera_format = Some(CameraFormat::new_from(width, height, fourcc, fps)); + UVCCaptureDevice::create(index, camera_format) + } +} + +// IDE Autocomplete ends here. Do not be afraid it your IDE does not show completion. +// Here are some docs to help you out: https://docs.rs/ouroboros/0.9.3/ouroboros/attr.self_referencing.html +impl<'a> CaptureBackendTrait for UVCCaptureDevice<'a> { + fn backend(&self) -> ApiBackend { + ApiBackend::UniversalVideoClass + } + + fn camera_info(&self) -> &CameraInfo { + self.borrow_camera_info() + } + + fn camera_format(&self) -> CameraFormat { + *self.borrow_camera_format() + } + + fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + let prev_fmt = *self.borrow_camera_format(); + + self.with_camera_format_mut(|cfmt| { + *cfmt = new_fmt; + }); + + let is_streamh_some = self.borrow_stream_handle_init().get(); + + if is_streamh_some { + return match self.open_stream() { + Ok(_) => Ok(()), + Err(why) => { + // revert + self.with_camera_format_mut(|cfmt| { + *cfmt = prev_fmt; + }); + Err(NokhwaError::SetPropertyError { + property: "CameraFormat".to_string(), + value: new_fmt.to_string(), + error: why.to_string(), + }) + } + }; + } + Ok(()) + } + + #[allow(clippy::cast_possible_truncation)] + fn compatible_list_by_resolution( + &mut self, + fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + let mut resolution_fps_map: HashMap> = HashMap::new(); + for fmt in self.with_device_handle(|devh| devh).supported_formats() { + for frame_desc in fmt.supported_formats() { + // FIXME: Verify that this is correct way to interpret DescriptionSubtype! + let format = match frame_desc.subtype() { + DescriptionSubtype::FormatMJPEG | DescriptionSubtype::FrameMJPEG => { + FrameFormat::MJPEG + } + DescriptionSubtype::FormatUncompressed + | DescriptionSubtype::FrameUncompressed => FrameFormat::YUYV, + _ => continue, + }; + + if format != fourcc { + continue; + } + + let resolution = + Resolution::new(frame_desc.width().into(), frame_desc.height().into()); + let fps: Vec = frame_desc + .intervals_duration() + .into_iter() + .map(|duration| (1000 / duration.as_millis()) as u32) + .collect(); + resolution_fps_map.insert(resolution, fps); + } + } + Ok(resolution_fps_map) + } + + fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + let mut frameformats = vec![]; + for fmt in self.with_device_handle(|devh| devh).supported_formats() { + for frame_desc in fmt.supported_formats() { + // FIXME: Verify that this is correct way to interpret DescriptionSubtype! + match frame_desc.subtype() { + DescriptionSubtype::FormatMJPEG | DescriptionSubtype::FrameMJPEG => { + frameformats.push(FrameFormat::MJPEG); + } + DescriptionSubtype::FormatUncompressed + | DescriptionSubtype::FrameUncompressed => frameformats.push(FrameFormat::YUYV), + _ => continue, + }; + } + } + + frameformats.sort(); + frameformats.dedup(); + Ok(frameformats) + } + + fn resolution(&self) -> Resolution { + self.borrow_camera_format().resolution() + } + + fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + let mut current_format = *self.borrow_camera_format(); + current_format.set_resolution(new_res); + self.set_camera_format(current_format) + } + + fn frame_rate(&self) -> u32 { + self.borrow_camera_format().frame_rate() + } + + fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + let mut current_format = *self.borrow_camera_format(); + current_format.set_frame_rate(new_fps); + self.set_camera_format(current_format) + } + + fn frame_format(&self) -> FrameFormat { + self.borrow_camera_format().format() + } + + fn set_frame_format(&mut self, fourcc: FrameFormat) -> Result<(), NokhwaError> { + let mut current_format = *self.borrow_camera_format(); + current_format.set_format(fourcc); + self.set_camera_format(current_format) + } + + fn supported_camera_controls(&self) -> Result, NokhwaError> { + Ok(vec![ + KnownCameraControl::Exposure, + KnownCameraControl::Focus, + ]) + } + + fn camera_control(&self, control: KnownCameraControl) -> Result { + match control { + KnownCameraControl::Focus => match self.with_device_handle(|x| x).exposure_rel() { + Ok(v) => { + let v: i8 = v; + match CameraControl::new( + control, + i32::from(i8::MIN), + i32::from(i8::MAX), + i32::from(v), + 1_i32, + i32::from(v), + KnownCameraControlFlag::Automatic, + true, + ) { + Ok(cc) => Ok(cc), + Err(why) => Err(NokhwaError::GetPropertyError { + property: control.to_string(), + error: why.to_string(), + }), + } + } + Err(why) => Err(NokhwaError::GetPropertyError { + property: control.to_string(), + error: why.to_string(), + }), + }, + _ => Err(NokhwaError::GetPropertyError { + property: control.to_string(), + error: "Not Supported".to_string(), + }), + } + } + + fn set_camera_control(&mut self, _control: CameraControl) -> Result<(), NokhwaError> { + Err(NokhwaError::UnsupportedOperationError( + ApiBackend::UniversalVideoClass, + )) + } + + fn raw_supported_camera_controls(&self) -> Result>, NokhwaError> { + Err(NokhwaError::UnsupportedOperationError( + ApiBackend::UniversalVideoClass, + )) + } + + fn raw_camera_control(&self, _control: &dyn Any) -> Result, NokhwaError> { + Err(NokhwaError::UnsupportedOperationError( + ApiBackend::UniversalVideoClass, + )) + } + + fn set_raw_camera_control( + &mut self, + _control: &dyn Any, + _value: &dyn Any, + ) -> Result<(), NokhwaError> { + Err(NokhwaError::UnsupportedOperationError( + ApiBackend::UniversalVideoClass, + )) + } + + fn open_stream(&mut self) -> Result<(), NokhwaError> { + let ret: Result<(), NokhwaError> = self.with_mut(|fields| { + let stream_format: StreamFormat = StreamFormat { + width: (*fields.camera_format).width(), + height: (*fields.camera_format).height(), + fps: (*fields.camera_format).frame_rate(), + format: (*fields.camera_format).format().into(), + }; + + // first, drop the existing stream by setting it to None + { + if fields.active_stream_init.get() { + let innard_value = fields.active_stream.replace(MaybeUninit::uninit()); + unsafe { + std::mem::drop(innard_value.assume_init()); + }; + fields.active_stream_init.set(false); + } + + if fields.stream_handle_init.get() { + let innard_value = fields.stream_handle.replace(MaybeUninit::uninit()); + unsafe { + std::mem::drop(innard_value.assume_init()); + }; + fields.stream_handle_init.set(false); + } + } + // second, set the stream handle according to the streamformat + match fields + .device_handle + .get_stream_handle_with_format(stream_format) + { + Ok(streamh) => match fields.stream_handle.try_borrow_mut() { + Ok(mut streamh_raw) => { + *streamh_raw = MaybeUninit::new(streamh); + fields.stream_handle_init.set(true); + } + Err(why) => return Err(NokhwaError::OpenStreamError(why.to_string())), + }, + Err(why) => return Err(NokhwaError::OpenStreamError(why.to_string())), + } + Ok(()) + }); + + if ret.is_err() { + return ret; + } + + let ret_2: Result<(), NokhwaError> = self.with(|fields| { + // finally, get the active stream + let counter = Arc::new(AtomicUsize::new(0)); + let frame_sender: Sender> = self.with_frame_sender(Clone::clone); + let streamh = unsafe { + let raw_ptr = + (*fields.stream_handle.borrow_mut()).as_ptr() as *mut MaybeUninit; + let assume_inited: *mut MaybeUninit> = + raw_ptr.cast::>(); + &mut *assume_inited + }; + let streamh_init = unsafe { + match streamh.as_mut_ptr().as_mut() { + Some(sth) => sth, + None => { + return Err(NokhwaError::OpenStreamError( + "Failed to get mutable raw pointer to stream handle!".to_string(), + )) + } + } + }; + + let active_stream = match streamh_init.start_stream( + move |frame, _count| { + let vec_frame = frame.to_rgb().unwrap().to_bytes().to_vec(); + if frame_sender.send(vec_frame).is_err() { + // do nothing + } + }, + counter, + ) { + Ok(active) => active, + Err(why) => return Err(NokhwaError::OpenStreamError(why.to_string())), + }; + *fields.active_stream.borrow_mut() = MaybeUninit::new(active_stream); + Ok(()) + }); + + if ret_2.is_err() { + return ret_2; + } + self.borrow_active_stream_init().set(true); + + Ok(()) + } + + fn is_stream_open(&self) -> bool { + self.with_active_stream_init(Cell::get) + } + + fn frame(&mut self) -> Result, Vec>, NokhwaError> { + let resolution: Resolution = self.borrow_camera_format().resolution(); + + let data = match self.frame_raw() { + Ok(d) => d, + Err(why) => return Err(why), + }; + + let imagebuf: ImageBuffer, Vec> = + match ImageBuffer::from_vec(resolution.width(), resolution.height(), data.to_vec()) { + Some(img) => img, + None => { + return Err(NokhwaError::ReadFrameError( + "ImageBuffer too small! This is probably a bug, please report it!" + .to_string(), + )) + } + }; + + Ok(imagebuf) + } + + fn frame_raw(&mut self) -> Result, NokhwaError> { + // assertions + if !self.borrow_active_stream_init().get() { + return Err(NokhwaError::ReadFrameError( + "Please call `open_stream()` first!".to_string(), + )); + } + + let f_recv = self.borrow_frame_receiver(); + let messages_iter = f_recv.drain(); + match messages_iter.last() { + Some(msg) => Ok(Cow::from(msg)), + None => match f_recv.recv() { + Ok(msg) => Ok(Cow::from(msg)), + Err(why) => { + return Err(NokhwaError::ReadFrameError(format!( + "All sender dropped: {}", + why.to_string() + ))) + } + }, + } + } + + fn stop_stream(&mut self) -> Result<(), NokhwaError> { + self.with(|fields| { + if fields.active_stream_init.get() { + let innard_value = fields.active_stream.replace(MaybeUninit::uninit()); + unsafe { + std::mem::drop(innard_value.assume_init()); + }; + fields.active_stream_init.set(false); + } + + if fields.stream_handle_init.get() { + let innard_value = fields.stream_handle.replace(MaybeUninit::uninit()); + unsafe { + std::mem::drop(innard_value.assume_init()); + }; + fields.stream_handle_init.set(false); + } + }); + Ok(()) + } +} diff --git a/third_party/nokhwa/src/backends/mod.rs b/third_party/nokhwa/src/backends/mod.rs new file mode 100644 index 00000000000..2eadd1d7d39 --- /dev/null +++ b/third_party/nokhwa/src/backends/mod.rs @@ -0,0 +1,17 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pub mod capture; diff --git a/third_party/nokhwa/src/camera.rs b/third_party/nokhwa/src/camera.rs new file mode 100644 index 00000000000..31aca6e531d --- /dev/null +++ b/third_party/nokhwa/src/camera.rs @@ -0,0 +1,582 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use nokhwa_core::types::RequestedFormatType; +use nokhwa_core::{ + buffer::Buffer, + error::NokhwaError, + pixel_format::FormatDecoder, + traits::CaptureBackendTrait, + types::{ + ApiBackend, CameraControl, CameraFormat, CameraIndex, CameraInfo, ControlValueSetter, + FrameFormat, KnownCameraControl, RequestedFormat, Resolution, + }, +}; +use std::{borrow::Cow, collections::HashMap}; +#[cfg(feature = "output-wgpu")] +use wgpu::{Device as WgpuDevice, Queue as WgpuQueue, Texture as WgpuTexture}; + +/// The main `Camera` struct. This is the struct that abstracts over all the backends, providing a simplified interface for use. +pub struct Camera { + idx: CameraIndex, + api: ApiBackend, + device: Box, +} + +impl Camera { + /// Create a new camera from an `index` and `format` + /// # Errors + /// This will error if you either have a bad platform configuration (e.g. `input-v4l` but not on linux) or the backend cannot create the camera (e.g. permission denied). + pub fn new(index: CameraIndex, format: RequestedFormat) -> Result { + Camera::with_backend(index, format, ApiBackend::Auto) + } + + /// Create a new camera from an `index`, `format`, and `backend`. `format` can be `None`. + /// # Errors + /// This will error if you either have a bad platform configuration (e.g. `input-v4l` but not on linux) or the backend cannot create the camera (e.g. permission denied). + pub fn with_backend( + index: CameraIndex, + format: RequestedFormat, + backend: ApiBackend, + ) -> Result { + let camera_backend = init_camera(&index, format, backend)?; + + Ok(Camera { + idx: index, + api: backend, + device: camera_backend, + }) + } + + /// Create a new `Camera` from raw values. + /// # Errors + /// This will error if you either have a bad platform configuration (e.g. `input-v4l` but not on linux) or the backend cannot create the camera (e.g. permission denied). + #[deprecated(since = "0.10.0", note = "please use `new` instead.")] + pub fn new_with( + index: CameraIndex, + width: u32, + height: u32, + fps: u32, + fourcc: FrameFormat, + backend: ApiBackend, + ) -> Result { + let camera_format = CameraFormat::new_from(width, height, fourcc, fps); + Camera::with_backend( + index, + RequestedFormat::with_formats(RequestedFormatType::Exact(camera_format), &[fourcc]), + backend, + ) + } + + /// Allows creation of a [`Camera`] with a custom backend. This is useful if you are creating e.g. a custom module. + /// + /// You **must** have set a format beforehand. + pub fn with_custom( + idx: CameraIndex, + api: ApiBackend, + device: Box, + ) -> Self { + Self { idx, api, device } + } + + /// Gets the current Camera's index. + #[must_use] + pub fn index(&self) -> &CameraIndex { + &self.idx + } + + /// Sets the current Camera's index. Note that this re-initializes the camera. + /// # Errors + /// The Backend may fail to initialize. + pub fn set_index(&mut self, new_idx: &CameraIndex) -> Result<(), NokhwaError> { + { + self.device.stop_stream()?; + } + let new_camera_format = self.device.camera_format(); + let temp = vec![new_camera_format.format()]; + let new_camera = init_camera( + new_idx, + RequestedFormat::with_formats(RequestedFormatType::Exact(new_camera_format), &temp), + self.api, + )?; + self.device = new_camera; + Ok(()) + } + + /// Gets the current Camera's backend + #[must_use] + pub fn backend(&self) -> ApiBackend { + self.api + } + + /// Sets the current Camera's backend. Note that this re-initializes the camera. + /// # Errors + /// The new backend may not exist or may fail to initialize the new camera. + pub fn set_backend(&mut self, new_backend: ApiBackend) -> Result<(), NokhwaError> { + { + self.device.stop_stream()?; + } + let new_camera_format = self.device.camera_format(); + let temp = vec![new_camera_format.format()]; + let new_camera = init_camera( + &self.idx, + RequestedFormat::with_formats(RequestedFormatType::Exact(new_camera_format), &temp), + new_backend, + )?; + self.device = new_camera; + Ok(()) + } + + /// Gets the camera information such as Name and Index as a [`CameraInfo`]. + #[must_use] + pub fn info(&self) -> &CameraInfo { + self.device.camera_info() + } + + /// Gets the current [`CameraFormat`]. + #[must_use] + pub fn camera_format(&self) -> CameraFormat { + self.device.camera_format() + } + + /// Forcefully refreshes the stored camera format, bringing it into sync with "reality" (current camera state) + /// # Errors + /// If the camera can not get its most recent [`CameraFormat`]. this will error. + pub fn refresh_camera_format(&mut self) -> Result { + self.device.refresh_camera_format()?; + Ok(self.device.camera_format()) + } + + /// Will set the current [`CameraFormat`], using a [`RequestedFormat.`] + /// This will reset the current stream if used while stream is opened. + /// + /// This will also update the cache. + /// + /// This will return the new [`CameraFormat`] + /// # Errors + /// If nothing fits the requested criteria, this will return an error. + pub fn set_camera_requset( + &mut self, + request: RequestedFormat, + ) -> Result { + let new_format = request + .fulfill(self.device.compatible_camera_formats()?.as_slice()) + .ok_or(NokhwaError::GetPropertyError { + property: "Compatible Camera Format by request".to_string(), + error: "Failed to fufill".to_string(), + })?; + self.device.set_camera_format(new_format)?; + Ok(new_format) + } + + #[deprecated(since = "0.10.0", note = "please use `set_camera_requset` instead.")] + /// Will set the current [`CameraFormat`] + /// This will reset the current stream if used while stream is opened. + /// + /// This will also update the cache. + /// # Errors + /// If you started the stream and the camera rejects the new camera format, this will return an error. + pub fn set_camera_format(&mut self, new_fmt: CameraFormat) -> Result<(), NokhwaError> { + self.device.set_camera_format(new_fmt) + } + + /// A hashmap of [`Resolution`]s mapped to framerates + /// # Errors + /// This will error if the camera is not queryable or a query operation has failed. Some backends will error this out as a [`UnsupportedOperationError`](crate::NokhwaError::UnsupportedOperationError). + pub fn compatible_list_by_resolution( + &mut self, + fourcc: FrameFormat, + ) -> Result>, NokhwaError> { + self.device.compatible_list_by_resolution(fourcc) + } + + /// A Vector of compatible [`FrameFormat`]s. + /// # Errors + /// This will error if the camera is not queryable or a query operation has failed. Some backends will error this out as a [`UnsupportedOperationError`](crate::NokhwaError::UnsupportedOperationError). + pub fn compatible_fourcc(&mut self) -> Result, NokhwaError> { + self.device.compatible_fourcc() + } + + /// A Vector of available [`CameraFormat`]s. + /// # Errors + /// This will error if the camera is not queryable or a query operation has failed. Some backends will error this out as a [`UnsupportedOperationError`](crate::NokhwaError::UnsupportedOperationError). + pub fn compatible_camera_formats(&mut self) -> Result, NokhwaError> { + self.device.compatible_camera_formats() + } + + /// Gets the current camera resolution (See: [`Resolution`], [`CameraFormat`]). This will force refresh to the current latest if it has changed. + #[must_use] + pub fn resolution(&self) -> Resolution { + self.device.resolution() + } + + /// Will set the current [`Resolution`] + /// This will reset the current stream if used while stream is opened. + /// + /// This will also update the cache. + /// # Errors + /// If you started the stream and the camera rejects the new resolution, this will return an error. + pub fn set_resolution(&mut self, new_res: Resolution) -> Result<(), NokhwaError> { + self.device.set_resolution(new_res) + } + + /// Gets the current camera framerate (See: [`CameraFormat`]). + #[must_use] + pub fn frame_rate(&self) -> u32 { + self.device.frame_rate() + } + + /// Will set the current framerate + /// This will reset the current stream if used while stream is opened. + /// + /// This will also update the cache. + /// # Errors + /// If you started the stream and the camera rejects the new framerate, this will return an error. + pub fn set_frame_rate(&mut self, new_fps: u32) -> Result<(), NokhwaError> { + self.device.set_frame_rate(new_fps) + } + + /// Gets the current camera's frame format (See: [`FrameFormat`], [`CameraFormat`]). This will force refresh to the current latest if it has changed. + #[must_use] + pub fn frame_format(&self) -> FrameFormat { + self.device.frame_format() + } + + /// Will set the current [`FrameFormat`] + /// This will reset the current stream if used while stream is opened. + /// + /// This will also update the cache. + /// # Errors + /// If you started the stream and the camera rejects the new frame format, this will return an error. + pub fn set_frame_format(&mut self, fourcc: FrameFormat) -> Result<(), NokhwaError> { + self.device.set_frame_format(fourcc) + } + + /// Gets the current supported list of [`KnownCameraControl`](crate::utils::KnownCameraControl) + /// # Errors + /// If the list cannot be collected, this will error. This can be treated as a "nothing supported". + pub fn supported_camera_controls(&self) -> Result, NokhwaError> { + Ok(self + .device + .camera_controls()? + .iter() + .map(CameraControl::control) + .collect()) + } + + /// Gets the current supported list of [`CameraControl`]s keyed by its name as a `String`. + /// # Errors + /// If the list cannot be collected, this will error. This can be treated as a "nothing supported". + pub fn camera_controls(&self) -> Result, NokhwaError> { + let known_controls = self.supported_camera_controls()?; + let maybe_camera_controls = known_controls + .iter() + .map(|x| self.camera_control(*x)) + .filter(Result::is_ok) + .map(Result::unwrap) + .collect::>(); + + Ok(maybe_camera_controls) + } + + /// Gets the current supported list of [`CameraControl`]s keyed by its name as a `String`. + /// # Errors + /// If the list cannot be collected, this will error. This can be treated as a "nothing supported". + pub fn camera_controls_string(&self) -> Result, NokhwaError> { + let known_controls = self.supported_camera_controls()?; + let maybe_camera_controls = known_controls + .iter() + .map(|x| (x.to_string(), self.camera_control(*x))) + .filter(|(_, x)| x.is_ok()) + .map(|(c, x)| (c, Result::unwrap(x))) + .collect::>(); + let mut control_map = HashMap::with_capacity(maybe_camera_controls.len()); + + for (kc, cc) in maybe_camera_controls { + control_map.insert(kc, cc); + } + + Ok(control_map) + } + + /// Gets the current supported list of [`CameraControl`]s keyed by its name as a `String`. + /// # Errors + /// If the list cannot be collected, this will error. This can be treated as a "nothing supported". + pub fn camera_controls_known_camera_controls( + &self, + ) -> Result, NokhwaError> { + let known_controls = self.supported_camera_controls()?; + let maybe_camera_controls = known_controls + .iter() + .map(|x| (*x, self.camera_control(*x))) + .filter(|(_, x)| x.is_ok()) + .map(|(c, x)| (c, Result::unwrap(x))) + .collect::>(); + let mut control_map = HashMap::with_capacity(maybe_camera_controls.len()); + + for (kc, cc) in maybe_camera_controls { + control_map.insert(kc, cc); + } + + Ok(control_map) + } + + /// Gets the value of [`KnownCameraControl`]. + /// # Errors + /// If the `control` is not supported or there is an error while getting the camera control values (e.g. unexpected value, too high, etc) + /// this will error. + pub fn camera_control( + &self, + control: KnownCameraControl, + ) -> Result { + self.device.camera_control(control) + } + + /// Sets the control to `control` in the camera. + /// Usually, the pipeline is calling [`camera_control()`](crate::camera_traits::CaptureBackendTrait::camera_control), getting a camera control that way + /// then calling [`value()`](crate::utils::CameraControl::value()) to get a [`ControlValueSetter`](crate::utils::ControlValueSetter) and setting the value that way. + /// # Errors + /// If the `control` is not supported, the value is invalid (less than min, greater than max, not in step), or there was an error setting the control, + /// this will error. + pub fn set_camera_control( + &mut self, + id: KnownCameraControl, + value: ControlValueSetter, + ) -> Result<(), NokhwaError> { + self.device.set_camera_control(id, value) + } + + /// Will open the camera stream with set parameters. This will be called internally if you try and call [`frame()`](CaptureBackendTrait::frame()) before you call [`open_stream()`](CaptureBackendTrait::open_stream()). + /// # Errors + /// If the specific backend fails to open the camera (e.g. already taken, busy, doesn't exist anymore) this will error. + pub fn open_stream(&mut self) -> Result<(), NokhwaError> { + self.device.open_stream() + } + + /// Checks if stream if open. If it is, it will return true. + #[must_use] + pub fn is_stream_open(&self) -> bool { + self.device.is_stream_open() + } + + /// Will get a frame from the camera as a Raw RGB image buffer. Depending on the backend, if you have not called [`open_stream()`](CaptureBackendTrait::open_stream()) before you called this, + /// it will either return an error. + /// # Errors + /// If the backend fails to get the frame (e.g. already taken, busy, doesn't exist anymore), the decoding fails (e.g. MJPEG -> u8), or [`open_stream()`](CaptureBackendTrait::open_stream()) has not been called yet, + /// this will error. + pub fn frame(&mut self) -> Result { + self.device.frame() + } + + /// Will get a frame from the camera **without** any processing applied, meaning you will usually get a frame you need to decode yourself. + /// # Errors + /// If the backend fails to get the frame (e.g. already taken, busy, doesn't exist anymore), or [`open_stream()`](CaptureBackendTrait::open_stream()) has not been called yet, this will error. + pub fn frame_raw(&mut self) -> Result, NokhwaError> { + match self.device.frame_raw() { + Ok(f) => Ok(f), + Err(why) => Err(why), + } + } + + /// Directly writes the current frame into said `buffer`. + /// # Errors + /// If the backend fails to get the frame (e.g. already taken, busy, doesn't exist anymore), or [`open_stream()`](CaptureBackendTrait::open_stream()) has not been called yet, this will error. + pub fn write_frame_to_buffer( + &mut self, + buffer: &mut [u8], + ) -> Result<(), NokhwaError> { + self.device.frame()?.decode_image_to_buffer::(buffer) + } + + #[cfg(feature = "output-wgpu")] + #[cfg_attr(feature = "docs-features", doc(cfg(feature = "output-wgpu")))] + /// Directly copies a frame to a Wgpu texture. This will automatically convert the frame into a RGBA frame. + /// # Errors + /// If the frame cannot be captured or the resolution is 0 on any axis, this will error. + pub fn frame_texture<'a, F: FormatDecoder>( + &mut self, + device: &WgpuDevice, + queue: &WgpuQueue, + label: Option<&'a str>, + ) -> Result { + self.device.frame_texture(device, queue, label) + } + + /// Will drop the stream. + /// # Errors + /// Please check the `Quirks` section of each backend. + pub fn stop_stream(&mut self) -> Result<(), NokhwaError> { + self.device.stop_stream() + } +} + +impl Drop for Camera { + fn drop(&mut self) { + self.stop_stream().unwrap(); + } +} + +// TODO: Update as we go +#[allow(clippy::ifs_same_cond)] +fn figure_out_auto() -> Option { + let platform = std::env::consts::OS; + let mut cap = ApiBackend::Auto; + if cfg!(feature = "input-v4l") && platform == "linux" { + cap = ApiBackend::Video4Linux; + } else if cfg!(feature = "input-msmf") && platform == "windows" { + cap = ApiBackend::MediaFoundation; + } else if cfg!(feature = "input-avfoundation") && (platform == "macos" || platform == "ios") { + cap = ApiBackend::AVFoundation; + } else if cfg!(feature = "input-opencv") { + cap = ApiBackend::OpenCv; + } + if cap == ApiBackend::Auto { + return None; + } + Some(cap) +} + +macro_rules! cap_impl_fn { + { + $( ($backend:expr, $init_fn:ident, $cfg:meta, $backend_name:ident) ),+ + } => { + $( + paste::paste! { + #[cfg ($cfg) ] + fn [< init_ $backend_name>](idx: &CameraIndex, setting: RequestedFormat) -> Option, NokhwaError>> { + use crate::backends::capture::$backend; + match <$backend>::$init_fn(idx, setting) { + Ok(cap) => Some(Ok(cap.into())), + Err(why) => Some(Err(why)), + } + } + #[cfg(not( $cfg ))] + fn [< init_ $backend_name>](_idx: &CameraIndex, _setting: RequestedFormat) -> Option, NokhwaError>> { + None + } + } + )+ + }; +} + +macro_rules! cap_impl_matches { + { + $use_backend: expr, $index:expr, $setting:expr, + $( ($feature:expr, $backend:ident, $fn:ident) ),+ + } => { + { + let i = $index; + let s = $setting; + match $use_backend { + ApiBackend::Auto => match figure_out_auto() { + Some(cap) => match cap { + $( + ApiBackend::$backend => { + match cfg!(feature = $feature) { + true => { + match $fn(i,s) { + Some(cap) => match cap { + Ok(c) => c, + Err(why) => return Err(why), + } + None => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied (Wrong Platform - Not Implemented).".to_string(), + )); + } + } + } + false => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied. (Wrong Platform - Not Selected)".to_string(), + )); + } + } + } + )+ + _ => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied. (Invalid Backend)".to_string(), + )); + } + } + None => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied. (No Selection)".to_string(), + )); + } + } + $( + ApiBackend::$backend => { + match cfg!(feature = $feature) { + true => { + match $fn(i,s) { + Some(cap) => match cap { + Ok(c) => c, + Err(why) => return Err(why), + } + None => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied (Wrong Platform - Not Implemented).".to_string(), + )); + } + } + } + false => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied. (Wrong Platform - Not Selected)".to_string(), + )); + } + } + } + )+ + + _ => { + return Err(NokhwaError::NotImplementedError( + "Platform requirements not satisfied. (Wrong Platform - Not Selected)".to_string(), + )); + } + } + } + } +} + +cap_impl_fn! { + // (GStreamerCaptureDevice, new, feature = "input-gst", gst), + (OpenCvCaptureDevice, new, feature = "input-opencv", opencv), + // (UVCCaptureDevice, create, feature = "input-uvc", uvc), + (V4LCaptureDevice, new, all(feature = "input-v4l", target_os = "linux"), v4l), + (MediaFoundationCaptureDevice, new, all(feature = "input-msmf", target_os = "windows"), msmf), + (AVFoundationCaptureDevice, new, all(feature = "input-avfoundation", any(target_os = "macos", target_os = "ios")), avfoundation) +} + +fn init_camera( + index: &CameraIndex, + format: RequestedFormat, + backend: ApiBackend, +) -> Result, NokhwaError> { + let camera_backend = cap_impl_matches! { + backend, index, format, + ("input-v4l", Video4Linux, init_v4l), + ("input-msmf", MediaFoundation, init_msmf), + ("input-avfoundation", AVFoundation, init_avfoundation), + ("input-opencv", OpenCv, init_opencv) + }; + Ok(camera_backend) +} + +#[cfg(feature = "camera-sync-impl")] +unsafe impl Send for Camera {} diff --git a/third_party/nokhwa/src/init.rs b/third_party/nokhwa/src/init.rs new file mode 100644 index 00000000000..2c4854f9054 --- /dev/null +++ b/third_party/nokhwa/src/init.rs @@ -0,0 +1,71 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#[cfg(not(all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") +)))] +fn init_avfoundation(callback: impl Fn(bool) + Send + 'static) { + callback(true); +} + +#[cfg(all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") +))] +fn init_avfoundation(callback: impl Fn(bool) + Send + Sync + 'static) { + use nokhwa_bindings_macos::request_permission_with_callback; + + request_permission_with_callback(callback); +} + +#[cfg(not(all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") +)))] +fn status_avfoundation() -> bool { + true +} + +#[cfg(all( + feature = "input-avfoundation", + any(target_os = "macos", target_os = "ios") +))] +fn status_avfoundation() -> bool { + use nokhwa_bindings_macos::{current_authorization_status, AVAuthorizationStatus}; + + matches!( + current_authorization_status(), + AVAuthorizationStatus::Authorized + ) +} + +// todo: make this work on browser code +/// Initialize `nokhwa` +/// It is your responsibility to call this function before anything else, but only on `MacOS`. +/// +/// The `on_complete` is called after initialization (a.k.a User granted permission). The callback's argument +/// is weather the initialization was successful or not +pub fn nokhwa_initialize(on_complete: impl Fn(bool) + Send + Sync + 'static) { + init_avfoundation(on_complete); +} + +/// Check the status if `nokhwa` +/// True if the initialization is successful (ready-to-use) +#[must_use] +pub fn nokhwa_check() -> bool { + status_avfoundation() +} diff --git a/third_party/nokhwa/src/js_camera.rs b/third_party/nokhwa/src/js_camera.rs new file mode 100644 index 00000000000..9186fc5457c --- /dev/null +++ b/third_party/nokhwa/src/js_camera.rs @@ -0,0 +1,2720 @@ +/* + * Copyright 2022 l1npengtul / The Nokhwa Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! This contains all the code for using webcams in the browser. +//! +//! Anything starting with `js` is meant as a binding, a.k.a. not meant for consumption. +//! +//! This assumes that you are running a modern browser on the desktop. + +use image::{buffer::ConvertBuffer, ImageBuffer, Rgb, RgbImage, Rgba}; +use js_sys::{Array, JsString, Map, Object, Promise}; +use nokhwa_core::{ + error::NokhwaError, + types::{CameraIndex, CameraInfo, Resolution}, +}; +use std::{ + borrow::{Borrow, Cow}, + convert::TryFrom, + fmt::{Debug, Display, Formatter}, + ops::Deref, +}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + console::log_1, CanvasRenderingContext2d, Document, Element, HtmlCanvasElement, + HtmlVideoElement, ImageData, MediaDeviceInfo, MediaDeviceKind, MediaDevices, MediaStream, + MediaStreamConstraints, MediaStreamTrack, MediaStreamTrackState, Navigator, Node, Window, +}; +#[cfg(feature = "output-wgpu")] +use wgpu::{ + Device, Extent3d, ImageCopyTexture, ImageDataLayout, Queue, Texture, TextureAspect, + TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, +}; + +// why no code completion +// big sadger + +// intellij 2021.2 review: i like structure window, 4 pengs / 5 pengs + +macro_rules! jsv { + ($value:expr) => {{ + JsValue::from($value) + }}; +} + +macro_rules! obj { + ($(($key:expr, $value:expr)),+ ) => {{ + use js_sys::{Map, Object}; + use wasm_bindgen::JsValue; + + let map = Map::new(); + $( + map.set(&jsv!($key), &jsv!($value)); + )+ + Object::from(map) + }}; + ($object:expr, $(($key:expr, $value:expr)),+ ) => {{ + use js_sys::{Map, Object}; + use wasm_bindgen::JsValue; + + let map = Map::new(); + $( + map.set(&jsv!($key), &jsv!($value)); + )+ + let o = Object::from(map); + Object::assign(&$object, &o) + }}; +} + +fn window() -> Result { + match web_sys::window() { + Some(win) => Ok(win), + None => Err(NokhwaError::StructureError { + structure: "web_sys Window".to_string(), + error: "None".to_string(), + }), + } +} + +fn media_devices(navigator: &Navigator) -> Result { + match navigator.media_devices() { + Ok(media) => Ok(media), + Err(why) => Err(NokhwaError::StructureError { + structure: "MediaDevices".to_string(), + error: format!("{why:?}"), + }), + } +} + +fn document(window: &Window) -> Result { + match window.document() { + Some(doc) => Ok(doc), + None => Err(NokhwaError::StructureError { + structure: "web_sys Document".to_string(), + error: "None".to_string(), + }), + } +} + +fn document_select_elem(doc: &Document, element: &str) -> Result { + match doc.get_element_by_id(element) { + Some(elem) => Ok(elem), + None => { + return Err(NokhwaError::StructureError { + structure: format!("Document {element}"), + error: "None".to_string(), + }) + } + } +} + +fn element_cast(from: T, name: &str) -> Result { + if !from.has_type::() { + return Err(NokhwaError::StructureError { + structure: name.to_string(), + error: "Cannot Cast - No Subtype".to_string(), + }); + } + + let casted = match from.dyn_into::() { + Ok(cast) => cast, + Err(_) => { + return Err(NokhwaError::StructureError { + structure: name.to_string(), + error: "Casting Error".to_string(), + }); + } + }; + Ok(casted) +} + +fn element_cast_ref<'a, T: JsCast, U: JsCast>( + from: &'a T, + name: &'a str, +) -> Result<&'a U, NokhwaError> { + if !from.has_type::() { + return Err(NokhwaError::StructureError { + structure: name.to_string(), + error: "Cannot Cast - No Subtype".to_string(), + }); + } + + match from.dyn_ref::() { + Some(v_e) => Ok(v_e), + None => Err(NokhwaError::StructureError { + structure: name.to_string(), + error: "Cannot Cast".to_string(), + }), + } +} + +fn create_element(doc: &Document, element: &str) -> Result { + match Document::create_element(doc, element) { + // ???? thank you intellij + Ok(new_element) => Ok(new_element), + Err(why) => Err(NokhwaError::StructureError { + structure: "Document Video Element".to_string(), + error: format!("{:?}", why.as_string()), + }), + } +} + +fn set_autoplay_inline(element: &Element) -> Result<(), NokhwaError> { + if let Err(why) = element.set_attribute("autoplay", "autoplay") { + return Err(NokhwaError::SetPropertyError { + property: "Video-autoplay".to_string(), + value: "autoplay".to_string(), + error: format!("{why:?}"), + }); + } + + if let Err(why) = element.set_attribute("playsinline", "playsinline") { + return Err(NokhwaError::SetPropertyError { + property: "Video-playsinline".to_string(), + value: "playsinline".to_string(), + error: format!("{why:?}"), + }); + } + + Ok(()) +} + +/// Requests Webcam permissions from the browser using [`MediaDevices::get_user_media()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaDevices.html#method.get_user_media) [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +pub async fn request_permission() -> Result<(), NokhwaError> { + let window: Window = window()?; + let navigator = window.navigator(); + let media_devices = media_devices(&navigator)?; + + match media_devices.get_user_media_with_constraints( + MediaStreamConstraints::new() + .video(&JsValue::from_bool(true)) + .audio(&JsValue::from_bool(false)), + ) { + Ok(promise) => { + let js_future = JsFuture::from(promise); + match js_future.await { + Ok(stream) => { + let media_stream = MediaStream::from(stream); + media_stream + .get_tracks() + .iter() + .for_each(|track| MediaStreamTrack::from(track).stop()); + Ok(()) + } + Err(why) => Err(NokhwaError::OpenStreamError(format!("{why:?}"))), + } + } + Err(why) => Err(NokhwaError::StructureError { + structure: "UserMediaPermission".to_string(), + error: format!("{why:?}"), + }), + } +} + +/// Requests Webcam permissions from the browser using [`MediaDevices::get_user_media()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaDevices.html#method.get_user_media) [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +/// # JS-WASM +/// In exported JS bindings, the name of the function is `requestPermissions`. It may throw an exception. +#[cfg(feature = "output-wasm")] +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = requestPermissions))] +pub async fn js_request_permission() -> Result<(), JsValue> { + if let Err(why) = request_permission().await { + return Err(JsValue::from(why.to_string())); + } + Ok(()) +} + +/// Queries Cameras using [`MediaDevices::enumerate_devices()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaDevices.html#method.enumerate_devices) [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +pub async fn query_js_cameras() -> Result, NokhwaError> { + let window: Window = window()?; + let navigator = window.navigator(); + let media_devices = media_devices(&navigator)?; + + match media_devices.enumerate_devices() { + Ok(prom) => { + let prom: Promise = prom; + let future = JsFuture::from(prom); + match future.await { + Ok(v) => { + let array: Array = Array::from(&v); + let mut device_list = vec![]; + request_permission().await.unwrap_or(()); // swallow errors + for idx_device in 0_u32..array.length() { + if MediaDeviceInfo::instanceof(&array.get(idx_device)) { + let media_device_info = + MediaDeviceInfo::unchecked_from_js(array.get(idx_device)); + + if media_device_info.kind() == MediaDeviceKind::Videoinput { + match media_devices.get_user_media_with_constraints( + MediaStreamConstraints::new() + .audio(&jsv!(false)) + .video(&jsv!(obj!(( + "deviceId", + media_device_info.device_id() + )))), + ) { + Ok(promised_stream) => { + let future_stream = JsFuture::from(promised_stream); + if let Ok(stream) = future_stream.await { + let stream = MediaStream::from(stream); + let tracks = stream.get_video_tracks(); + let first = tracks.get(0); + let name = if first.is_undefined() { + format!( + "{:?}#{}", + media_device_info.kind(), + idx_device + ) + } else { + MediaStreamTrack::from(first).label() + }; + device_list.push(CameraInfo::new( + &name, + &format!("{:?}", media_device_info.kind()), + &format!( + "{} {}", + media_device_info.group_id(), + media_device_info.device_id() + ), + CameraIndex::String(format!( + "{} {}", + media_device_info.group_id(), + media_device_info.device_id() + )), + )); + tracks + .iter() + .for_each(|t| MediaStreamTrack::from(t).stop()); + } + } + Err(_) => { + device_list.push(CameraInfo::new( + &format!( + "{:?}#{}", + media_device_info.kind(), + idx_device + ), + &format!("{:?}", media_device_info.kind()), + &format!( + "{} {}", + media_device_info.group_id(), + media_device_info.device_id() + ), + CameraIndex::String(format!( + "{} {}", + media_device_info.group_id(), + media_device_info.device_id() + )), + )); + } + } + } + } + } + Ok(device_list) + } + Err(why) => Err(NokhwaError::StructureError { + structure: "EnumerateDevicesFuture".to_string(), + error: format!("{why:?}"), + }), + } + } + Err(why) => Err(NokhwaError::StructureError { + structure: "EnumerateDevices".to_string(), + error: format!("{why:?}"), + }), + } +} + +/// Queries Cameras using [`MediaDevices::enumerate_devices()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaDevices.html#method.enumerate_devices) [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +/// # JS-WASM +/// This is exported as `queryCameras`. It may throw an exception. +#[cfg(feature = "output-wasm")] +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = queryCameras))] +pub async fn js_query_js_cameras() -> Result { + match query_js_cameras().await { + Ok(cameras) => Ok(cameras.into_iter().map(JsValue::from).collect()), + Err(why) => Err(JsValue::from(why.to_string())), + } +} + +/// Queries the browser's supported constraints using [`navigator.mediaDevices.getSupportedConstraints()`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getSupportedConstraints) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +pub fn query_supported_constraints() -> Result, NokhwaError> { + let window: Window = window()?; + let navigator = window.navigator(); + let media_devices = media_devices(&navigator)?; + + let supported_constraints = JsValue::from(media_devices.get_supported_constraints()); + let dict_supported_constraints = Object::from(supported_constraints); + + let mut capabilities_vec = vec![]; + for constraint in Object::keys(&dict_supported_constraints).iter() { + let constraint_str = JsValue::from(JsString::from(constraint)) + .as_string() + .unwrap_or_default(); + + // swallow errors + if let Ok(cap) = JSCameraSupportedCapabilities::try_from(constraint_str) { + capabilities_vec.push(cap); + } + } + Ok(capabilities_vec) +} + +/// Queries the browser's supported constraints using [`navigator.mediaDevices.getSupportedConstraints()`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getSupportedConstraints) +/// # Errors +/// This will error if there is no valid web context or the web API is not supported +/// # JS-WASM +/// This is exported as `queryConstraints` and returns an array of strings. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = queryConstraints))] +pub fn query_supported_constraints_js() -> Result { + match query_supported_constraints() { + Ok(constraints) => Ok(constraints + .into_iter() + .map(|c| JsValue::from(c.to_string())) + .collect()), + Err(why) => Err(JsValue::from(why.to_string())), + } +} + +/// The enum describing the possible constraints for video in the browser. +/// - `DeviceID`: The ID of the device +/// - `GroupID`: The ID of the group that the device is in +/// - `AspectRatio`: The Aspect Ratio of the final stream +/// - `FacingMode`: What direction the camera is facing. This is more common on mobile. See [`JSCameraFacingMode`] +/// - `FrameRate`: The Frame Rate of the final stream +/// - `Height`: The height of the final stream in pixels +/// - `Width`: The width of the final stream in pixels +/// - `ResizeMode`: Whether the client can crop and/or scale the stream to match the resolution (width, height). See [`JSCameraResizeMode`] +/// See More: [`MediaTrackConstraints`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints) [`Capabilities, constraints, and settings`](https://developer.mozilla.org/en-US/docs/Web/API/Media_Streams_API/Constraints) +/// # JS-WASM +/// This is exported as `CameraSupportedCapabilities`. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = CameraSupportedCapabilities))] +#[derive(Copy, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] +pub enum JSCameraSupportedCapabilities { + DeviceID, + GroupID, + AspectRatio, + FacingMode, + FrameRate, + Height, + Width, + ResizeMode, +} + +impl Display for JSCameraSupportedCapabilities { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let cap = match self { + JSCameraSupportedCapabilities::DeviceID => "deviceId", + JSCameraSupportedCapabilities::GroupID => "groupId", + JSCameraSupportedCapabilities::AspectRatio => "aspectRatio", + JSCameraSupportedCapabilities::FacingMode => "facingMode", + JSCameraSupportedCapabilities::FrameRate => "frameRate", + JSCameraSupportedCapabilities::Height => "height", + JSCameraSupportedCapabilities::Width => "width", + JSCameraSupportedCapabilities::ResizeMode => "resizeMode", + }; + + write!(f, "{cap}") + } +} + +impl Debug for JSCameraSupportedCapabilities { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let str = self.to_string(); + write!(f, "{str}") + } +} + +impl TryFrom for JSCameraSupportedCapabilities { + type Error = NokhwaError; + + fn try_from(value: String) -> Result { + let value = value.as_str(); + let result = match value { + "deviceId" => JSCameraSupportedCapabilities::DeviceID, + "groupId" => JSCameraSupportedCapabilities::GroupID, + "aspectRatio" => JSCameraSupportedCapabilities::AspectRatio, + "facingMode" => JSCameraSupportedCapabilities::FacingMode, + "frameRate" => JSCameraSupportedCapabilities::FrameRate, + "height" => JSCameraSupportedCapabilities::Height, + "width" => JSCameraSupportedCapabilities::Width, + "resizeMode" => JSCameraSupportedCapabilities::ResizeMode, + _ => { + return Err(NokhwaError::StructureError { + structure: "JSCameraSupportedCapabilities".to_string(), + error: "No Match Str".to_string(), + }) + } + }; + Ok(result) + } +} + +/// The Facing Mode of the camera +/// - Any: Make no particular choice. +/// - Environment: The camera that shows the user's environment, such as the back camera of a smartphone +/// - User: The camera that shows the user, such as the front camera of a smartphone +/// - Left: The camera that shows the user but to their left, such as a camera that shows a user but to their left shoulder +/// - Right: The camera that shows the user but to their right, such as a camera that shows a user but to their right shoulder +/// See More: [`facingMode`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode) +/// # JS-WASM +/// This is exported as `CameraFacingMode`. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = CameraFacingMode))] +#[derive(Copy, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] +pub enum JSCameraFacingMode { + Any, + Environment, + User, + Left, + Right, +} + +impl Display for JSCameraFacingMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let cap = match self { + JSCameraFacingMode::Environment => "environment", + JSCameraFacingMode::User => "user", + JSCameraFacingMode::Left => "left", + JSCameraFacingMode::Right => "right", + JSCameraFacingMode::Any => "any", + }; + write!(f, "{cap}") + } +} + +impl Debug for JSCameraFacingMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let str = self.to_string(); + write!(f, "{str}") + } +} + +/// Whether the browser can crop and/or scale to match the requested resolution. +/// - `Any`: Make no particular choice. +/// - `None`: Do not crop and/or scale. +/// - `CropAndScale`: Crop and/or scale to match the requested resolution. +/// See More: [`resizeMode`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#resizemode) +/// # JS-WASM +/// This is exported as `CameraResizeMode`. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = CameraResizeMode))] +#[derive(Copy, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] +pub enum JSCameraResizeMode { + Any, + None, + CropAndScale, +} + +impl Display for JSCameraResizeMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let cap = match self { + JSCameraResizeMode::None => "none", + JSCameraResizeMode::CropAndScale => "crop-and-scale", + JSCameraResizeMode::Any => "", + }; + + write!(f, "{cap}") + } +} + +impl Debug for JSCameraResizeMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let str = self.to_string(); + write!(f, "{str}") + } +} + +/// A builder that builds a [`JSCameraConstraints`] that is used to construct a [`JSCamera`]. +/// See More: [`Constraints MDN`](https://developer.mozilla.org/en-US/docs/Web/API/Media_Streams_API/Constraints), [`Properties of Media Tracks MDN`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints) +/// # JS-WASM +/// This is exported as `CameraConstraintsBuilder`. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = CameraConstraintsBuilder))] +#[derive(Clone, Debug)] +#[allow(clippy::struct_excessive_bools)] +pub struct JSCameraConstraintsBuilder { + pub(crate) min_resolution: Option, + pub(crate) preferred_resolution: Resolution, + pub(crate) max_resolution: Option, + pub(crate) resolution_exact: bool, + pub(crate) min_aspect_ratio: Option, + pub(crate) aspect_ratio: f64, + pub(crate) max_aspect_ratio: Option, + pub(crate) aspect_ratio_exact: bool, + pub(crate) facing_mode: JSCameraFacingMode, + pub(crate) facing_mode_exact: bool, + pub(crate) min_frame_rate: Option, + pub(crate) frame_rate: u32, + pub(crate) max_frame_rate: Option, + pub(crate) frame_rate_exact: bool, + pub(crate) resize_mode: JSCameraResizeMode, + pub(crate) resize_mode_exact: bool, + pub(crate) device_id: String, + pub(crate) device_id_exact: bool, + pub(crate) group_id: String, + pub(crate) group_id_exact: bool, +} + +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_class = CameraConstraintsBuilder))] +impl JSCameraConstraintsBuilder { + /// Constructs a default [`JSCameraConstraintsBuilder`]. + /// The constructed default [`JSCameraConstraintsBuilder`] has these settings: + /// - 480x234 min, 640x360 ideal, 1920x1080 max + /// - 10 FPS min, 15 FPS ideal, 30 FPS max + /// - 1.0 aspect ratio min, 1.77777777778 aspect ratio ideal, 2.0 aspect ratio max + /// - No `exact`s + /// # JS-WASM + /// This is exported as a constructor. + #[must_use] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(constructor))] + pub fn new() -> Self { + JSCameraConstraintsBuilder::default() + } + + /// Sets the minimum resolution for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`width`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/width) and [`height`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height). + /// # JS-WASM + /// This is exported as `set_MinResolution`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MinResolution) + )] + pub fn min_resolution(mut self, min_resolution: Resolution) -> JSCameraConstraintsBuilder { + self.min_resolution = Some(min_resolution); + self + } + + /// Sets the preferred resolution for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`width`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/width) and [`height`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height). + /// # JS-WASM + /// This is exported as `set_Resolution`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = Resolution) + )] + pub fn resolution(mut self, new_resolution: Resolution) -> JSCameraConstraintsBuilder { + self.preferred_resolution = new_resolution; + self + } + + /// Sets the maximum resolution for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`width`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/width) and [`height`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height). + /// # JS-WASM + /// This is exported as `set_MaxResolution`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MaxResolution) + )] + pub fn max_resolution(mut self, max_resolution: Resolution) -> JSCameraConstraintsBuilder { + self.min_resolution = Some(max_resolution); + self + } + + /// Sets whether the resolution fields ([`width`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/width), [`height`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height)/[`resolution`](crate::js_camera::JSCameraConstraintsBuilder::resolution)) + /// should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// Note that this will make the builder ignore [`min_resolution`](crate::js_camera::JSCameraConstraintsBuilder::min_resolution) and [`max_resolution`](crate::js_camera::JSCameraConstraintsBuilder::max_resolution). + /// # JS-WASM + /// This is exported as `set_ResolutionExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = ResolutionExact) + )] + pub fn resolution_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.resolution_exact = value; + self + } + + /// Sets the minimum aspect ratio of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`aspectRatio`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio). + /// # JS-WASM + /// This is exported as `set_MinAspectRatio`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MinAspectRatio) + )] + pub fn min_aspect_ratio(mut self, ratio: f64) -> JSCameraConstraintsBuilder { + self.min_aspect_ratio = Some(ratio); + self + } + + /// Sets the aspect ratio of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`aspectRatio`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio). + /// # JS-WASM + /// This is exported as `set_AspectRatio`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = AspectRatio) + )] + pub fn aspect_ratio(mut self, ratio: f64) -> JSCameraConstraintsBuilder { + self.aspect_ratio = ratio; + self + } + + /// Sets the maximum aspect ratio of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`aspectRatio`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio). + /// # JS-WASM + /// This is exported as `set_MaxAspectRatio`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MaxAspectRatio) + )] + pub fn max_aspect_ratio(mut self, ratio: f64) -> JSCameraConstraintsBuilder { + self.max_aspect_ratio = Some(ratio); + self + } + + /// Sets whether the [`aspect_ratio`](crate::js_camera::JSCameraConstraintsBuilder::aspect_ratio) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// Note that this will make the builder ignore [`min_aspect_ratio`](crate::js_camera::JSCameraConstraintsBuilder::min_aspect_ratio) and [`max_aspect_ratio`](crate::js_camera::JSCameraConstraintsBuilder::max_aspect_ratio). + /// # JS-WASM + /// This is exported as `set_AspectRatioExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = AspectRatioExact) + )] + pub fn aspect_ratio_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.aspect_ratio_exact = value; + self + } + + /// Sets the facing mode of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`facingMode`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode). + /// # JS-WASM + /// This is exported as `set_FacingMode`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = FacingMode) + )] + pub fn facing_mode(mut self, facing_mode: JSCameraFacingMode) -> JSCameraConstraintsBuilder { + self.facing_mode = facing_mode; + self + } + + /// Sets whether the [`facing_mode`](crate::js_camera::JSCameraConstraintsBuilder::facing_mode) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// # JS-WASM + /// This is exported as `set_FacingModeExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = FacingModeExact) + )] + pub fn facing_mode_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.facing_mode_exact = value; + self + } + + /// Sets the minimum frame rate of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`frameRate`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/frameRate). + /// # JS-WASM + /// This is exported as `set_MinFrameRate`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MinFrameRate) + )] + pub fn min_frame_rate(mut self, fps: u32) -> JSCameraConstraintsBuilder { + self.min_frame_rate = Some(fps); + self + } + + /// Sets the frame rate of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`frameRate`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/frameRate). + /// # JS-WASM + /// This is exported as `set_FrameRate`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = FrameRate) + )] + pub fn frame_rate(mut self, fps: u32) -> JSCameraConstraintsBuilder { + self.frame_rate = fps; + self + } + + /// Sets the maximum frame rate of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`frameRate`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/frameRate). + /// # JS-WASM + /// This is exported as `set_MaxFrameRate`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = MaxFrameRate) + )] + pub fn max_frame_rate(mut self, fps: u32) -> JSCameraConstraintsBuilder { + self.max_frame_rate = Some(fps); + self + } + + /// Sets whether the [`frame_rate`](crate::js_camera::JSCameraConstraintsBuilder::frame_rate) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// Note that this will make the builder ignore [`min_frame_rate`](crate::js_camera::JSCameraConstraintsBuilder::min_frame_rate) and [`max_frame_rate`](crate::js_camera::JSCameraConstraintsBuilder::max_frame_rate). + /// # JS-WASM + /// This is exported as `set_FrameRateExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = FrameRateExact) + )] + pub fn frame_rate_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.frame_rate_exact = value; + self + } + + /// Sets the resize mode of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`resizeMode`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#resizemode). + /// # JS-WASM + /// This is exported as `set_ResizeMode`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = ResizeMode) + )] + pub fn resize_mode(mut self, resize_mode: JSCameraResizeMode) -> JSCameraConstraintsBuilder { + self.resize_mode = resize_mode; + self + } + + /// Sets whether the [`resize_mode`](crate::js_camera::JSCameraConstraintsBuilder::resize_mode) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// # JS-WASM + /// This is exported as `set_ResizeModeExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = ResizeModeExact) + )] + pub fn resize_mode_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.resize_mode_exact = value; + self + } + + /// Sets the device ID of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`deviceId`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/deviceId). + /// # JS-WASM + /// This is exported as `set_DeviceId`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = DeviceId) + )] + pub fn device_id(mut self, id: &str) -> JSCameraConstraintsBuilder { + self.device_id = id.to_string(); + self + } + + /// Sets whether the [`device_id`](crate::js_camera::JSCameraConstraintsBuilder::device_id) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// # JS-WASM + /// This is exported as `set_DeviceIdExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = DeviceIdExact) + )] + pub fn device_id_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.device_id_exact = value; + self + } + + /// Sets the group ID of the resulting constraint for the [`JSCameraConstraintsBuilder`]. + /// + /// Sets [`groupId`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/groupId). + /// # JS-WASM + /// This is exported as `set_GroupId`. + #[must_use] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = GroupId))] + pub fn group_id(mut self, id: &str) -> JSCameraConstraintsBuilder { + self.group_id = id.to_string(); + self + } + + /// Sets whether the [`group_id`](crate::js_camera::JSCameraConstraintsBuilder::group_id) field should use [`exact`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#constraints). + /// # JS-WASM + /// This is exported as `set_GroupIdExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = GroupIdExact) + )] + pub fn group_id_exact(mut self, value: bool) -> JSCameraConstraintsBuilder { + self.group_id_exact = value; + self + } + + /// Builds the [`JSCameraConstraints`]. Wrapper for [`build`](crate::js_camera::JSCameraConstraintsBuilder::build) + /// + /// Fields that use exact are marked `exact`, otherwise are marked with `ideal`. If min-max are involved, they will use `min` and `max` accordingly. + /// # JS-WASM + /// This is exported as `buildCameraConstraints`. + #[cfg(feature = "output-wasm")] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(js_name = buildCameraConstraints) + )] + #[must_use] + pub fn js_build(self) -> JSCameraConstraints { + self.build() + } +} + +impl JSCameraConstraintsBuilder { + /// Builds the [`JSCameraConstraints`] + #[allow(clippy::too_many_lines)] + #[must_use] + pub fn build(self) -> JSCameraConstraints { + let null_resolution = Resolution::default(); + let null_string = String::new(); + + let mut video_object = Object::new(); + + // width + if self.resolution_exact { + if self.preferred_resolution != null_resolution { + video_object = obj!( + video_object, + ("width", obj!(("exact", self.preferred_resolution.width()))) + ); + } + } else { + let mut width_object = Object::new(); + + if let Some(min_res) = self.min_resolution { + width_object = obj!(width_object, ("min", min_res.width())); + } + + width_object = obj!(width_object, ("ideal", self.preferred_resolution.width())); + if let Some(max_res) = self.max_resolution { + width_object = obj!(width_object, ("max", max_res.width())); + } + + video_object = obj!(video_object, ("width", width_object)); + } + + // height + if self.resolution_exact { + if self.preferred_resolution != null_resolution { + video_object = obj!( + video_object, + ( + "height", + obj!(("exact", self.preferred_resolution.height())) + ) + ); + } + } else { + let mut height_object = Object::new(); + + if let Some(min_res) = self.min_resolution { + height_object = obj!(height_object, ("min", min_res.height())); + } + + height_object = obj!(height_object, ("ideal", self.preferred_resolution.height())); + if let Some(max_res) = self.max_resolution { + height_object = obj!(height_object, ("max", max_res.height())); + } + + video_object = obj!(video_object, ("height", height_object)); + } + + // aspect ratio + if self.aspect_ratio_exact { + if self.aspect_ratio != 0_f64 { + video_object = obj!( + video_object, + ("aspectRatio", obj!(("exact", self.aspect_ratio))) + ); + } + } else { + let mut aspect_ratio_object = Object::new(); + + if let Some(min_ratio) = self.min_aspect_ratio { + aspect_ratio_object = obj!(aspect_ratio_object, ("min", min_ratio)); + } + + aspect_ratio_object = obj!(aspect_ratio_object, ("ideal", self.aspect_ratio)); + if let Some(max_ratio) = self.max_aspect_ratio { + aspect_ratio_object = obj!(aspect_ratio_object, ("max", max_ratio)); + } + + video_object = obj!(video_object, ("aspectRatio", aspect_ratio_object)); + } + + if self.facing_mode != JSCameraFacingMode::Any && self.facing_mode_exact { + video_object = obj!( + video_object, + ("facingMode", obj!(("exact", self.facing_mode.to_string()))) + ); + } else if self.facing_mode != JSCameraFacingMode::Any { + video_object = obj!( + video_object, + ("facingMode", obj!(("ideal", self.facing_mode.to_string()))) + ); + } + + // aspect ratio + if self.frame_rate_exact { + if self.frame_rate != 0 { + video_object = obj!( + video_object, + ("frameRate", obj!(("exact", self.frame_rate))) + ); + } + } else { + let mut frame_rate_object = Object::new(); + + if let Some(min_frame_rate) = self.min_frame_rate { + frame_rate_object = obj!(frame_rate_object, ("min", min_frame_rate)); + } + + frame_rate_object = obj!(frame_rate_object, ("ideal", self.frame_rate)); + if let Some(max_frame_rate) = self.max_frame_rate { + frame_rate_object = obj!(frame_rate_object, ("max", max_frame_rate)); + } + + video_object = obj!(video_object, ("frameRate", frame_rate_object)); + } + + if self.resize_mode != JSCameraResizeMode::Any && self.resize_mode_exact { + video_object = obj!( + video_object, + ("resizeMode", obj!(("exact", self.resize_mode.to_string()))) + ); + } else if self.resize_mode != JSCameraResizeMode::Any { + video_object = obj!( + video_object, + ("resizeMode", obj!(("ideal", self.resize_mode.to_string()))) + ); + } + + if self.device_id != null_string && self.device_id_exact { + video_object = obj!(video_object, ("deviceId", obj!(("exact", &self.device_id)))); + } else if self.device_id != null_string { + video_object = obj!(video_object, ("deviceId", obj!(("ideal", &self.device_id)))); + } + + if self.group_id != null_string && self.group_id_exact { + video_object = obj!(video_object, ("groupId", obj!(("exact", &self.group_id)))); + } else if self.group_id != null_string { + video_object = obj!(video_object, ("groupId", obj!(("ideal", &self.group_id)))); + } + + let media_stream_constraints = MediaStreamConstraints::new() + .audio(&jsv!(false)) + .video(&jsv!(video_object)) + .clone(); + + JSCameraConstraints { + media_constraints: media_stream_constraints, + min_resolution: self.min_resolution, + preferred_resolution: self.preferred_resolution, + max_resolution: self.max_resolution, + resolution_exact: self.resolution_exact, + min_aspect_ratio: self.min_aspect_ratio, + aspect_ratio: self.aspect_ratio, + max_aspect_ratio: self.max_aspect_ratio, + aspect_ratio_exact: self.aspect_ratio_exact, + facing_mode: self.facing_mode, + facing_mode_exact: self.facing_mode_exact, + min_frame_rate: self.min_frame_rate, + frame_rate: self.frame_rate, + max_frame_rate: self.max_frame_rate, + frame_rate_exact: self.frame_rate_exact, + resize_mode: self.resize_mode, + resize_mode_exact: self.resize_mode_exact, + device_id: self.device_id, + device_id_exact: self.device_id_exact, + group_id: self.group_id, + group_id_exact: self.device_id_exact, + } + } +} + +impl Default for JSCameraConstraintsBuilder { + fn default() -> Self { + JSCameraConstraintsBuilder { + min_resolution: Some(Resolution::new(480, 234)), + preferred_resolution: Resolution::new(640, 360), + max_resolution: Some(Resolution::new(1920, 1080)), + resolution_exact: false, + min_aspect_ratio: Some(1_f64), + aspect_ratio: 1.777_777_777_78_f64, + max_aspect_ratio: Some(2_f64), + aspect_ratio_exact: false, + facing_mode: JSCameraFacingMode::Any, + facing_mode_exact: false, + min_frame_rate: Some(10), + frame_rate: 15, + max_frame_rate: Some(30), + frame_rate_exact: false, + resize_mode: JSCameraResizeMode::Any, + resize_mode_exact: false, + device_id: String::new(), + device_id_exact: false, + group_id: String::new(), + group_id_exact: false, + } + } +} + +/// Constraints to create a [`JSCamera`] +/// +/// If you want more options, see [`JSCameraConstraintsBuilder`] +/// # JS-WASM +/// This is exported as `CameraConstraints`. +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = CameraConstraints))] +#[derive(Clone, Debug)] +#[allow(clippy::struct_excessive_bools)] +pub struct JSCameraConstraints { + pub(crate) media_constraints: MediaStreamConstraints, + pub(crate) min_resolution: Option, + pub(crate) preferred_resolution: Resolution, + pub(crate) max_resolution: Option, + pub(crate) resolution_exact: bool, + pub(crate) min_aspect_ratio: Option, + pub(crate) aspect_ratio: f64, + pub(crate) max_aspect_ratio: Option, + pub(crate) aspect_ratio_exact: bool, + pub(crate) facing_mode: JSCameraFacingMode, + pub(crate) facing_mode_exact: bool, + pub(crate) min_frame_rate: Option, + pub(crate) frame_rate: u32, + pub(crate) max_frame_rate: Option, + pub(crate) frame_rate_exact: bool, + pub(crate) resize_mode: JSCameraResizeMode, + pub(crate) resize_mode_exact: bool, + pub(crate) device_id: String, + pub(crate) device_id_exact: bool, + pub(crate) group_id: String, + pub(crate) group_id_exact: bool, +} + +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_class = CameraConstraints))] +impl JSCameraConstraints { + /// Gets the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) + /// # JS-WASM + /// This is exported as `get_MediaStreamConstraints`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MediaStreamConstraints) + )] + pub fn media_constraints(&self) -> MediaStreamConstraints { + self.media_constraints.clone() + } + + /// Gets the minimum [`Resolution`]. + /// # JS-WASM + /// This is exported as `get_MinResolution`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MinResolution) + )] + #[must_use] + pub fn min_resolution(&self) -> Option { + self.min_resolution + } + + /// Gets the minimum [`Resolution`]. + /// # JS-WASM + /// This is exported as `set_MinResolution`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MinResolution) + )] + pub fn set_min_resolution(&mut self, min_resolution: Resolution) { + self.min_resolution = Some(min_resolution); + } + + /// Gets the internal [`Resolution`] + /// # JS-WASM + /// This is exported as `get_Resolution`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = Resolution) + )] + pub fn resolution(&self) -> Resolution { + self.preferred_resolution + } + + /// Sets the internal [`Resolution`] + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_Resolution`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = Resolution) + )] + pub fn set_resolution(&mut self, preferred_resolution: Resolution) { + self.preferred_resolution = preferred_resolution; + } + + /// Gets the maximum [`Resolution`]. + /// # JS-WASM + /// This is exported as `get_MaxResolution`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MaxResolution) + )] + #[must_use] + pub fn max_resolution(&self) -> Option { + self.max_resolution + } + + /// Gets the maximum [`Resolution`]. + /// # JS-WASM + /// This is exported as `set_MaxResolution`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MaxResolution) + )] + pub fn set_max_resolution(&mut self, max_resolution: Resolution) { + self.max_resolution = Some(max_resolution); + } + + /// Gets the internal resolution exact. + /// # JS-WASM + /// This is exported as `get_ResolutionExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = ResolutionExact) + )] + pub fn resolution_exact(&self) -> bool { + self.resolution_exact + } + + /// Sets the internal resolution exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_ResolutionExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = ResolutionExact) + )] + pub fn set_resolution_exact(&mut self, resolution_exact: bool) { + self.resolution_exact = resolution_exact; + } + + /// Gets the minimum aspect ratio of the [`JSCameraConstraints`]. + /// # JS-WASM + /// This is exported as `get_MinAspectRatio`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MinAspectRatio) + )] + pub fn min_aspect_ratio(&self) -> Option { + self.min_aspect_ratio + } + + /// Sets the minimum aspect ratio of the [`JSCameraConstraints`]. + /// # JS-WASM + /// This is exported as `set_MinAspectRatio`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MinAspectRatio) + )] + pub fn set_min_aspect_ratio(&mut self, min_aspect_ratio: f64) { + self.min_aspect_ratio = Some(min_aspect_ratio); + } + + /// Gets the internal aspect ratio. + /// # JS-WASM + /// This is exported as `get_AspectRatio`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = AspectRatio) + )] + pub fn aspect_ratio(&self) -> f64 { + self.aspect_ratio + } + + /// Sets the internal aspect ratio. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_AspectRatio`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = AspectRatio) + )] + pub fn set_aspect_ratio(&mut self, aspect_ratio: f64) { + self.aspect_ratio = aspect_ratio; + } + + /// Gets the maximum aspect ratio. + /// # JS-WASM + /// This is exported as `get_MaxAspectRatio`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MaxAspectRatio) + )] + #[must_use] + pub fn max_aspect_ratio(&self) -> Option { + self.max_aspect_ratio + } + + /// Sets the maximum internal aspect ratio. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_MaxAspectRatio`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MaxAspectRatio) + )] + pub fn set_max_aspect_ratio(&mut self, max_aspect_ratio: f64) { + self.max_aspect_ratio = Some(max_aspect_ratio); + } + + /// Gets the internal aspect ratio exact. + /// # JS-WASM + /// This is exported as `get_AspectRatioExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = AspectRatioExact) + )] + pub fn aspect_ratio_exact(&self) -> bool { + self.aspect_ratio_exact + } + + /// Sets the internal aspect ratio exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_AspectRatioExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = AspectRatioExact) + )] + pub fn set_aspect_ratio_exact(&mut self, aspect_ratio_exact: bool) { + self.aspect_ratio_exact = aspect_ratio_exact; + } + + /// Gets the internal [`JSCameraFacingMode`]. + /// # JS-WASM + /// This is exported as `get_FacingMode`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = FacingMode) + )] + pub fn facing_mode(&self) -> JSCameraFacingMode { + self.facing_mode + } + + /// Sets the internal [`JSCameraFacingMode`] + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_FacingMode`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = FacingMode) + )] + pub fn set_facing_mode(&mut self, facing_mode: JSCameraFacingMode) { + self.facing_mode = facing_mode; + } + + /// Gets the internal facing mode exact. + /// # JS-WASM + /// This is exported as `get_FacingModeExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = FacingModeExact) + )] + pub fn facing_mode_exact(&self) -> bool { + self.facing_mode_exact + } + + /// Sets the internal facing mode exact + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_FacingModeExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = FacingModeExact) + )] + pub fn set_facing_mode_exact(&mut self, facing_mode_exact: bool) { + self.facing_mode_exact = facing_mode_exact; + } + + /// Gets the minimum internal frame rate. + /// # JS-WASM + /// This is exported as `get_MinFrameRate`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MinFrameRate) + )] + #[must_use] + pub fn min_frame_rate(&self) -> Option { + self.min_frame_rate + } + + /// Sets the minimum internal frame rate + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_MinFrameRate`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MinFrameRate) + )] + pub fn set_min_frame_rate(&mut self, min_frame_rate: u32) { + self.min_frame_rate = Some(min_frame_rate); + } + + /// Gets the internal frame rate. + /// # JS-WASM + /// This is exported as `get_FrameRate`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = FrameRate) + )] + pub fn frame_rate(&self) -> u32 { + self.frame_rate + } + + /// Sets the internal frame rate + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_FrameRate`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = FrameRate) + )] + pub fn set_frame_rate(&mut self, frame_rate: u32) { + self.frame_rate = frame_rate; + } + + /// Gets the maximum internal frame rate. + /// # JS-WASM + /// This is exported as `get_MaxFrameRate`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = MaxFrameRate) + )] + #[must_use] + pub fn max_frame_rate(&self) -> Option { + self.max_frame_rate + } + + /// Sets the maximum internal frame rate + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_MaxFrameRate`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = MaxFrameRate) + )] + pub fn set_max_frame_rate(&mut self, max_frame_rate: u32) { + self.max_frame_rate = Some(max_frame_rate); + } + + /// Gets the internal frame rate exact. + /// # JS-WASM + /// This is exported as `get_FrameRateExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = FrameRateExact) + )] + pub fn frame_rate_exact(&self) -> bool { + self.frame_rate_exact + } + + /// Sets the internal frame rate exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_FrameRateExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = FrameRateExact) + )] + pub fn set_frame_rate_exact(&mut self, frame_rate_exact: bool) { + self.frame_rate_exact = frame_rate_exact; + } + + /// Gets the internal [`JSCameraResizeMode`]. + /// # JS-WASM + /// This is exported as `get_ResizeMode`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = ResizeMode) + )] + pub fn resize_mode(&self) -> JSCameraResizeMode { + self.resize_mode + } + + /// Sets the internal [`JSCameraResizeMode`] + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_ResizeMode`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = ResizeMode) + )] + pub fn set_resize_mode(&mut self, resize_mode: JSCameraResizeMode) { + self.resize_mode = resize_mode; + } + + /// Gets the internal resize mode exact. + /// # JS-WASM + /// This is exported as `get_ResizeModeExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = ResizeModeExact) + )] + pub fn resize_mode_exact(&self) -> bool { + self.resize_mode_exact + } + + /// Sets the internal resize mode exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_ResizeModeExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = ResizeModeExact) + )] + pub fn set_resize_mode_exact(&mut self, resize_mode_exact: bool) { + self.resize_mode_exact = resize_mode_exact; + } + + /// Gets the internal device id. + /// # JS-WASM + /// This is exported as `get_DeviceId`. + #[must_use] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(getter = DeviceId))] + pub fn device_id(&self) -> String { + self.device_id.to_string() + } + + /// Sets the internal device ID. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_DeviceId`. + #[cfg_attr(feature = "output-wasm", wasm_bindgen(setter = DeviceId))] + pub fn set_device_id(&mut self, device_id: String) { + self.device_id = device_id; + } + + /// Gets the internal device id exact. + /// # JS-WASM + /// This is exported as `get_DeviceIdExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = DeviceIdExact) + )] + pub fn device_id_exact(&self) -> bool { + self.device_id_exact + } + + /// Sets the internal device ID exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_DeviceIdExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = DeviceIdExact) + )] + pub fn set_device_id_exact(&mut self, device_id_exact: bool) { + self.device_id_exact = device_id_exact; + } + + /// Gets the internal group id. + /// # JS-WASM + /// This is exported as `get_GroupId`. + #[must_use] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(getter = GroupId))] + pub fn group_id(&self) -> String { + self.group_id.to_string() + } + + /// Sets the internal group ID. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_GroupId`. + #[cfg_attr(feature = "output-wasm", wasm_bindgen(setter = GroupId))] + pub fn set_group_id(&mut self, group_id: String) { + self.group_id = group_id; + } + + /// Gets the internal group id exact. + /// # JS-WASM + /// This is exported as `get_GroupIdExact`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = GroupIdExact) + )] + pub fn group_id_exact(&self) -> bool { + self.group_id_exact + } + + /// Sets the internal group ID exact. + /// Note that this doesn't affect the internal [`MediaStreamConstraints`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStreamConstraints.html) until you call + /// [`apply_constraints()`](crate::js_camera::JSCameraConstraints::apply_constraints) + /// # JS-WASM + /// This is exported as `set_GroupIdExact`. + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = GroupIdExact) + )] + pub fn set_group_id_exact(&mut self, group_id_exact: bool) { + self.group_id_exact = group_id_exact; + } + + /// Applies any modified constraints. + /// # JS-WASM + /// This is exported as `applyConstraints`. + #[cfg(feature = "output-wasm")] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = applyConstraints))] + pub fn js_apply_constraints(&mut self) { + self.apply_constraints(); + } +} + +impl JSCameraConstraints { + /// Applies any modified constraints. + pub fn apply_constraints(&mut self) { + let new_constraints = JSCameraConstraintsBuilder { + min_resolution: self.min_resolution(), + preferred_resolution: self.resolution(), + max_resolution: self.max_resolution(), + resolution_exact: self.resolution_exact(), + min_aspect_ratio: self.min_aspect_ratio(), + aspect_ratio: self.aspect_ratio(), + max_aspect_ratio: self.max_aspect_ratio(), + aspect_ratio_exact: self.aspect_ratio_exact(), + facing_mode: self.facing_mode(), + facing_mode_exact: self.facing_mode_exact(), + min_frame_rate: self.min_frame_rate(), + frame_rate: self.frame_rate(), + max_frame_rate: self.max_frame_rate(), + frame_rate_exact: self.frame_rate_exact(), + resize_mode: self.resize_mode(), + resize_mode_exact: self.resize_mode_exact(), + device_id: self.device_id(), + device_id_exact: self.device_id_exact(), + group_id: self.group_id(), + group_id_exact: self.group_id_exact(), + } + .build(); + + self.media_constraints = new_constraints.media_constraints; + } +} + +impl Deref for JSCameraConstraints { + type Target = MediaStreamConstraints; + + fn deref(&self) -> &Self::Target { + &self.media_constraints + } +} + +/// A wrapper around a [`MediaStream`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MediaStream.html) +/// # JS-WASM +/// This is exported as `NokhwaCamera`. +#[cfg(feature = "input-jscam")] +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_name = NokhwaCamera))] +#[cfg_attr(feature = "docs-features", doc(cfg(feature = "input-jscam")))] +pub struct JSCamera { + media_stream: MediaStream, + constraints: JSCameraConstraints, + attached: bool, + attached_node: Option, + measured_resolution: Resolution, + attached_canvas: Option, + canvas_context: Option, +} + +#[cfg(feature = "input-jscam")] +#[cfg_attr(feature = "output-wasm", wasm_bindgen(js_class = NokhwaCamera))] +impl JSCamera { + /// Creates a new [`JSCamera`] using [`JSCameraConstraints`]. + /// + /// # Errors + /// This may error if permission is not granted, or the constraints are invalid. + /// # JS-WASM + /// This is the constructor for `NokhwaCamera`. It returns a promise and may throw an error. + #[cfg(feature = "output-wasm")] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(constructor))] + pub async fn js_new(constraints: JSCameraConstraints) -> Result { + match JSCamera::new(constraints).await { + Ok(camera) => Ok(camera), + Err(why) => Err(JsValue::from(why.to_string())), + } + } + + /// Gets the internal [`JSCameraConstraints`]. + /// Most likely, you will edit this value by taking ownership of it, then feed it back into [`set_constraints`](crate::js_camera::JSCamera::set_constraints). + /// # JS-WASM + /// This is exported as `get_Constraints`. + #[must_use] + #[cfg_attr(feature = "output-wasm", wasm_bindgen(getter = Constraints))] + pub fn constraints(&self) -> JSCameraConstraints { + self.constraints.clone() + } + + /// Sets the [`JSCameraConstraints`]. This calls [`apply_constraints`](crate::js_camera::JSCamera::apply_constraints) internally. + /// + /// # Errors + /// See [`apply_constraints`](crate::js_camera::JSCamera::apply_constraints). + /// # JS-WASM + /// This is exported as `set_Constraints`. It may throw an error. + #[cfg(feature = "output-wasm")] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(setter = Constraints) + )] + pub fn js_set_constraints(&mut self, constraints: JSCameraConstraints) -> Result<(), JsValue> { + match self.set_constraints(constraints) { + Ok(_) => Ok(()), + Err(why) => Err(JsValue::from(why.to_string())), + } + } + + /// Gets the internal [`Resolution`]. + /// + /// Note: This value is only updated after you call [`measure_resolution`](crate::js_camera::JSCamera::measure_resolution) + /// # JS-WASM + /// This is exported as `get_Resolution`. + #[must_use] + #[cfg_attr( + feature = "output-wasm", + wasm_bindgen(getter = Resolution) + )] + pub fn resolution(&self) -> Resolution { + self.measured_resolution + } + + /// Measures the [`Resolution`] of the internal stream. You usually do not need to call this. + /// + /// # Errors + /// If the camera fails to attach to the created `