diff --git a/.github/workflows/dcs-windows-release.yml b/.github/workflows/dcs-windows-release.yml new file mode 100644 index 00000000000..1873368c1e6 --- /dev/null +++ b/.github/workflows/dcs-windows-release.yml @@ -0,0 +1,109 @@ +# DCS Norway Windows release — tag builds publish portable installer to GitHub Releases. +# Clients check Chesster1981/RustDesk releases (not rustdesk.com). +name: DCS Windows Release + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Git tag / asset version (e.g. 1.4.10). Required when not running from a tag ref." + required: false + type: string + default: "" + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+-[0-9]+' + - 'dcs-v[0-9]+.[0-9]+.[0-9]+' + - 'dcs-[0-9]+.[0-9]+.[0-9]+' + - 'dcs-v[0-9]+.[0-9]+.[0-9]+-[0-9]+' + - 'dcs-[0-9]+.[0-9]+.[0-9]+-[0-9]+' + +# Reusable flutter-build.yml jobs (SBOM / softprops release) need contents:write. +permissions: + contents: write + actions: read + +jobs: + resolve-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + steps: + - id: ver + shell: bash + env: + INPUT_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + TAG="${INPUT_TAG:-}" + if [ -z "$TAG" ]; then + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG="${GITHUB_REF_NAME}" + else + echo "::error::Set workflow_dispatch input release_tag (e.g. 1.4.10), or push a version tag." + exit 1 + fi + fi + # Asset filenames must use the same string as the release tag path segment + # (updater builds rustdesk-{tag}-x86_64.exe from the release page URL). + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${TAG}" >> "$GITHUB_OUTPUT" + echo "Release tag/version=${TAG}" + + run-flutter-tag-build: + needs: resolve-version + permissions: + contents: write + actions: write + id-token: write + uses: ./.github/workflows/flutter-build.yml + secrets: inherit + with: + upload-artifact: true + upload-tag: ${{ needs.resolve-version.outputs.tag }} + + publish-dcs-alias: + name: Publish DCS-named installer alias + if: ${{ always() && needs.resolve-version.result == 'success' }} + needs: [resolve-version, run-flutter-tag-build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download, alias, and publish + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve-version.outputs.tag }} + VER: ${{ needs.resolve-version.outputs.version }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + DISPLAY="${VER}" + DISPLAY="${DISPLAY#dcs-}" + DISPLAY="${DISPLAY#v}" + mkdir -p ./out + + alias_asset() { + local src="$1" dest="$2" required="${3:-0}" + if gh release download "$TAG" --repo "$REPO" --pattern "$src" --dir ./out; then + cp "./out/$src" "./out/$dest" + gh release upload "$TAG" "./out/$dest" --repo "$REPO" --clobber + # Keep rustdesk-* names so Windows, Linux, and Android in-app updaters share one asset scheme. + elif [[ "$required" == "1" ]]; then + echo "::error::missing required asset $src" + exit 1 + else + echo "skip missing $src" + fi + } + + alias_asset "rustdesk-${VER}-x86_64.exe" "DCS-Norway-RDC-${DISPLAY}-x86_64-install.exe" 1 + alias_asset "rustdesk-${VER}-aarch64.apk" "DCS-Norway-RDC-${DISPLAY}-aarch64.apk" + alias_asset "rustdesk-${VER}-x86_64.deb" "DCS-Norway-RDC-${DISPLAY}-x86_64.deb" + ls -la ./out + gh release edit "$TAG" --repo "$REPO" --prerelease=false diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 4f1dbb1c957..7b02bb20801 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -44,7 +44,7 @@ env: # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version - VERSION: "1.4.9" + VERSION: "1.4.15" NDK_VERSION: "r28c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" @@ -558,7 +558,8 @@ jobs: ./SignOutput/rustdesk-*.exe build-rustdesk-ios: - if: ${{ inputs.upload-artifact }} + # Unsigned IPA cannot be installed by DCS operators. + if: false name: build rustdesk ios ipa runs-on: ${{ matrix.job.os }} needs: [generate-bridge] @@ -680,6 +681,8 @@ jobs: build-for-macOS: + # DCS ships Windows, Linux, and Android only. + if: false name: ${{ matrix.job.target }} runs-on: ${{ matrix.job.os }} needs: [generate-bridge] @@ -905,7 +908,8 @@ jobs: - build-for-windows-flutter - build-for-windows-sciter runs-on: ubuntu-latest - if: ${{ inputs.upload-artifact }} + # Unsigned macOS+Windows bundle is not a DCS product. + if: false steps: - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -1219,7 +1223,7 @@ jobs: path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}} - name: Publish signed apk package - if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' + if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' && matrix.job.arch != 'x86_64' uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: prerelease: true @@ -1228,7 +1232,7 @@ jobs: ${{steps.sign-rustdesk.outputs.signedReleaseFile}} - name: Publish unsigned apk package - if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true' + if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true' && matrix.job.arch != 'x86_64' uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: prerelease: true @@ -2048,16 +2052,6 @@ jobs: vcpkg-triplet: x64-linux, extra_features: ",hwcodec,unix-file-copy-paste", } - - { - arch: armv7, - target: armv7-unknown-linux-gnueabihf, - on: ubuntu-22.04-arm, - distro: ubuntu18.04-rustdesk, - deb_arch: armhf, - sciter_arch: arm32, - vcpkg-triplet: arm-linux, - extra_features: ",unix-file-copy-paste", - } steps: - name: Export GitHub Actions cache environment variables uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index a64dd11972a..25171695020 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -19,6 +19,12 @@ on: jobs: run-ci: + # generate-sbom in flutter-build.yml requests contents:write; callers default to read. + permissions: + contents: write + actions: write + id-token: write uses: ./.github/workflows/flutter-build.yml + secrets: inherit with: upload-artifact: false diff --git a/.github/workflows/flutter-tag.yml b/.github/workflows/flutter-tag.yml index bf39db5ccee..e46fa98e0bb 100644 --- a/.github/workflows/flutter-tag.yml +++ b/.github/workflows/flutter-tag.yml @@ -2,12 +2,6 @@ name: Flutter Tag Build on: workflow_dispatch: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+-[0-9]+' jobs: run-flutter-tag-build: 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 93e1a683712..db7e752eb96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7259,7 +7259,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.9" +version = "1.4.15" dependencies = [ "android-wakelock", "android_logger", @@ -7371,7 +7371,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.4.15" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 2fac88c0057..de97619c993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.9" +version = "1.4.15" authors = ["rustdesk "] edition = "2021" build= "build.rs" diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index bad4e84db94..93c123cd075 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -16,7 +16,7 @@ AppDir: path: ./AppDir app_info: id: rustdesk - name: rustdesk + name: DCS Norway icon: rustdesk version: 1.4.9 exec: usr/share/rustdesk/rustdesk diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 7cd52b89a5b..9802eef028a 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -16,7 +16,7 @@ AppDir: path: ./AppDir app_info: id: rustdesk - name: rustdesk + name: DCS Norway icon: rustdesk version: 1.4.9 exec: usr/share/rustdesk/rustdesk diff --git a/build.py b/build.py index b32e95672f8..829006ee3c3 100755 --- a/build.py +++ b/build.py @@ -366,7 +366,7 @@ def generate_control_file(version): Homepage: https://rustdesk.com Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s Recommends: libayatana-appindicator3-1 -Description: A remote control software. +Description: DCS Norway Remote Desktop Client. """ % (version, get_deb_arch(), get_deb_extra_depends()) file = open(control_file_path, "w") diff --git a/dcs-norway-x86_64-debug.apk b/dcs-norway-x86_64-debug.apk new file mode 100644 index 00000000000..1a8149ae790 Binary files /dev/null and b/dcs-norway-x86_64-debug.apk differ diff --git a/docs/DCS-RELEASE.md b/docs/DCS-RELEASE.md new file mode 100644 index 00000000000..5b7907ca471 --- /dev/null +++ b/docs/DCS-RELEASE.md @@ -0,0 +1,91 @@ +# DCS Norway Remote Desktop Client — releases & updates + +Operators get updates from **GitHub Releases** (pre-built installers), not by compiling source on their PCs. + +Windows, Linux, macOS (Apple Silicon and Intel), and Android share the same DCS client: +branding, Available devices only (no Recent / Favorites / Discovered / Address book / Remote ID), +and the DCS About page. iOS is not published (unsigned IPA would require sideloading). + +## Publish a new build + +1. Bump the version in source so clients detect “newer”: + - [`Cargo.toml`](../Cargo.toml) / [`libs/portable/Cargo.toml`](../libs/portable/Cargo.toml) + - Commit the updated [`Cargo.lock`](../Cargo.lock) (CI uses `cargo build --locked`) + - Default `VERSION` in [`.github/workflows/flutter-build.yml`](../.github/workflows/flutter-build.yml) (must match the release tag for asset names) + - [`flutter/pubspec.yaml`](../flutter/pubspec.yaml) version (optional consistency) +2. Commit and push to the branding branch. +3. Create and push a **tag** matching the version string used in asset names, e.g.: + - `1.4.10` + - `1.4.9-2` + Prefer tags **without** a leading `v` so updater filenames stay simple (`rustdesk-1.4.10-x86_64.exe`). +4. Start the Windows build: + - **Preferred while the workflow lives only on the branding branch:** + Actions → **DCS Windows Release** → Run workflow (branch = branding branch) → set `release_tag` to `1.4.10`. + Or: `gh workflow run dcs-windows-release.yml --ref -f release_tag=1.4.10` + - **Automatic on tag push** works only after this workflow file exists on the repo **default** branch (GitHub limitation). +5. Wait for workflow **DCS Windows Release**. It calls the full Flutter build reusable workflow; ensure `VERSION` in `.github/workflows/flutter-build.yml` matches the tag (asset name `rustdesk-{tag}-x86_64.exe`). +6. On the GitHub Release for that tag, confirm assets: + - `rustdesk-{tag}-x86_64.exe` — used by in-app updater + - `DCS-Norway-RDC-{version}-x86_64-install.exe` — human-friendly Windows alias + - `DCS-Norway-RDC-{version}-aarch64.apk` — Android phones / tablets + - `DCS-Norway-RDC-{version}-x86_64.deb` — Linux (when built) + - `DCS-Norway-RDC-{version}-x86_64.dmg` — macOS Intel (when built) + - `DCS-Norway-RDC-{version}-aarch64.dmg` — macOS Apple Silicon (when built) + Release is marked **non-prerelease** so clients can find it. + +Linux and both macOS architectures use the same Flutter desktop client as Windows (RdClient home, +Available devices, DCS About). + +### Android (DCS-branded APK) + +Website / GitHub Releases file for phones and tablets: + +- `DCS-Norway-RDC-{version}-aarch64.apk` + +Upload via the release page, or after a local/CI build: + +```bash +gh release upload 1.4.10 ./DCS-Norway-RDC-1.4.10-aarch64.apk --clobber +``` + +Do not publish `*-x86_64.apk`. That ABI is for PC Android emulators, not operator phones. + +### Linux (.deb) + +Build the Flutter Linux client from this branding branch, then attach: + +- `DCS-Norway-RDC-{version}-x86_64.deb` +- `DCS-Norway-RDC-{version}-aarch64.deb` (when built) + +The `.desktop` launcher name is **DCS Norway**. The on-disk binary stays `rustdesk` so packaging +paths remain compatible. + +### macOS (.dmg) — Apple Silicon and Intel + +Both architectures share `flutter/macos/` (display name **DCS Norway**, DCS app icon). Attach: + +- `DCS-Norway-RDC-{version}-aarch64.dmg` — Apple Silicon +- `DCS-Norway-RDC-{version}-x86_64.dmg` — Intel + +The `.app` product name stays `RustDesk.app` for signing/CI; Finder and the Dock show **DCS Norway**. + +Manual run (same as step 4): Actions → **DCS Windows Release** → Run workflow. + +## How clients update + +- On startup (if **Check for software update on startup** is enabled — default on): queries + `https://api.github.com/repos/Chesster1981/RustDesk/releases` +- Settings → **About** → **Check for updates** +- A newer tag is offered when it has a DCS client asset (`.exe`, `.deb`, `.dmg`, `.apk`, …). + Windows can download + `--update` install; Linux / macOS / Android open the GitHub Release page. + +## Local test (no tag) + +Build and run Release as usual; packing an installer is optional until you want to ship. + +## Notes + +- Compile-from-source on operator machines is **not** supported. +- Windows x64 is the primary automated release target; Linux / macOS / Android use the same branded + source and are packaged from this branch. +- Code signing uses existing fork secrets when configured; unsigned builds are fine for internal DCS use. diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4cfb..eeeecb3aa6c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + @@ -25,7 +26,7 @@ @@ -46,7 +47,7 @@ android:name=".InputService" android:enabled="true" android:exported="false" - android:label="RustDesk Input" + android:label="DCS Norway Input" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> @@ -101,6 +102,16 @@ + + + + diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/BootReceiver.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/BootReceiver.kt index 71bbba754e6..24a481df602 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/BootReceiver.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/BootReceiver.kt @@ -36,7 +36,7 @@ class BootReceiver : BroadcastReceiver() { action = ACT_INIT_MEDIA_PROJECTION_AND_SERVICE putExtra(EXT_INIT_FROM_BOOT, true) } - Toast.makeText(context, "RustDesk is Open", Toast.LENGTH_LONG).show() + Toast.makeText(context, "DCS Norway is Open", Toast.LENGTH_LONG).show() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(it) } else { diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt index 6dd4a2f6122..67d484765fe 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/FloatingWindowService.kt @@ -302,8 +302,8 @@ class FloatingWindowService : Service(), View.OnTouchListener { private fun showPopupMenu() { val popupMenu = PopupMenu(this, floatingView) - val idShowRustDesk = 0 - popupMenu.menu.add(0, idShowRustDesk, 0, translate("Show RustDesk")) + val idShowApp = 0 + popupMenu.menu.add(0, idShowApp, 0, translate("Show DCS Norway")) // For host side, clipboard sync val idSyncClipboard = 1 val isServiceSyncEnabled = (MainActivity.rdClipboardManager?.isCaptureStarted ?: false) && FFI.isServiceClipboardEnabled() @@ -317,7 +317,7 @@ class FloatingWindowService : Service(), View.OnTouchListener { } popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { - idShowRustDesk -> { + idShowApp -> { openMainActivity() true } diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 7274085fd3f..c776267b39c 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -14,11 +14,15 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.ClipboardManager +import android.net.Uri import android.os.Bundle import android.os.Build import android.os.IBinder +import android.provider.Settings import android.util.Log import android.view.WindowManager +import androidx.core.content.FileProvider +import java.io.File import android.media.MediaCodecInfo import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar @@ -284,6 +288,13 @@ class MainActivity : FlutterActivity() { "on_voice_call_closed" -> { onVoiceCallClosed() } + "install_update" -> { + if (call.arguments is String) { + result.success(installUpdate(call.arguments as String)) + } else { + result.success(false) + } + } else -> { result.error("-1", "No such method", null) } @@ -291,6 +302,32 @@ class MainActivity : FlutterActivity() { } } + private fun installUpdate(path: String): Boolean { + val file = File(path) + if (!file.exists()) { + Log.e(logTag, "Update package missing: $path") + return false + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !packageManager.canRequestPackageInstalls() + ) { + startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:$packageName") + ) + ) + return false + } + val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + return true + } + private fun setCodecInfo() { val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) val codecs = codecList.codecInfos diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index b03b63844b4..42512825ae9 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -46,7 +46,7 @@ import java.nio.ByteBuffer import kotlin.math.max import kotlin.math.min -const val DEFAULT_NOTIFY_TITLE = "RustDesk" +const val DEFAULT_NOTIFY_TITLE = "DCS Norway" const val DEFAULT_NOTIFY_TEXT = "Service is running" const val DEFAULT_NOTIFY_ID = 1 const val NOTIFY_ID_OFFSET = 100 @@ -608,13 +608,13 @@ class MainService : Service() { private fun initNotification() { notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationChannel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = "RustDesk" - val channelName = "RustDesk Service" + val channelId = "DCSNorway" + val channelName = "DCS Norway Service" val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_HIGH ).apply { - description = "RustDesk Service Channel" + description = "DCS Norway Service Channel" } channel.lightColor = Color.BLUE channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 116904a84fc..847c5c69f6c 100644 Binary files a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 7c8a2be8deb..f0a437e5ddf 100644 Binary files a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index c8aef7fd583..847c5c69f6c 100644 Binary files a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_stat_logo.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_stat_logo.png index 42e74fe0b05..68fb1419b4f 100644 Binary files a/flutter/android/app/src/main/res/mipmap-hdpi/ic_stat_logo.png and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_stat_logo.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 7dca207d887..fb402ac8ac2 100644 Binary files a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 0faadfca038..091ba070e7b 100644 Binary files a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 246d6ee7adc..fb402ac8ac2 100644 Binary files a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_stat_logo.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_stat_logo.png index d643a4fac77..a3625f3e1b7 100644 Binary files a/flutter/android/app/src/main/res/mipmap-mdpi/ic_stat_logo.png and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_stat_logo.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 33a40ed83a4..6bf6b6ac62f 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index e083cecf1ee..131a4402fa4 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index ab9c356dc5d..6bf6b6ac62f 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_stat_logo.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_stat_logo.png index e02182dd1d6..afa08566f68 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_stat_logo.png and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_stat_logo.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 4585230d3d2..1807e972d15 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index b9975190c88..d6772f73432 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 79eab3d8425..1807e972d15 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_stat_logo.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_stat_logo.png index f7b57b32918..c5eab6d8422 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_stat_logo.png and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_stat_logo.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 463d20eeb7b..ec9c74c5247 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index d781e2595cc..fd9ce356d5b 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 3cae0ce40d7..ec9c74c5247 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_stat_logo.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_stat_logo.png index 9c2153e9ee4..d153ef2762c 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_stat_logo.png and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_stat_logo.png differ diff --git a/flutter/android/app/src/main/res/values/ic_launcher_background.xml b/flutter/android/app/src/main/res/values/ic_launcher_background.xml index ab983282473..762d034d1f4 100644 --- a/flutter/android/app/src/main/res/values/ic_launcher_background.xml +++ b/flutter/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #ffffff + #0A1A3A \ No newline at end of file diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml index 3e058a81b81..e1102c1fe8d 100644 --- a/flutter/android/app/src/main/res/values/strings.xml +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - RustDesk - Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established + DCS Norway + Allow other devices to control your phone using virtual touch, when DCS Norway screen sharing is established diff --git a/flutter/android/app/src/main/res/xml/file_paths.xml b/flutter/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000000..f383a78d73d --- /dev/null +++ b/flutter/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/flutter/assets/dcs_norway_logo.png b/flutter/assets/dcs_norway_logo.png new file mode 100644 index 00000000000..919b566f89a Binary files /dev/null and b/flutter/assets/dcs_norway_logo.png differ diff --git a/flutter/assets/icon.ico b/flutter/assets/icon.ico new file mode 100644 index 00000000000..77a9a803d77 Binary files /dev/null and b/flutter/assets/icon.ico differ diff --git a/flutter/assets/icon.png b/flutter/assets/icon.png new file mode 100644 index 00000000000..892843fd1c2 Binary files /dev/null and b/flutter/assets/icon.png differ diff --git a/flutter/assets/icon.svg b/flutter/assets/icon.svg index 4d43f8bcdd3..52a49fe5fbb 100644 --- a/flutter/assets/icon.svg +++ b/flutter/assets/icon.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/flutter/assets/logo.png b/flutter/assets/logo.png new file mode 100644 index 00000000000..892843fd1c2 Binary files /dev/null and b/flutter/assets/logo.png differ diff --git a/flutter/assets/logo_dark.png b/flutter/assets/logo_dark.png new file mode 100644 index 00000000000..892843fd1c2 Binary files /dev/null and b/flutter/assets/logo_dark.png differ diff --git a/flutter/assets/logo_light.png b/flutter/assets/logo_light.png new file mode 100644 index 00000000000..892843fd1c2 Binary files /dev/null and b/flutter/assets/logo_light.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index fa4ecad0a1c..44331c4905a 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 080f311fc9d..79464024efa 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 8daf5718b31..ed1b4f257bc 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index fa5b04f7f39..eb020080350 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index a4bc6dfd7be..6f7e581e260 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 97831dc8289..982f9529061 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index cfaff2b9bac..559f1b93c4f 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 8daf5718b31..ed1b4f257bc 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 85365e1852c..b4340949c04 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index bafaf7e0754..e23a70b6a7a 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index bafaf7e0754..e23a70b6a7a 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e5755e5fb50..afdb56ab516 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index b60a754dfd3..b41be193ca0 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 0ddd120e62d..2d3f83e3825 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 22628c5362a..61b4db684e8 100644 Binary files a/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index 9351dac53a3..4856f15be86 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - RustDesk + DCS Norway CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - RustDesk + DCS Norway CFBundlePackageType APPL CFBundleShortVersionString @@ -62,6 +62,11 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIDeviceFamily + + 1 + 2 + UISupportsDocumentBrowser UIViewControllerBasedStatusBarAppearance diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 94c3c2a72b8..be0774803e1 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -38,6 +38,7 @@ import 'desktop/pages/remote_page.dart' as desktop_remote; import 'desktop/pages/file_manager_page.dart' as desktop_file_manager; import 'desktop/pages/view_camera_page.dart' as desktop_view_camera; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; +import 'package:flutter_hbb/desktop/widgets/update_progress.dart'; import 'models/model.dart'; import 'models/platform_model.dart'; @@ -65,6 +66,9 @@ var isMobile = isAndroid || isIOS; var version = ''; int androidVersion = 0; +bool isTablet(BuildContext context) => + MediaQuery.sizeOf(context).shortestSide >= 600; + // Only used on Linux. // `windowManager.setResizable(false)` will reset the window size to the default size on Linux. // https://stackoverflow.com/questions/8193613/gtk-window-resize-disable-without-going-back-to-default @@ -183,10 +187,10 @@ class ColorThemeExtension extends ThemeExtension { ); static final dark = ColorThemeExtension( - border: Color(0xFF555555), - border2: Color(0xFFE5E5E5), + border: Color(0xFF30363D), + border2: Color(0xFFE6EDF3), border3: Colors.white24, - highlight: Color(0xFF3F3F3F), + highlight: Color(0xFF21262D), drag_indicator: Colors.grey, shadow: Colors.grey, errorBannerBg: Color(0xFF470F2D), @@ -473,9 +477,9 @@ class MyTheme { static ThemeData darkTheme = ThemeData( useMaterial3: false, brightness: Brightness.dark, - hoverColor: Color.fromARGB(255, 45, 46, 53), - scaffoldBackgroundColor: Color(0xFF18191E), - dialogBackgroundColor: Color(0xFF18191E), + hoverColor: Color(0xFF21262D), + scaffoldBackgroundColor: Color(0xFF0D1117), + dialogBackgroundColor: Color(0xFF0D1117), appBarTheme: AppBarTheme( shadowColor: Colors.transparent, ), @@ -485,14 +489,14 @@ class MyTheme { borderRadius: BorderRadius.circular(18.0), side: BorderSide( width: 1, - color: Color(0xFF24252B), + color: Color(0xFF30363D), ), ), ), scrollbarTheme: scrollbarThemeDark, inputDecorationTheme: (isDesktop || isWebDesktop) ? InputDecorationTheme( - fillColor: Color(0xFF24252B), + fillColor: Color(0xFF161B22), filled: true, isDense: true, border: OutlineInputBorder( @@ -511,7 +515,7 @@ class MyTheme { color: accent80, ), ), - cardColor: Color(0xFF24252B), + cardColor: Color(0xFF161B22), visualDensity: VisualDensity.adaptivePlatformDensity, tabBarTheme: const TabBarTheme( labelColor: Colors.white70, @@ -562,9 +566,9 @@ class MyTheme { style: MenuStyle( backgroundColor: MaterialStatePropertyAll(Color(0xFF121212)))), colorScheme: ColorScheme.dark( - primary: Colors.blue, + primary: Color(0xFF58A6FF), secondary: accent, - background: Color(0xFF24252B), + background: Color(0xFF161B22), ), popupMenuTheme: PopupMenuThemeData( shape: RoundedRectangleBorder( @@ -874,9 +878,11 @@ class OverlayDialogManager { ? Colors.black12 : Colors.black45, child: StatefulBuilder(builder: (context, setState) { + final dialogChild = + builder(setState, close, overlayState.context); return Listener( onPointerUp: (_) => innerClicked = true, - child: builder(setState, close, overlayState.context), + child: dialogChild, ); }))); }); @@ -2607,6 +2613,17 @@ connect(BuildContext context, String id, assert(!(isFileTransfer && isTcpTunneling && isRDP), "more than one connect type"); + // Apply Betterdesk/AB stored password when the caller did not supply one. + if (password == null || password.isEmpty) { + try { + final abPassword = gFFI.abModel.getPasswordForPeerId(id); + if (abPassword != null && abPassword.isNotEmpty) { + password = abPassword; + isSharedPassword = true; + } + } catch (_) {} + } + if (isDesktop) { if (desktopType == DesktopType.main) { await connectMainDesktop( @@ -4080,21 +4097,26 @@ void earlyAssert() { assert('\1' == '1'); } +Timer? _softwareUpdateCheckTimer; + void checkUpdate() { - if (!isWeb) { - if (!bind.isCustomClient()) { - platformFFI.registerEventHandler( - kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, - (Map evt) async { - if (evt['url'] is String) { - stateGlobal.updateUrl.value = evt['url']; - } - }); - Timer(const Duration(seconds: 1), () async { - bind.mainGetSoftwareUpdateUrl(); - }); - } + if (isWeb) { + return; } + platformFFI.registerEventHandler( + kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, + (Map evt) async { + if (evt['url'] is String) { + final url = evt['url'] as String; + stateGlobal.updateUrl.value = url; + promptSoftwareUpdate(url); + } + }, replace: true); + void runCheck() => bind.mainGetSoftwareUpdateUrl(); + Timer(const Duration(seconds: 1), runCheck); + _softwareUpdateCheckTimer?.cancel(); + _softwareUpdateCheckTimer = + Timer.periodic(const Duration(hours: 1), (_) => runCheck()); } // https://github.com/flutter/flutter/issues/153560#issuecomment-2497160535 diff --git a/flutter/lib/common/hbbs/hbbs.dart b/flutter/lib/common/hbbs/hbbs.dart index 0c729e4dfe5..acb7d2b06d8 100644 --- a/flutter/lib/common/hbbs/hbbs.dart +++ b/flutter/lib/common/hbbs/hbbs.dart @@ -82,15 +82,23 @@ class PeerPayload { String user_name = ''; String? device_group_name; String note = ''; + String alias = ''; + // BetterDesk Access Policy password for Accessible devices auto-connect. + String password = ''; PeerPayload.fromJson(Map json) - : id = json['id'] ?? '', + : id = json['id']?.toString() ?? '', info = (json['info'] is Map) ? json['info'] : {}, status = json['status'], user = json['user'] ?? '', user_name = json['user_name'] ?? '', - device_group_name = json['device_group_name'] ?? '', - note = json['note'] ?? ''; + device_group_name = json['device_group_name'], + note = json['note']?.toString() ?? '', + // Betterdesk puts device note / AB alias in `alias`. + alias = (json['alias']?.toString() ?? '').isNotEmpty + ? json['alias'].toString() + : (json['display_name']?.toString() ?? ''), + password = json['password']?.toString() ?? ''; static Peer toPeer(PeerPayload p) { return Peer.fromJson({ @@ -101,6 +109,9 @@ class PeerPayload { "hostname": p.info['device_name'], "device_group_name": p.device_group_name, "note": p.note, + "alias": p.alias.isNotEmpty ? p.alias : p.note, + "display_name": p.alias, + "password": p.password, }); } diff --git a/flutter/lib/common/widgets/address_book.dart b/flutter/lib/common/widgets/address_book.dart index 054a1666c30..5bcef98c034 100644 --- a/flutter/lib/common/widgets/address_book.dart +++ b/flutter/lib/common/widgets/address_book.dart @@ -73,6 +73,22 @@ class _AddressBookState extends State { }); Widget _buildAddressBookLandscape() { + if (kUseRdClientHomeShell) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Row( + children: [ + Expanded(child: _buildAbDropdown()), + _buildAbPermission(), + ], + ), + ), + Expanded(child: _buildPeersViews()), + ], + ); + } return Row( children: [ Offstage( diff --git a/flutter/lib/common/widgets/connection_page_title.dart b/flutter/lib/common/widgets/connection_page_title.dart index ba03c265696..9c46b1ccf89 100644 --- a/flutter/lib/common/widgets/connection_page_title.dart +++ b/flutter/lib/common/widgets/connection_page_title.dart @@ -10,14 +10,16 @@ Widget getConnectionPageTitle(BuildContext context, bool isWeb) { Expanded( child: Row( children: [ - AutoSizeText( - translate('Control Remote Desktop'), - maxLines: 1, - style: Theme.of(context) - .textTheme - .titleLarge - ?.merge(TextStyle(height: 1)), - ).marginOnly(right: 4), + Flexible( + child: AutoSizeText( + translate('Control Remote Desktop'), + maxLines: 1, + style: Theme.of(context) + .textTheme + .titleLarge + ?.merge(TextStyle(height: 1)), + ), + ), Tooltip( waitDuration: Duration(milliseconds: 300), message: translate(isWeb ? "web_id_input_tip" : "id_input_tip"), @@ -30,7 +32,7 @@ Widget getConnectionPageTitle(BuildContext context, bool isWeb) { ?.color ?.withOpacity(0.5), ), - ), + ).marginOnly(left: 4), ], )), ], diff --git a/flutter/lib/common/widgets/my_group.dart b/flutter/lib/common/widgets/my_group.dart index 74ce34e71d6..924fee21c9f 100644 --- a/flutter/lib/common/widgets/my_group.dart +++ b/flutter/lib/common/widgets/my_group.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/hbbs/hbbs.dart'; import 'package:flutter_hbb/common/widgets/login.dart'; import 'package:flutter_hbb/common/widgets/peers_view.dart'; +import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; @@ -58,6 +59,13 @@ class _MyGroupState extends State { } Widget _buildLandscape() { + if (kUseRdClientHomeShell) { + return Align( + alignment: Alignment.topLeft, + child: MyGroupPeerView( + menuPadding: widget.menuPadding, + )); + } return Row( children: [ Container( @@ -94,6 +102,13 @@ class _MyGroupState extends State { } Widget _buildPortrait() { + if (kUseRdClientHomeShell) { + return Align( + alignment: Alignment.topLeft, + child: MyGroupPeerView( + menuPadding: widget.menuPadding, + )); + } return Column( children: [ Container( diff --git a/flutter/lib/common/widgets/peer_card.dart b/flutter/lib/common/widgets/peer_card.dart index 1f9f3ed7f48..abc1657c210 100644 --- a/flutter/lib/common/widgets/peer_card.dart +++ b/flutter/lib/common/widgets/peer_card.dart @@ -131,6 +131,59 @@ class _PeerCardState extends State<_PeerCard> return peerTabShowNote(widget.tab) && peer.note.isNotEmpty; } + // Betterdesk friendly name: local alias, AB/Devices, then PeerConfig (survives logout). + String _resolvedAlias(Peer peer) { + if (peer.alias.isNotEmpty) { + return peer.alias; + } + final fromAbOrGroup = gFFI.abModel.getDisplayNameForPeerId(peer.id); + if (fromAbOrGroup.isNotEmpty) { + return fromAbOrGroup; + } + return _peerConfigAlias(peer.id); + } + + String _peerConfigAlias(String id) { + try { + final alias = bind.mainGetPeerOptionSync(id: id, key: 'alias'); + if (alias.isNotEmpty) { + return alias; + } + final normalized = id.replaceAll(' ', ''); + if (normalized != id) { + return bind.mainGetPeerOptionSync(id: normalized, key: 'alias'); + } + } catch (_) {} + return ''; + } + + String _peerCardPrimaryText(Peer peer) { + final alias = _resolvedAlias(peer); + if (alias.isNotEmpty) { + return alias; + } + if (peer.note.isNotEmpty) { + return peer.note; + } + if (peer.hostname.isNotEmpty) { + return peer.hostname; + } + return formatID(peer.id); + } + + bool _peerCardHasFriendlyName(Peer peer) { + return _resolvedAlias(peer).isNotEmpty || + peer.note.isNotEmpty || + peer.hostname.isNotEmpty; + } + + String _peerCardSecondaryText(Peer peer, String name) { + if (_peerCardHasFriendlyName(peer)) { + return formatID(peer.id); + } + return name; + } + makeChild(bool isPortrait, Peer peer) { final name = hideUsernameOnCard == true ? peer.hostname @@ -139,6 +192,15 @@ class _PeerCardState extends State<_PeerCard> fontSize: 11, color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6)); final showNote = _showNote(peer); + final primaryText = _peerCardPrimaryText(peer); + final secondaryText = _peerCardSecondaryText(peer, name); + final primaryStyle = Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold); + final rdShell = kUseRdClientHomeShell && (isDesktop || isWebDesktop); + final leftW = isPortrait ? 50.0 : (rdShell ? 52.0 : 42.0); + final radius = rdShell ? 10.0 : _tileRadius; return Row( mainAxisSize: MainAxisSize.max, @@ -147,14 +209,14 @@ class _PeerCardState extends State<_PeerCard> decoration: BoxDecoration( color: str2color('${peer.id}${peer.platform}', 0x7f), borderRadius: isPortrait - ? BorderRadius.circular(_tileRadius) + ? BorderRadius.circular(radius) : BorderRadius.only( - topLeft: Radius.circular(_tileRadius), - bottomLeft: Radius.circular(_tileRadius), + topLeft: Radius.circular(radius), + bottomLeft: Radius.circular(radius), ), ), alignment: Alignment.center, - width: isPortrait ? 50 : 42, + width: leftW, height: isPortrait ? 50 : null, child: Stack( children: [ @@ -173,8 +235,8 @@ class _PeerCardState extends State<_PeerCard> decoration: BoxDecoration( color: Theme.of(context).colorScheme.background, borderRadius: BorderRadius.only( - topRight: Radius.circular(_tileRadius), - bottomRight: Radius.circular(_tileRadius), + topRight: Radius.circular(radius), + bottomRight: Radius.circular(radius), ), ), child: Row( @@ -186,21 +248,21 @@ class _PeerCardState extends State<_PeerCard> getOnline(isPortrait ? 4 : 8, peer.online), Expanded( child: Text( - peer.alias.isEmpty ? formatID(peer.id) : peer.alias, + primaryText, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, + style: primaryStyle, )), ]).marginOnly(top: isPortrait ? 0 : 2), Row( children: [ Flexible( child: Tooltip( - message: name, + message: secondaryText, waitDuration: const Duration(seconds: 1), child: Align( alignment: Alignment.centerLeft, child: Text( - name, + secondaryText, style: isPortrait ? null : greyStyle, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, @@ -220,14 +282,10 @@ class _PeerCardState extends State<_PeerCard> style: isPortrait ? null : greyStyle, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, - ).marginOnly( - left: peerCardUiType.value == - PeerUiType.list - ? 32 - : 4), + ), ), ), - ) + ), ], ), ], @@ -367,11 +425,35 @@ class _PeerCardState extends State<_PeerCard> child: Row(children: [ getOnline(8, peer.online), Expanded( - child: Text( - peer.alias.isEmpty ? formatID(peer.id) : peer.alias, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - )), + child: Builder(builder: (context) { + final primary = _peerCardPrimaryText(peer); + final showIdBelow = _peerCardHasFriendlyName(peer); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + primary, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (showIdBelow) + Text( + formatID(peer.id), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .textTheme + .titleLarge + ?.color + ?.withOpacity(0.6)), + ), + ], + ); + })), ]).paddingSymmetric(vertical: 8)), checkBoxOrActionMoreLandscape(peer, isTile: false), ], @@ -1556,18 +1638,23 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab, alias: peer.alias, ); } - if (!gFFI.abModel.current.isPersonal()) { - if (peer.password.isNotEmpty) { - password = peer.password; + // Betterdesk/legacy personal AB stores plaintext password on the peer. + if (peer.password.isNotEmpty) { + password = peer.password; + isSharedPassword = true; + } else if (!gFFI.abModel.current.isPersonal()) { + final abPassword = gFFI.abModel.getdefaultSharedPassword(); + if (abPassword != null) { + password = abPassword; isSharedPassword = true; } - if (password.isEmpty) { - final abPassword = gFFI.abModel.getdefaultSharedPassword(); - if (abPassword != null) { - password = abPassword; - isSharedPassword = true; - } - } + } + } else if (tab == PeerTabIndex.group) { + // BetterDesk Accessible devices: Access Policy password from /api/peers. + // Logged-in users already passed ACL; do not prompt when password is present. + if (peer.password.isNotEmpty) { + password = peer.password; + isSharedPassword = true; } } connect(context, peer.id, diff --git a/flutter/lib/common/widgets/peer_tab_page.dart b/flutter/lib/common/widgets/peer_tab_page.dart index 4849f278327..78784414375 100644 --- a/flutter/lib/common/widgets/peer_tab_page.dart +++ b/flutter/lib/common/widgets/peer_tab_page.dart @@ -83,9 +83,14 @@ class _PeerTabPageState extends State : int.parse(uiType) == 1 ? PeerUiType.tile : PeerUiType.list; + } else if (kUseRdClientHomeShell) { + peerCardUiType.value = PeerUiType.tile; } hideAbTagsPanel.value = bind.mainGetLocalOption(key: kOptionHideAbTagsPanel) == 'Y'; + if (kUseRdClientHomeShell) { + hideAbTagsPanel.value = true; + } } Future handleTabSelection(int tabIndex) async { @@ -114,13 +119,24 @@ class _PeerTabPageState extends State child: Container( padding: stateGlobal.isPortrait.isTrue ? EdgeInsets.symmetric(horizontal: 2) - : null, + : const EdgeInsets.symmetric(horizontal: 8), child: selectionWrap(Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - child: visibleContextMenuListener( - _createSwitchBar(context))), + if (!kUseRdClientHomeShell) + Expanded( + child: visibleContextMenuListener( + _createSwitchBar(context))) + else + Expanded( + child: Text( + Provider.of(context) + .tabTooltip(Provider.of(context) + .currentTab), + style: Theme.of(context).textTheme.titleSmall, + overflow: TextOverflow.ellipsis, + ), + ), if (stateGlobal.isPortrait.isTrue) ..._portraitRightActions(context) else @@ -563,11 +579,15 @@ class _PeerTabPageState extends State ), _createPeerViewTypeSwitch(context), Offstage( - offstage: model.currentTab == PeerTabIndex.recent.index, + offstage: model.currentTab == PeerTabIndex.recent.index || + (kUseRdClientHomeShell && + model.currentTab == PeerTabIndex.ab.index), child: PeerSortDropdown(), ), Offstage( - offstage: model.currentTab != PeerTabIndex.ab.index, + // Tags live in RdNavSidebar when RdClient shell is active. + offstage: model.currentTab != PeerTabIndex.ab.index || + kUseRdClientHomeShell, child: _toggleTags(), ), ]; diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 6c22057f95f..b13b78b66da 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -153,6 +153,8 @@ const String kOptionRemoteMenubarFraction = "remote-menubar-frac"; const String kOptionAllowMultiEdgeToolbarDock = "allow-multi-edge-toolbar-dock"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; +/// Desktop home shell matching BetterDesk RdClient (header / connect / sidebar / grid). +const bool kUseRdClientHomeShell = true; const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionPeerSorting = "peer-sorting"; const String kOptionPeerTabIndex = "peer-tab-index"; diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 7af8c9911f7..dec45c1d7dd 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -304,14 +304,24 @@ class _ConnectionPageState extends State @override Widget build(BuildContext context) { final isOutgoingOnly = bind.isOutgoingOnly(); + // When RdClient shell owns header/connect/footer, ConnectionPage is unused + // on the home path; keep classic layout for the legacy toggle. return Column( children: [ Expanded( child: Column( children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible(child: _buildRemoteIDTextField(context)), + const Spacer(), + Image.asset( + 'assets/dcs_norway_logo.png', + height: 120, + width: 120, + filterQuality: FilterQuality.medium, + ).marginOnly(right: 24, top: 8), ], ).marginOnly(top: 22), SizedBox(height: 12), diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 42ec1003237..0362abf33db 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -8,10 +8,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/animated_rotation_widget.dart'; import 'package:flutter_hbb/common/widgets/custom_password.dart'; +import 'package:flutter_hbb/common/widgets/peer_tab_page.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/connection_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_account_bar.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_header.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_nav_sidebar.dart'; import 'package:flutter_hbb/desktop/widgets/update_progress.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; @@ -60,6 +65,9 @@ class _DesktopHomePageState extends State Widget build(BuildContext context) { super.build(context); final isIncomingOnly = bind.isIncomingOnly(); + if (kUseRdClientHomeShell) { + return _buildBlock(child: _buildRdClientShell(context)); + } return _buildBlock( child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -71,6 +79,50 @@ class _DesktopHomePageState extends State )); } + Widget _buildRdClientShell(BuildContext context) { + final isIncomingOnly = bind.isIncomingOnly(); + + // Pure connection client: no Your Desktop, no manual connect, no service footer. + // Follow Settings → Theme (light / dark / system); do not force darkTheme. + return Container( + color: RdHomeTheme.bgOf(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Obx(() { + final url = stateGlobal.updateUrl.value; + return RdHomeHeader( + banner: url.isEmpty ? null : buildHelpCards(url), + ); + }), + if (!isIncomingOnly) const RdAccountBar(), + if (!isIncomingOnly) + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const RdNavSidebar(), + Expanded( + child: Container( + margin: const EdgeInsets.fromLTRB(0, 10, 16, 16), + decoration: RdHomeTheme.panel(context), + clipBehavior: Clip.antiAlias, + child: ChangeNotifierProvider.value( + value: gFFI.peerTabModel, + child: const PeerTabPage(), + ), + ), + ), + ], + ), + ) + else + const Expanded(child: SizedBox.shrink()), + ], + ), + ); + } + Widget _buildBlock({required Widget child}) { return buildRemoteBlock( block: _block, mask: true, use: canBeBlocked, child: child); @@ -430,14 +482,17 @@ class _DesktopHomePageState extends State } Widget buildHelpCards(String updateUrl) { - if (!bind.isCustomClient() && - updateUrl.isNotEmpty && + // Stock RustDesk + DCS custom client: show update when a newer release URL is set. + if (updateUrl.isNotEmpty && !isCardClosed && - bind.mainUriPrefixSync().contains('rustdesk')) { - final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled(); + (bind.isCustomClient() || + bind.mainUriPrefixSync().contains('rustdesk'))) { + final isToUpdate = bind.isCustomClient() || + ((isWindows || isMacOS) && bind.mainIsInstalled()); String btnText = isToUpdate ? 'Update' : 'Download'; GestureTapCallback onPressed = () async { - final Uri url = Uri.parse('https://rustdesk.com/download'); + final Uri url = Uri.parse( + bind.isCustomClient() ? updateUrl : 'https://rustdesk.com/download'); await launchUrl(url); }; if (isToUpdate) { @@ -445,6 +500,9 @@ class _DesktopHomePageState extends State handleUpdate(updateUrl); }; } + final changelogLink = bind.isCustomClient() + ? updateUrl + : 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'; return buildInstallCard( "Status", "${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).", @@ -452,9 +510,7 @@ class _DesktopHomePageState extends State onPressed, closeButton: true, help: isToUpdate ? 'Changelog' : null, - link: isToUpdate - ? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}' - : null); + link: isToUpdate ? changelogLink : null); } if (systemError.isNotEmpty) { return buildInstallCard("", systemError, "", () {}); @@ -462,6 +518,10 @@ class _DesktopHomePageState extends State if (isWindows && !bind.isDisableInstallation()) { if (!bind.mainIsInstalled()) { + if (kUseRdClientHomeShell) { + // Pure client: never prompt to install as a host/service. + return const SizedBox.shrink(); + } return buildInstallCard( "", bind.isOutgoingOnly() ? "" : "install_tip", "Install", () async { @@ -469,14 +529,14 @@ class _DesktopHomePageState extends State bind.mainGotoInstall(); }); } else if (bind.mainIsInstalledLowerVersion()) { - return buildInstallCard( - "Status", "Your installation is lower version.", "Click to upgrade", - () async { - await rustDeskWinManager.closeAllSubWindows(); - bind.mainUpdateMe(); - }); + // Hidden for DCS Norway fork — upgrade prompt is not applicable. + return const SizedBox.shrink(); } } else if (isMacOS) { + if (kUseRdClientHomeShell) { + // Pure client: no host daemon or hosting-permission prompts. + return const SizedBox.shrink(); + } final isOutgoingOnly = bind.isOutgoingOnly(); if (!(isOutgoingOnly || bind.mainIsCanScreenRecording(prompt: false))) { return buildInstallCard("Permissions", "config_screen", "Configure", @@ -514,7 +574,7 @@ class _DesktopHomePageState extends State // }); // } } else if (isLinux) { - if (bind.isOutgoingOnly()) { + if (kUseRdClientHomeShell || bind.isOutgoingOnly()) { return Container(); } final LinuxCards = []; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index b2aab1cfbbb..9f0c0cefe65 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -22,7 +22,6 @@ import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/login.dart'; @@ -65,7 +64,8 @@ class DesktopSettingPage extends StatefulWidget { final SettingsTabKey initialTabkey; static final List tabKeys = [ SettingsTabKey.general, - if (!isWeb && + if (!kUseRdClientHomeShell && + !isWeb && !bind.isOutgoingOnly() && !bind.isDisableSettings() && bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y') @@ -76,10 +76,8 @@ class DesktopSettingPage extends StatefulWidget { if (!bind.isIncomingOnly()) SettingsTabKey.display, if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) SettingsTabKey.plugin, - if (!bind.isDisableAccount()) SettingsTabKey.account, - if (isWindows && - bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') - SettingsTabKey.printer, + if (!kUseRdClientHomeShell && !bind.isDisableAccount()) + SettingsTabKey.account, SettingsTabKey.about, ]; @@ -415,7 +413,8 @@ class _GeneralState extends State<_General> { return ListView( controller: scrollController, children: [ - if (!isWeb) service(), + // Pure connection client: no local Service start/stop card. + if (!isWeb && !kUseRdClientHomeShell) service(), theme(), _Card(title: 'Language', children: [language()]), if (!isWeb) hwcodec(), @@ -545,14 +544,14 @@ class _GeneralState extends State<_General> { ), ), ], - if (!isWeb && !bind.isCustomClient()) + if (!isWeb) _OptionCheckBox( context, 'Check for software update on startup', kOptionEnableCheckUpdate, isServer: false, ), - if (showAutoUpdate) + if (showAutoUpdate && (!kUseRdClientHomeShell || bind.isCustomClient())) _OptionCheckBox( context, 'Auto update', @@ -2447,104 +2446,61 @@ class __PrinterState extends State<_Printer> { } } -class _About extends StatefulWidget { +class _About extends StatelessWidget { const _About({Key? key}) : super(key: key); - @override - State<_About> createState() => _AboutState(); -} - -class _AboutState extends State<_About> { @override Widget build(BuildContext context) { return futureBuilder(future: () async { - final license = await bind.mainGetLicense(); final version = await bind.mainGetVersion(); final buildDate = await bind.mainGetBuildDate(); - final fingerprint = await bind.mainGetFingerprint(); - final myId = await bind.mainGetMyId(); return { - 'license': license, 'version': version, 'buildDate': buildDate, - 'fingerprint': fingerprint, - 'myId': myId }; }(), hasData: (data) { - final license = data['license'].toString(); final version = data['version'].toString(); final buildDate = data['buildDate'].toString(); - final fingerprint = data['fingerprint'].toString(); - final myId = data['myId'].toString(); - const linkStyle = TextStyle(decoration: TextDecoration.underline); final scrollController = ScrollController(); return SingleChildScrollView( controller: scrollController, - child: _Card(title: translate('About RustDesk'), children: [ - Column( + child: _Card( + title: 'About DCS Norway Remote Desktop Client', + children: [ + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox( - height: 8.0, - ), - SelectionArea( - child: Text('${translate('Version')}: $version') - .marginSymmetric(vertical: 4.0)), - SelectionArea( - child: Text('${translate('Build Date')}: $buildDate') - .marginSymmetric(vertical: 4.0)), - if (!isWeb) - SelectionArea( - child: Text('${translate('Fingerprint')}: $fingerprint') - .marginSymmetric(vertical: 4.0)), - SelectionArea( - child: Text('${translate('ID')}: $myId') - .marginSymmetric(vertical: 4.0)), - InkWell( - onTap: () { - launchUrlString('https://rustdesk.com/privacy.html'); - }, - child: Text( - translate('Privacy Statement'), - style: linkStyle, - ).marginSymmetric(vertical: 4.0)), - InkWell( - onTap: () { - launchUrlString('https://rustdesk.com'); - }, - child: Text( - translate('Website'), - style: linkStyle, - ).marginSymmetric(vertical: 4.0)), - Container( - decoration: const BoxDecoration(color: Color(0xFF2c8cff)), - padding: - const EdgeInsets.symmetric(vertical: 24, horizontal: 8), - child: SelectionArea( - child: Row( + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license', - style: const TextStyle(color: Colors.white), - ), - Text( - translate('Slogan_tip'), - style: TextStyle( - fontWeight: FontWeight.w800, - color: Colors.white), - ) - ], - ), - ), + const SizedBox(height: 8.0), + SelectionArea( + child: Text('Base version: RustDesk $version') + .marginSymmetric(vertical: 4.0)), + SelectionArea( + child: Text('${translate('Build Date')}: $buildDate') + .marginSymmetric(vertical: 4.0)), + SelectionArea( + child: const Text('Creator: Chesster') + .marginSymmetric(vertical: 4.0)), ], - )), - ).marginSymmetric(vertical: 4.0) + ), + ), + const SizedBox(width: 16), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Image.asset( + 'assets/icon.png', + width: 72, + height: 72, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), ], - ).marginOnly(left: _kContentHMargin) + ).marginOnly(left: _kContentHMargin, right: _kContentHMargin) ]), ); }); diff --git a/flutter/lib/desktop/pages/desktop_tab_page.dart b/flutter/lib/desktop/pages/desktop_tab_page.dart index 6440e55a111..72da9cdae77 100644 --- a/flutter/lib/desktop/pages/desktop_tab_page.dart +++ b/flutter/lib/desktop/pages/desktop_tab_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -91,9 +92,12 @@ class _DesktopTabPageState extends State { @override Widget build(BuildContext context) { + final bg = kUseRdClientHomeShell + ? RdHomeTheme.bgOf(context) + : Theme.of(context).colorScheme.background; final tabWidget = Container( child: Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: bg, body: DesktopTab( controller: tabController, tail: Offstage( diff --git a/flutter/lib/desktop/pages/install_page.dart b/flutter/lib/desktop/pages/install_page.dart index fe94b388bf8..105536fd292 100644 --- a/flutter/lib/desktop/pages/install_page.dart +++ b/flutter/lib/desktop/pages/install_page.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -80,7 +81,9 @@ class _InstallPageBodyState extends State<_InstallPageBody> final installOptions = jsonDecode(bind.installInstallOptions()); startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0'; desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0'; - printer.value = installOptions['PRINTER'] == '1'; + // Pure connection client: never offer remote printer install. + printer.value = + !kUseRdClientHomeShell && installOptions['PRINTER'] == '1'; } @override @@ -165,7 +168,8 @@ class _InstallPageBodyState extends State<_InstallPageBody> .marginOnly(bottom: 7), Option(desktopicon, label: 'Create desktop icon') .marginOnly(bottom: 7), - Option(printer, label: 'Install {$appName} Printer'), + if (!kUseRdClientHomeShell) + Option(printer, label: 'Install {$appName} Printer'), Container( padding: EdgeInsets.all(12), decoration: BoxDecoration( diff --git a/flutter/lib/desktop/widgets/rd_account_bar.dart b/flutter/lib/desktop/widgets/rd_account_bar.dart new file mode 100644 index 00000000000..008c7c26ae5 --- /dev/null +++ b/flutter/lib/desktop/widgets/rd_account_bar.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/login.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; + +/// Account / login status bar for the RdClient-style home shell. +class RdAccountBar extends StatelessWidget { + const RdAccountBar({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final surface = RdHomeTheme.surfaceOf(context); + final border = RdHomeTheme.borderOf(context); + final textPrimary = RdHomeTheme.textPrimaryOf(context); + final textMuted = RdHomeTheme.textMutedOf(context); + final surface2 = RdHomeTheme.surface2Of(context); + + return Container( + margin: const EdgeInsets.fromLTRB(16, 10, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: border), + ), + child: Obx(() { + final loggedIn = gFFI.userModel.userName.value.isNotEmpty; + final display = gFFI.userModel.displayNameOrUserName; + final handle = gFFI.userModel.userName.value; + final avatar = + bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value); + + return Row( + children: [ + _avatar(avatar, loggedIn, surface2, border, textMuted), + const SizedBox(width: 12), + Expanded( + child: loggedIn + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '@$handle', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: textMuted, + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + translate('Not logged in'), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + translate('Sign in to see accessible devices'), + style: TextStyle( + fontSize: 12, + color: textMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + SizedBox( + height: 36, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: RdHomeTheme.accentSolid, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + onPressed: () { + if (loggedIn) { + logOutConfirmDialog(); + } else { + loginDialog(); + } + }, + child: Text( + loggedIn + ? '${translate('Logout')} ($display)' + : translate('Login'), + ), + ), + ), + ], + ); + }), + ); + } + + Widget _avatar(String avatar, bool loggedIn, Color surface2, Color border, + Color textMuted) { + final built = + loggedIn ? buildAvatarWidget(avatar: avatar, size: 40) : null; + if (built != null) return built; + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: surface2, + shape: BoxShape.circle, + border: Border.all(color: border), + ), + child: Icon( + loggedIn ? Icons.person : Icons.person_outline, + color: textMuted, + size: 22, + ), + ); + } +} diff --git a/flutter/lib/desktop/widgets/rd_home_header.dart b/flutter/lib/desktop/widgets/rd_home_header.dart new file mode 100644 index 00000000000..ed17a0b38e6 --- /dev/null +++ b/flutter/lib/desktop/widgets/rd_home_header.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart' hide Dialog; +import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; + +/// Top brand bar for pure connection-client home (no Your Desktop). +class RdHomeHeader extends StatelessWidget { + const RdHomeHeader({ + Key? key, + this.banner, + }) : super(key: key); + + final Widget? banner; + + @override + Widget build(BuildContext context) { + final textPrimary = RdHomeTheme.textPrimaryOf(context); + final textMuted = RdHomeTheme.textMutedOf(context); + + return Container( + // Match account bar / devices panel: inset margins + rounded panel. + margin: const EdgeInsets.fromLTRB(16, 10, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: RdHomeTheme.panel(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _logo(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DCS Norway', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: textPrimary, + height: 1.15, + ), + ), + Text( + 'Remote Desktop Client', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: textPrimary, + height: 1.2, + ), + ), + ], + ), + ), + if (!bind.isDisableSettings()) + IconButton( + tooltip: translate('Settings'), + onPressed: () => DesktopTabPage.onAddSetting(), + icon: Icon(Icons.settings_outlined, + color: textMuted, size: 22), + ), + ], + ), + if (banner != null) ...[ + const SizedBox(height: 8), + banner!, + ], + ], + ), + ); + } + + Widget _logo() { + return ClipOval( + child: Image.asset( + 'assets/dcs_norway_logo.png', + width: 48, + height: 48, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + errorBuilder: (_, __, ___) => Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: RdHomeTheme.accentSolid, + shape: BoxShape.circle, + ), + child: + const Icon(Icons.desktop_windows, color: Colors.white, size: 24), + ), + ), + ); + } +} diff --git a/flutter/lib/desktop/widgets/rd_home_theme.dart b/flutter/lib/desktop/widgets/rd_home_theme.dart new file mode 100644 index 00000000000..cc0b83da697 --- /dev/null +++ b/flutter/lib/desktop/widgets/rd_home_theme.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Colors aligned with BetterDesk RdClient; adapts to light/dark ThemeMode. +class RdHomeTheme { + RdHomeTheme._(); + + static const Color bgDark = Color(0xFF0D1117); + static const Color surfaceDark = Color(0xFF161B22); + static const Color surface2Dark = Color(0xFF21262D); + static const Color borderDark = Color(0xFF30363D); + static const Color textPrimaryDark = Color(0xFFE6EDF3); + static const Color textMutedDark = Color(0xFF8B949E); + + static const Color bgLight = Color(0xFFF5F7FA); + static const Color surfaceLight = Color(0xFFFFFFFF); + static const Color surface2Light = Color(0xFFEEF1F5); + static const Color borderLight = Color(0xFFD0D7DE); + static const Color textPrimaryLight = Color(0xFF1F2328); + static const Color textMutedLight = Color(0xFF656D76); + + static const Color accent = Color(0xFF58A6FF); + static const Color accentSolid = Color(0xFF1F6FEB); + static const Color online = Color(0xFF3FB950); + static const Color warn = Color(0xFFD29922); + + // Back-compat aliases (dark defaults). + static const Color bg = bgDark; + static const Color surface = surfaceDark; + static const Color surface2 = surface2Dark; + static const Color border = borderDark; + static const Color textPrimary = textPrimaryDark; + static const Color textMuted = textMutedDark; + + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + static Color bgOf(BuildContext context) => + isDark(context) ? bgDark : bgLight; + + static Color surfaceOf(BuildContext context) => + isDark(context) ? surfaceDark : surfaceLight; + + static Color surface2Of(BuildContext context) => + isDark(context) ? surface2Dark : surface2Light; + + static Color borderOf(BuildContext context) => + isDark(context) ? borderDark : borderLight; + + static Color textPrimaryOf(BuildContext context) => + isDark(context) ? textPrimaryDark : textPrimaryLight; + + static Color textMutedOf(BuildContext context) => + isDark(context) ? textMutedDark : textMutedLight; + + static BoxDecoration panel(BuildContext context, {double radius = 10}) => + BoxDecoration( + color: surfaceOf(context), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: borderOf(context)), + ); +} diff --git a/flutter/lib/desktop/widgets/rd_nav_sidebar.dart b/flutter/lib/desktop/widgets/rd_nav_sidebar.dart new file mode 100644 index 00000000000..043fcbe6e5d --- /dev/null +++ b/flutter/lib/desktop/widgets/rd_nav_sidebar.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/login.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; +import 'package:flutter_hbb/models/peer_tab_model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; + +/// Groups-only sidebar for pure Accessible-devices client. +class RdNavSidebar extends StatefulWidget { + const RdNavSidebar({Key? key}) : super(key: key); + + @override + State createState() => _RdNavSidebarState(); +} + +class _RdNavSidebarState extends State { + bool _groupsOpen = true; + + Future _ensureGroupTab() async { + final model = gFFI.peerTabModel; + final group = PeerTabIndex.group.index; + if (model.currentTab != group) { + model.setCurrentTabCachedPeers([]); + model.setCurrentTab(group); + await bind.setLocalFlutterOption( + k: kOptionPeerTabIndex, v: group.toString()); + gFFI.groupModel.pull(force: true); + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: 220, + // Match account bar + devices panel: same top/bottom air and radius. + margin: const EdgeInsets.fromLTRB(16, 10, 10, 16), + decoration: RdHomeTheme.panel(context), + child: Consumer( + builder: (context, model, _) { + return ListView( + padding: const EdgeInsets.fromLTRB(10, 12, 10, 12), + children: [ + _section( + context, + title: translate('Groups'), + open: _groupsOpen, + onToggle: () => setState(() => _groupsOpen = !_groupsOpen), + child: _groupsBody(context, model), + ), + ], + ); + }, + ), + ); + } + + Widget _section( + BuildContext context, { + required String title, + required bool open, + required VoidCallback onToggle, + required Widget child, + }) { + final muted = RdHomeTheme.textMutedOf(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 10), + child: Row( + children: [ + Expanded( + child: Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + color: muted, + ), + ), + ), + Icon( + open ? Icons.expand_more : Icons.chevron_right, + size: 18, + color: muted, + ), + ], + ), + ), + ), + if (open) child, + ], + ); + } + + Widget _groupsBody(BuildContext context, PeerTabModel model) { + if (!model.isEnabled[PeerTabIndex.group.index]) { + return _emptyHint(context, translate('Disabled')); + } + return Obx(() { + final loggedIn = gFFI.userModel.userName.value.isNotEmpty; + if (!loggedIn) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: TextButton( + onPressed: loginDialog, + child: + Text(translate('Login'), style: const TextStyle(fontSize: 12)), + ), + ); + } + final groups = List.of(gFFI.groupModel.deviceGroups); + final users = List.of(gFFI.groupModel.users); + final selectedName = gFFI.groupModel.selectedAccessibleItemName.value; + final selectedIsDevice = gFFI.groupModel.isSelectedDeviceGroup.value; + final onGroupTab = model.currentTab == PeerTabIndex.group.index; + + if (groups.isEmpty && users.isEmpty) { + return _emptyHint(context, translate('No groups')); + } + return Column( + children: [ + for (final g in groups) + _navTile( + context, + selected: onGroupTab && selectedIsDevice && selectedName == g.name, + icon: IconFont.deviceGroupOutline, + label: g.name, + onTap: () async { + await _ensureGroupTab(); + gFFI.groupModel.isSelectedDeviceGroup.value = true; + if (gFFI.groupModel.selectedAccessibleItemName.value == g.name) { + gFFI.groupModel.selectedAccessibleItemName.value = ''; + } else { + gFFI.groupModel.selectedAccessibleItemName.value = g.name; + } + }, + ), + for (final u in users) + _navTile( + context, + selected: + onGroupTab && !selectedIsDevice && selectedName == u.name, + icon: Icons.person_outline, + label: u.displayNameOrName, + onTap: () async { + await _ensureGroupTab(); + gFFI.groupModel.isSelectedDeviceGroup.value = false; + if (gFFI.groupModel.selectedAccessibleItemName.value == u.name) { + gFFI.groupModel.selectedAccessibleItemName.value = ''; + } else { + gFFI.groupModel.selectedAccessibleItemName.value = u.name; + } + }, + ), + ], + ); + }); + } + + Widget _emptyHint(BuildContext context, String text) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Text( + text, + style: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: RdHomeTheme.textMutedOf(context), + ), + ), + ); + } + + Widget _navTile( + BuildContext context, { + required bool selected, + required IconData icon, + required String label, + required VoidCallback onTap, + int? count, + Color? iconColor, + }) { + final muted = RdHomeTheme.textMutedOf(context); + final primary = RdHomeTheme.textPrimaryOf(context); + final selBg = RdHomeTheme.accent.withOpacity(0.15); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Material( + color: selected ? selBg : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + hoverColor: RdHomeTheme.surface2Of(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Row( + children: [ + Icon(icon, + size: 18, + color: iconColor ?? + (selected ? RdHomeTheme.accent : muted)), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? RdHomeTheme.accent : primary, + ), + ), + ), + if (count != null) + Text( + '$count', + style: TextStyle(fontSize: 11, color: muted), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/desktop/widgets/rd_quick_connect_bar.dart b/flutter/lib/desktop/widgets/rd_quick_connect_bar.dart new file mode 100644 index 00000000000..a968399033f --- /dev/null +++ b/flutter/lib/desktop/widgets/rd_quick_connect_bar.dart @@ -0,0 +1,354 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/formatter/id_formatter.dart'; +import 'package:flutter_hbb/common/widgets/autocomplete.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart' + as mod_menu; +import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; +import 'package:flutter_hbb/desktop/widgets/rd_home_theme.dart'; +import 'package:flutter_hbb/models/peer_model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; + +/// Full-width quick-connect bar (RdClient style). +class RdQuickConnectBar extends StatefulWidget { + const RdQuickConnectBar({Key? key}) : super(key: key); + + @override + State createState() => _RdQuickConnectBarState(); +} + +class _RdQuickConnectBarState extends State { + final _idController = IDTextEditingController(); + final RxBool _idInputFocused = false.obs; + final FocusNode _idFocusNode = FocusNode(); + final TextEditingController _idEditingController = TextEditingController(); + final AllPeersLoader _allPeersLoader = AllPeersLoader(); + Iterable _autocompleteOpts = []; + final _menuOpen = false.obs; + + @override + void initState() { + super.initState(); + _allPeersLoader.init(setState); + _idFocusNode.addListener(_onFocusChanged); + if (_idController.text.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final lastRemoteId = await bind.mainGetLastRemoteId(); + if (lastRemoteId != _idController.id) { + setState(() { + _idController.id = lastRemoteId; + }); + } + }); + } + Get.put(_idEditingController); + Get.put(_idController); + } + + @override + void dispose() { + _idController.dispose(); + _allPeersLoader.clear(); + _idFocusNode.removeListener(_onFocusChanged); + _idFocusNode.dispose(); + _idEditingController.dispose(); + if (Get.isRegistered()) { + Get.delete(); + } + if (Get.isRegistered()) { + Get.delete(); + } + super.dispose(); + } + + void _onFocusChanged() { + _idInputFocused.value = _idFocusNode.hasFocus; + if (_idFocusNode.hasFocus) { + if (_allPeersLoader.needLoad) { + _allPeersLoader.getAllPeers(); + } + final textLength = _idEditingController.value.text.length; + _idEditingController.selection = + TextSelection(baseOffset: 0, extentOffset: textLength); + } + } + + void onConnect( + {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTerminal = false}) { + connect(context, _idController.id, + isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 10, 16, 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: RdHomeTheme.panel(context), + child: Row( + children: [ + Icon(Icons.connected_tv, + size: 22, color: RdHomeTheme.textMutedOf(context)), + const SizedBox(width: 10), + Text( + translate('Control Remote Desktop'), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: RdHomeTheme.textMutedOf(context), + ), + ), + const SizedBox(width: 16), + Expanded(child: _buildIdField(context)), + const SizedBox(width: 10), + _connectButton(context), + const SizedBox(width: 6), + _moreButton(context), + ], + ), + ); + } + + Widget _buildIdField(BuildContext context) { + return RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + _autocompleteOpts = const Iterable.empty(); + } else if (_allPeersLoader.peers.isEmpty && + !_allPeersLoader.isPeersLoaded) { + _autocompleteOpts = [ + Peer( + id: '', + username: '', + hostname: '', + alias: '', + platform: '', + tags: [], + hash: '', + password: '', + forceAlwaysRelay: false, + rdpPort: '', + rdpUsername: '', + loginName: '', + device_group_name: '', + note: '', + ) + ]; + } else { + var tev = textEditingValue; + final textWithoutSpaces = tev.text.replaceAll(" ", ""); + if (int.tryParse(textWithoutSpaces) != null) { + tev = TextEditingValue( + text: textWithoutSpaces, + selection: tev.selection, + ); + } + final textToFind = tev.text.toLowerCase(); + _autocompleteOpts = _allPeersLoader.peers + .where((peer) => + peer.id.toLowerCase().contains(textToFind) || + peer.username.toLowerCase().contains(textToFind) || + peer.hostname.toLowerCase().contains(textToFind) || + peer.alias.toLowerCase().contains(textToFind)) + .toList(); + _allPeersLoader.queryOnlines(_autocompleteOpts); + } + return _autocompleteOpts; + }, + focusNode: _idFocusNode, + textEditingController: _idEditingController, + fieldViewBuilder: ( + BuildContext context, + TextEditingController fieldTextEditingController, + FocusNode fieldFocusNode, + VoidCallback onFieldSubmitted, + ) { + updateTextAndPreserveSelection( + fieldTextEditingController, _idController.text); + return Obx(() => TextField( + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + focusNode: fieldFocusNode, + style: const TextStyle( + fontFamily: 'WorkSans', + fontSize: 16, + height: 1.3, + color: RdHomeTheme.textPrimary, + ), + maxLines: 1, + cursorColor: RdHomeTheme.accent, + decoration: InputDecoration( + filled: true, + fillColor: RdHomeTheme.bg, + counterText: '', + hintText: _idInputFocused.value + ? null + : translate('Enter Remote ID'), + hintStyle: const TextStyle( + color: RdHomeTheme.textMuted, + fontSize: 15, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: RdHomeTheme.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: RdHomeTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: RdHomeTheme.accent), + ), + ), + controller: fieldTextEditingController, + inputFormatters: [IDTextInputFormatter()], + onChanged: (v) { + _idController.id = v; + }, + onSubmitted: (_) { + onConnect(); + }, + ).workaroundFreezeLinuxMint()); + }, + onSelected: (option) { + setState(() { + _idController.id = option.id; + FocusScope.of(context).unfocus(); + }); + }, + optionsViewBuilder: (BuildContext context, + AutocompleteOnSelected onSelected, Iterable options) { + options = _autocompleteOpts; + double maxHeight = options.length * 50; + if (options.length == 1) { + maxHeight = 52; + } else if (options.length == 3) { + maxHeight = 146; + } else if (options.length == 4) { + maxHeight = 193; + } + maxHeight = maxHeight.clamp(0, 200); + + return Align( + alignment: Alignment.topLeft, + child: Material( + color: RdHomeTheme.surface2, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + maxWidth: 420, + ), + child: _allPeersLoader.peers.isEmpty && + !_allPeersLoader.isPeersLoaded + ? const SizedBox( + height: 80, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2))) + : ListView( + padding: const EdgeInsets.only(top: 4), + children: options + .map((peer) => AutocompletePeerTile( + onSelect: () => onSelected(peer), peer: peer)) + .toList(), + ), + ), + ), + ); + }, + ); + } + + Widget _connectButton(BuildContext context) { + return SizedBox( + height: 40, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: RdHomeTheme.accentSolid, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => onConnect(), + icon: const Icon(Icons.play_arrow, size: 20), + label: Text(translate('Connect')), + ), + ); + } + + Widget _moreButton(BuildContext context) { + return Container( + height: 40, + width: 40, + decoration: BoxDecoration( + border: Border.all(color: RdHomeTheme.border), + borderRadius: BorderRadius.circular(8), + color: RdHomeTheme.surface2, + ), + child: Obx(() => InkWell( + borderRadius: BorderRadius.circular(8), + child: Transform.rotate( + angle: _menuOpen.value ? pi : 0, + child: const Icon(IconFont.more, + size: 14, color: RdHomeTheme.textMuted), + ), + onTapDown: (e) async { + final offset = e.globalPosition; + _menuOpen.value = true; + final x = offset.dx; + final y = offset.dy; + await mod_menu + .showMenu( + context: context, + position: RelativeRect.fromLTRB(x, y, x, y), + items: [ + ('Transfer file', () => onConnect(isFileTransfer: true)), + ('View camera', () => onConnect(isViewCamera: true)), + ( + '${translate('Terminal')} (beta)', + () => onConnect(isTerminal: true) + ), + ] + .map((e) => MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate(e.$1), + style: style, + ), + proc: () => e.$2(), + padding: EdgeInsets.symmetric( + horizontal: kDesktopMenuPadding.left), + dismissOnClicked: true, + )) + .map((e) => e.build( + context, + const MenuConfig( + commonColor: CustomPopupMenuTheme.commonColor, + height: CustomPopupMenuTheme.height, + dividerHeight: + CustomPopupMenuTheme.dividerHeight))) + .expand((i) => i) + .toList(), + elevation: 8, + ) + .then((_) { + _menuOpen.value = false; + }); + }, + )), + ); + } +} diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 93f661b7b7d..e4e3b0030f4 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -9,7 +9,70 @@ import 'package:url_launcher/url_launcher.dart'; final _isExtracting = false.obs; +const _kUpdatePromptTag = 'dcs-software-update'; +final _dismissedUpdateUrls = {}; +bool _updatePromptOpen = false; + +/// Prompt once per release URL per process. Hourly checks still catch a newer tag. +void promptSoftwareUpdate(String url) { + if (url.isEmpty || _dismissedUpdateUrls.contains(url) || _updatePromptOpen) { + return; + } + _updatePromptOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + final app = bind.mainGetAppNameSync(); + final version = bind.mainGetNewVersion(); + try { + for (var i = 0; i < 20 && globalKey.currentState?.overlay == null; i++) { + await Future.delayed(const Duration(milliseconds: 250)); + } + await gFFI.dialogManager.show( + (setState, close, context) { + return CustomAlertDialog( + title: Text(translate('Update')), + content: Text( + '${translate("new-version-of-{$app}-tip")} ($version).', + ), + actions: [ + dialogButton('Cancel', onPressed: () { + _dismissedUpdateUrls.add(url); + close(); + }, isOutline: true), + dialogButton('Update', onPressed: () { + _dismissedUpdateUrls.add(url); + close(); + handleUpdate(url); + }), + ], + onCancel: () { + _dismissedUpdateUrls.add(url); + close(); + }, + ); + }, + tag: _kUpdatePromptTag, + forceGlobal: true, + ); + } catch (e) { + debugPrint('software update prompt: $e'); + } finally { + _updatePromptOpen = false; + } + }); +} + void handleUpdate(String releasePageUrl) { + platformFFI.registerEventHandler( + 'install-update-file', + 'install-update-file', + (evt) async { + final path = evt['path']; + if (path is String && path.isNotEmpty) { + await gFFI.invokeMethod('install_update', path); + } + }, + replace: true, + ); _isExtracting.value = false; String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index ff5dc828cc5..c9f8580be5d 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -80,20 +80,26 @@ class _ConnectionPageState extends State { @override Widget build(BuildContext context) { Provider.of(context); + // DCS pure client: no manual Remote ID — only Available devices. + final showRemoteId = !kUseRdClientHomeShell; return CustomScrollView( slivers: [ SliverList( delegate: SliverChildListDelegate([ if (!bind.isCustomClient() && !isIOS) Obx(() => _buildUpdateUI(stateGlobal.updateUrl.value)), - _buildRemoteIDTextField(), + if (showRemoteId) _buildRemoteIDTextField(), ])), SliverFillRemaining( hasScrollBody: true, child: PeerTabPage(), ) ], - ).marginOnly(top: 2, left: 10, right: 10); + ).marginOnly( + top: 2, + left: isTablet(context) ? 24 : 10, + right: isTablet(context) ? 24 : 10, + ); } /// Callback for the connect button. diff --git a/flutter/lib/mobile/pages/home_page.dart b/flutter/lib/mobile/pages/home_page.dart index 651ec4f1727..c2d7cdfd813 100644 --- a/flutter/lib/mobile/pages/home_page.dart +++ b/flutter/lib/mobile/pages/home_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_hbb/web/settings_page.dart'; import 'package:get/get.dart'; import '../../common.dart'; import '../../common/widgets/chat_page.dart'; +import '../../consts.dart'; import '../../models/platform_model.dart'; import '../../models/state_model.dart'; import 'connection_page.dart'; @@ -150,6 +151,51 @@ class HomePageState extends State { ], ); } + if (kUseRdClientHomeShell) { + final tablet = isTablet(context); + final logo = tablet ? 44.0 : 32.0; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: Image.asset( + 'assets/dcs_norway_logo.png', + width: logo, + height: logo, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + errorBuilder: (_, __, ___) => const Icon(Icons.desktop_windows), + ), + ), + SizedBox(width: tablet ? 10 : 8), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DCS Norway', + style: TextStyle( + fontSize: tablet ? 20 : 16, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + Text( + 'Remote Desktop Client', + style: TextStyle( + fontSize: tablet ? 15 : 13, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ], + ), + ), + ], + ); + } return Text(bind.mainGetAppNameSync()); } } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index cbc99fd2bfa..51b42812362 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -9,7 +9,6 @@ import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; @@ -1023,10 +1022,8 @@ class _SettingsState extends State with WidgetsBindingObserver { ), leading: Icon(Icons.perm_identity)), SettingsTile( - title: Text(translate("Privacy Statement")), - onPressed: (context) => - launchUrlString('https://rustdesk.com/privacy.html'), - leading: Icon(Icons.privacy_tip), + title: Text('Creator: Chesster'), + leading: Icon(Icons.info_outline), ) ], ), @@ -1133,21 +1130,10 @@ void showThemeSettings(OverlayDialogManager dialogManager) async { void showAbout(OverlayDialogManager dialogManager) { dialogManager.show((setState, close, context) { return CustomAlertDialog( - title: Text(translate('About RustDesk')), + title: Text('About DCS Norway Remote Desktop Client'), content: Wrap(direction: Axis.vertical, spacing: 12, children: [ - Text('Version: $version'), - InkWell( - onTap: () async { - const url = 'https://rustdesk.com/'; - await launchUrl(Uri.parse(url)); - }, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('rustdesk.com', - style: TextStyle( - decoration: TextDecoration.underline, - )), - )), + Text('Base version: RustDesk $version'), + Text('Creator: Chesster'), ]), actions: [], ); diff --git a/flutter/lib/models/ab_model.dart b/flutter/lib/models/ab_model.dart index 001887c0c10..1c3cf4a37b8 100644 --- a/flutter/lib/models/ab_model.dart +++ b/flutter/lib/models/ab_model.dart @@ -214,6 +214,7 @@ class AbModel { _callbackPeerUpdate(); if (listInitialized && current.initialized) { _saveCache(); + await syncAliasesToLocalPeerConfigs(); } } @@ -651,6 +652,7 @@ class AbModel { _deserializeCache(data); legacyMode.value = addressbooks.containsKey(_legacyAddressBookName); trySetCurrentToLast(); + await syncAliasesToLocalPeerConfigs(); } catch (e) { debugPrint("load ab cache: $e"); } @@ -862,6 +864,107 @@ class AbModel { } } + // Prefer peer-level password from any AB / Available devices; fall back to shared default. + String? getPasswordForPeerId(String id) { + final normalized = id.replaceAll(' ', ''); + for (final ab in addressbooks.values) { + final peer = ab.peers.firstWhereOrNull( + (e) => e.id == id || e.id.replaceAll(' ', '') == normalized); + if (peer != null && peer.password.isNotEmpty) { + return peer.password; + } + } + // Available devices (group/API peers) may carry password on the card. + final groupPeer = gFFI.groupModel.peers.firstWhereOrNull( + (e) => e.id == id || e.id.replaceAll(' ', '') == normalized); + if (groupPeer != null && groupPeer.password.isNotEmpty) { + return groupPeer.password; + } + return getdefaultSharedPassword(); + } + + // Betterdesk/AB display name for Recent/Fav cards when local alias is empty. + String getAliasForPeerId(String id) { + final normalized = id.replaceAll(' ', ''); + for (final ab in addressbooks.values) { + final peer = ab.peers.firstWhereOrNull( + (e) => e.id == id || e.id.replaceAll(' ', '') == normalized); + if (peer != null) { + if (peer.alias.isNotEmpty) { + return peer.alias; + } + if (peer.note.isNotEmpty) { + return peer.note; + } + } + } + return ''; + } + + // Devices/group list often has Betterdesk note/alias when AB does not. + String getGroupDisplayNameForPeerId(String id) { + final normalized = id.replaceAll(' ', ''); + final peer = gFFI.groupModel.peers.firstWhereOrNull( + (e) => e.id == id || e.id.replaceAll(' ', '') == normalized); + if (peer == null) { + return ''; + } + if (peer.alias.isNotEmpty) { + return peer.alias; + } + if (peer.note.isNotEmpty) { + return peer.note; + } + return ''; + } + + String getDisplayNameForPeerId(String id) { + final ab = getAliasForPeerId(id); + if (ab.isNotEmpty) { + return ab; + } + return getGroupDisplayNameForPeerId(id); + } + + // Write AB/Devices display names into local peer configs for Recent/Fav. + Future syncAliasesToLocalPeerConfigs() async { + final names = {}; + void putName(String id, String name) { + if (name.isEmpty) return; + names[id.replaceAll(' ', '')] = name; + } + + for (final peer in allPeers()) { + putName(peer.id, peer.alias.isNotEmpty ? peer.alias : peer.note); + } + for (final peer in gFFI.groupModel.peers) { + final key = peer.id.replaceAll(' ', ''); + if (names.containsKey(key)) continue; + putName(peer.id, peer.alias.isNotEmpty ? peer.alias : peer.note); + } + if (names.isEmpty) { + return; + } + + // Persist using the Recent/Fav peer id so PeerConfig paths match. + for (final model in [gFFI.recentPeersModel, gFFI.favoritePeersModel]) { + for (final peer in model.peers) { + final name = names[peer.id.replaceAll(' ', '')]; + if (name == null || name.isEmpty) continue; + try { + final local = bind.mainGetPeerOptionSync(id: peer.id, key: 'alias'); + if (local != name) { + await bind.mainSetPeerAlias(id: peer.id, alias: name); + } + } catch (_) {} + } + } + + // Update cards immediately so logout does not flash hostname. + gFFI.recentPeersModel.applyAliases(names); + gFFI.favoritePeersModel.applyAliases(names); + } + // #endregion } @@ -1101,7 +1204,6 @@ class LegacyAb extends BaseAb { bool full = false; for (var p in ps) { if (!isFull()) { - p.remove('password'); // legacy ab ignore password final index = peers.indexWhere((e) => e.id == p['id']); if (index >= 0) { _merge(Peer.fromJson(p), peers[index]); @@ -1205,6 +1307,7 @@ class LegacyAb extends BaseAb { void _merge(Peer r, Peer p) { p.hash = r.hash.isEmpty ? p.hash : r.hash; + p.password = r.password.isEmpty ? p.password : r.password; p.username = r.username.isEmpty ? p.username : r.username; p.hostname = r.hostname.isEmpty ? p.hostname : r.hostname; p.platform = r.platform.isEmpty ? p.platform : r.platform; diff --git a/flutter/lib/models/group_model.dart b/flutter/lib/models/group_model.dart index d55cff453a0..1de8190b275 100644 --- a/flutter/lib/models/group_model.dart +++ b/flutter/lib/models/group_model.dart @@ -98,6 +98,7 @@ class GroupModel { .toList(); groupLoadError.value = ''; _callbackPeerUpdate(); + await gFFI.abModel.syncAliasesToLocalPeerConfigs(); } Future _getDeviceGroups( diff --git a/flutter/lib/models/peer_model.dart b/flutter/lib/models/peer_model.dart index 3171b373a90..247d2d7a79c 100644 --- a/flutter/lib/models/peer_model.dart +++ b/flutter/lib/models/peer_model.dart @@ -31,22 +31,45 @@ class Peer { } Peer.fromJson(Map json) - : id = json['id'] ?? '', - hash = json['hash'] ?? '', - password = json['password'] ?? '', - username = json['username'] ?? '', - hostname = json['hostname'] ?? '', - platform = json['platform'] ?? '', - alias = json['alias'] ?? '', + : id = json['id']?.toString() ?? '', + hash = json['hash']?.toString() ?? '', + // Betterdesk/legacy AB may store plaintext under password or info.password. + password = _passwordFromJson(json), + username = json['username']?.toString() ?? '', + hostname = json['hostname']?.toString() ?? '', + platform = json['platform']?.toString() ?? '', + // Betterdesk may send display_name when alias is unset. + alias = _stringOrEmpty(json['alias']).isNotEmpty + ? _stringOrEmpty(json['alias']) + : _stringOrEmpty(json['display_name']), tags = json['tags'] ?? [], forceAlwaysRelay = json['forceAlwaysRelay'] == 'true', - rdpPort = json['rdpPort'] ?? '', - rdpUsername = json['rdpUsername'] ?? '', - loginName = json['loginName'] ?? '', - device_group_name = json['device_group_name'] ?? '', - note = json['note'] is String ? json['note'] : '', + rdpPort = json['rdpPort']?.toString() ?? '', + rdpUsername = json['rdpUsername']?.toString() ?? '', + loginName = json['loginName']?.toString() ?? '', + device_group_name = json['device_group_name']?.toString() ?? '', + note = json['note'] is String ? json['note'] : (json['note']?.toString() ?? ''), sameServer = json['same_server']; + static String _stringOrEmpty(dynamic value) { + if (value is String) { + return value; + } + return value?.toString() ?? ''; + } + + static String _passwordFromJson(Map json) { + final direct = _stringOrEmpty(json['password']); + if (direct.isNotEmpty) { + return direct; + } + final info = json['info']; + if (info is Map) { + return _stringOrEmpty(info['password']); + } + return ''; + } + Map toJson() { return { "id": id, @@ -79,6 +102,10 @@ class Peer { if (includingHash) { res['hash'] = hash; } + // Keep Betterdesk plaintext passwords across cache/push round-trips. + if (password.isNotEmpty) { + res['password'] = password; + } return res; } @@ -216,6 +243,23 @@ class Peers extends ChangeNotifier { return peers.length; } + // Keep Betterdesk display names on Recent/Fav cards after account logout. + void applyAliases(Map normalizedIdToAlias) { + var changed = false; + for (final peer in peers) { + final name = normalizedIdToAlias[peer.id.replaceAll(' ', '')]; + if (name == null || name.isEmpty || peer.alias == name) { + continue; + } + peer.alias = name; + changed = true; + } + if (changed) { + event = UpdateEvent.load; + notifyListeners(); + } + } + void _updateOnlineState(Map evt) { int changedCount = 0; evt['onlines'].split(',').forEach((online) { diff --git a/flutter/lib/models/peer_tab_model.dart b/flutter/lib/models/peer_tab_model.dart index e5fa7fb03d5..481291bfc48 100644 --- a/flutter/lib/models/peer_tab_model.dart +++ b/flutter/lib/models/peer_tab_model.dart @@ -28,7 +28,7 @@ class PeerTabModel with ChangeNotifier { 'Favorites', 'Discovered', 'Address book', - 'Accessible devices', + 'Available devices', ]; static const List icons = [ Icons.access_time_filled, @@ -37,14 +37,21 @@ class PeerTabModel with ChangeNotifier { IconFont.addressBook, IconFont.deviceGroupFill, ]; + // Pure connection client: only Accessible devices. List isEnabled = List.from([ - true, - true, - !isWeb && bind.mainGetLocalOption(key: "disable-discovery-panel") != "Y", - !(bind.isDisableAb() || bind.isDisableAccount()), + false, + false, + false, + false, !(bind.isDisableGroupPanel() || bind.isDisableAccount()), ]); - final List _isVisible = List.filled(maxTabCount, true, growable: false); + final List _isVisible = List.from([ + false, + false, + false, + false, + true, + ]); List get isVisibleEnabled => () { final list = _isVisible.toList(); for (int i = 0; i < maxTabCount; i++) { @@ -68,52 +75,11 @@ class PeerTabModel with ChangeNotifier { String get lastId => _lastId; PeerTabModel(this.parent) { - // visible - try { - final option = bind.getLocalFlutterOption(k: kOptionPeerTabVisible); - if (option.isNotEmpty) { - List decodeList = jsonDecode(option); - if (decodeList.length == _isVisible.length) { - for (int i = 0; i < _isVisible.length; i++) { - if (decodeList[i] is bool) { - _isVisible[i] = decodeList[i]; - } - } - } - } - } catch (e) { - debugPrint("failed to get peer tab visible list:$e"); - } - // order - try { - final option = bind.getLocalFlutterOption(k: kOptionPeerTabOrder); - if (option.isNotEmpty) { - List decodeList = jsonDecode(option); - if (decodeList.length == maxTabCount) { - var sortedList = decodeList.toList(); - sortedList.sort(); - bool valid = true; - for (int i = 0; i < maxTabCount; i++) { - if (sortedList[i] is! int || sortedList[i] != i) { - valid = false; - } - } - if (valid) { - for (int i = 0; i < orders.length; i++) { - orders[i] = decodeList[i]; - } - } - } - } - } catch (e) { - debugPrint("failed to get peer tab order list: $e"); - } - // init currentTab - _currentTab = - int.tryParse(bind.getLocalFlutterOption(k: kOptionPeerTabIndex)) ?? 0; - if (_currentTab < 0 || _currentTab >= maxTabCount) { - _currentTab = 0; + // Pure client: only Accessible devices — ignore saved tab visibility. + for (int i = 0; i < maxTabCount; i++) { + _isVisible[i] = i == PeerTabIndex.group.index; } + _currentTab = PeerTabIndex.group.index; _trySetCurrentTabToFirstVisibleEnabled(); } @@ -216,21 +182,18 @@ class PeerTabModel with ChangeNotifier { } } - setTabVisible(int index, bool visible) { - if (index >= 0 && index < maxTabCount) { - if (_isVisible[index] != visible) { - _isVisible[index] = visible; - if (index == _currentTab && !visible) { - _trySetCurrentTabToFirstVisibleEnabled(); - } else if (visible && visibleEnabledOrderedIndexs.length == 1) { - _currentTab = index; - } - try { - bind.setLocalFlutterOption( - k: kOptionPeerTabVisible, v: jsonEncode(_isVisible)); - } catch (_) {} - notifyListeners(); - } + setTabVisible(int index, bool _) { + // Pure client: only Accessible devices; ignore other toggles. + if (index != PeerTabIndex.group.index) { + return; + } + if (!_isVisible[index]) { + _isVisible[index] = true; + try { + bind.setLocalFlutterOption( + k: kOptionPeerTabVisible, v: jsonEncode(_isVisible)); + } catch (_) {} + notifyListeners(); } } diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index 405a9faddc9..c2a29533351 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -86,7 +86,39 @@ class UserModel { reset(resetOther: status == 401); return; } - final data = json.decode(decode_http_response(response)); + final raw = decode_http_response(response).trim(); + // Empty/non-JSON bodies (some Betterdesk/proxy responses) must not + // surface as a blocking FormatException in the Devices tab. + if (raw.isEmpty) { + debugPrint( + 'refreshCurrentUser: empty body (HTTP $status), keeping local user'); + return; + } + final dynamic decoded; + try { + decoded = json.decode(raw); + } on FormatException catch (e) { + debugPrint('Failed to refreshCurrentUser: $e'); + if (getLocalUserInfo() != null) { + return; + } + if (networkError.value.isEmpty) { + networkError.value = e.toString(); + } + return; + } + if (decoded is! Map) { + debugPrint( + 'refreshCurrentUser: unexpected JSON type ${decoded.runtimeType}'); + if (getLocalUserInfo() != null) { + return; + } + if (networkError.value.isEmpty) { + networkError.value = 'Invalid currentUser response'; + } + return; + } + final data = Map.from(decoded); final error = data['error']; if (error != null) { // The only failure known to come from the server itself, so the @@ -136,6 +168,12 @@ class UserModel { } Future reset({bool resetOther = false}) async { + // Persist Betterdesk names onto Recent/Fav before AB/Devices are cleared. + if (resetOther) { + try { + await gFFI.abModel.syncAliasesToLocalPeerConfigs(); + } catch (_) {} + } await bind.mainSetLocalOption(key: 'access_token', value: ''); await bind.mainSetLocalOption(key: 'user_info', value: ''); if (resetOther) { @@ -169,6 +207,10 @@ class UserModel { Future logOut({String? apiServer}) async { final tag = gFFI.dialogManager.showLoading(translate('Waiting')); + // Capture display names while AB/Devices are still loaded. + try { + await gFFI.abModel.syncAliasesToLocalPeerConfigs(); + } catch (_) {} try { final url = apiServer ?? await bind.mainGetApiServer(); final authHeaders = getHttpHeaders(); diff --git a/flutter/linux/my_application.cc b/flutter/linux/my_application.cc index 210adba96bf..c72da8c6460 100644 --- a/flutter/linux/my_application.cc +++ b/flutter/linux/my_application.cc @@ -142,11 +142,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "rustdesk"); + gtk_header_bar_set_title(header_bar, "DCS Norway"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "rustdesk"); + gtk_window_set_title(window, "DCS Norway"); } // auto bdw = bitsdojo_window_from(window); // <--- add this line diff --git a/flutter/macos/Runner/AppIcon.icns b/flutter/macos/Runner/AppIcon.icns index 31a9c223b35..b541269e34f 100644 Binary files a/flutter/macos/Runner/AppIcon.icns and b/flutter/macos/Runner/AppIcon.icns differ diff --git a/flutter/macos/Runner/Configs/AppInfo.xcconfig b/flutter/macos/Runner/Configs/AppInfo.xcconfig index 4c6b155d646..83c5b9805c2 100644 --- a/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved. +PRODUCT_COPYRIGHT = DCS Norway Remote Desktop Client. Based on RustDesk. diff --git a/flutter/macos/Runner/Info.plist b/flutter/macos/Runner/Info.plist index 36ca81e18c0..2ccd39da285 100644 --- a/flutter/macos/Runner/Info.plist +++ b/flutter/macos/Runner/Info.plist @@ -12,8 +12,10 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleDisplayName + DCS Norway CFBundleName - $(PRODUCT_NAME) + DCS Norway CFBundlePackageType APPL CFBundleShortVersionString diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b9f8e1ccbe1..9be838a8eeb 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_hbb -description: Your Remote Desktop Software +description: DCS Norway Remote Desktop Client # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers -version: 1.4.9+67 +version: 1.4.15+73 environment: sdk: '^3.1.0' @@ -70,7 +70,7 @@ dependencies: desktop_drop: ^0.4.4 scroll_pos: ^0.4.0 debounce_throttle: ^2.0.0 - file_picker: ^5.1.0 + file_picker: 5.5.0 flutter_svg: ^2.0.5 uni_links: git: diff --git a/flutter/windows/runner/resources/app_icon.ico b/flutter/windows/runner/resources/app_icon.ico index bdf42cfdb18..77a9a803d77 100644 Binary files a/flutter/windows/runner/resources/app_icon.ico and b/flutter/windows/runner/resources/app_icon.ico differ diff --git a/libs/hbb_common b/libs/hbb_common deleted file mode 160000 index 69cea8dafee..00000000000 --- a/libs/hbb_common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 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..6ad3012fa96 --- /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 = { git = "https://github.com/rustdesk-org/confy" } +dirs-next = "2.0" +filetime = "0.2" +sodiumoxide = "0.2" +regex = "1.11" +tokio-socks = { git = "https://github.com/rustdesk-org/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 = { git = "https://github.com/rustdesk-org/sysinfo", branch = "rlim_max" } +# 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 = { git = "https://github.com/rustdesk-org/default_net" } +machine-uid = { git = "https://github.com/rustdesk-org/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..b2e5c015725 --- /dev/null +++ b/libs/hbb_common/protos/rendezvous.proto @@ -0,0 +1,269 @@ +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; + string switch_code = 11; +} + +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; + string switch_code = 11; +} + +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..8bdd01f9d4e --- /dev/null +++ b/libs/hbb_common/src/compress.rs @@ -0,0 +1,75 @@ +use std::{ + cell::RefCell, + io::{self, Read}, +}; +use zstd::bulk::Compressor; + +const MAX_DECOMPRESSED_SIZE: usize = 256 * 1024 * 1024; + +// 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 { + decompress_with_limit(data, MAX_DECOMPRESSED_SIZE).unwrap_or_default() +} + +fn decompress_with_limit(data: &[u8], limit: usize) -> io::Result> { + let decoder = zstd::Decoder::new(data)?; + let mut output = Vec::new(); + decoder + .take(limit.saturating_add(1) as u64) + .read_to_end(&mut output)?; + if output.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "decompressed data exceeds size limit", + )); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_data_larger_than_limit() { + let compressed = zstd::encode_all(&vec![0u8; 1025][..], 0).unwrap(); + assert!(decompress_with_limit(&compressed, 1024).is_err()); + } + + #[test] + fn accepts_data_at_limit() { + let input = vec![0u8; 1024]; + let compressed = zstd::encode_all(&input[..], 0).unwrap(); + assert_eq!( + decompress_with_limit(&compressed, input.len()).unwrap(), + input + ); + } +} diff --git a/libs/hbb_common/src/config.rs b/libs/hbb_common/src/config.rs new file mode 100644 index 00000000000..f52f778d71f --- /dev/null +++ b/libs/hbb_common/src/config.rs @@ -0,0 +1,4029 @@ +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(); + // Must match validate_install_app_name: [a-zA-Z0-9-]+ (no spaces). + // Installed exe becomes {APP_NAME}.exe (DCS-Norway-RD.exe). + pub static ref APP_NAME: RwLock = RwLock::new("DCS-Norway-RD".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_SCOPE_VIOLATION_CLOSE: &str = "allow-scope-violation-close"; + pub const OPTION_ALLOW_SCOPE_VIOLATION_ALARM: &str = "allow-scope-violation-alarm"; + 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_ID_WHITELIST: &str = "id-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_HIDE_RECORDING_BUTTON: &str = "hide-recording-button"; + pub const OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY: &str = + "windows-service-video-save-directory"; + 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_HIDE_RECORDING_BUTTON, + 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_SCOPE_VIOLATION_CLOSE, + OPTION_ALLOW_SCOPE_VIOLATION_ALARM, + 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_ID_WHITELIST, + OPTION_ALLOW_AUTO_DISCONNECT, + OPTION_AUTO_DISCONNECT_TIMEOUT, + OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN, + OPTION_ALLOW_AUTO_RECORD_INCOMING, + OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY, + 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..3dcc1c6dbb8 --- /dev/null +++ b/libs/hbb_common/src/fs.rs @@ -0,0 +1,1807 @@ +#[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(()) +} + +/// Validate an untrusted relative file name and existing path components before joining it. +pub 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..d9137656b23 --- /dev/null +++ b/libs/hbb_common/src/lib.rs @@ -0,0 +1,634 @@ +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); + // wrapping_* avoids debug-build panics on malformed punch payloads + let ip = (((number >> 49).wrapping_sub(tm)) as u32).to_le_bytes(); + let port = (number & 0xFFFFFF).wrapping_sub(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..618194a40c2 --- /dev/null +++ b/libs/hbb_common/src/mem.rs @@ -0,0 +1,109 @@ +use std::{ + alloc::{alloc, dealloc, handle_alloc_error, Layout}, + ops::Deref, + ptr::NonNull, +}; + +/// An owned byte buffer with a caller-specified allocation alignment. +pub struct AlignedU8Vec { + ptr: NonNull, + len: usize, + capacity: usize, + layout: Layout, +} + +// SAFETY: AlignedU8Vec uniquely owns its allocation and exposes mutation only +// through exclusive access, so moving or sharing it is as safe as Vec. +unsafe impl Send for AlignedU8Vec {} +unsafe impl Sync for AlignedU8Vec {} + +impl AlignedU8Vec { + /// Appends bytes without exceeding the capacity requested at allocation. + pub fn extend_from_slice(&mut self, data: &[u8]) { + assert!( + data.len() <= self.capacity - self.len, + "extend beyond fixed capacity" + ); + unsafe { + self.ptr + .as_ptr() + .add(self.len) + .copy_from_nonoverlapping(data.as_ptr(), data.len()); + } + self.len += data.len(); + } +} + +impl Deref for AlignedU8Vec { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) } + } +} + +impl Drop for AlignedU8Vec { + fn drop(&mut self) { + unsafe { + dealloc(self.ptr.as_ptr(), self.layout); + } + } +} + +/// Allocates an empty byte buffer with the requested capacity and alignment. +pub fn aligned_u8_vec(cap: usize, align: usize) -> AlignedU8Vec { + let layout = Layout::from_size_align(cap.max(1), align) + .expect("invalid aligned value, must be power of 2"); + unsafe { + let ptr = NonNull::new(alloc(layout)).unwrap_or_else(|| handle_alloc_error(layout)); + AlignedU8Vec { + ptr, + len: 0, + capacity: cap, + layout, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn is_send_and_sync() { + assert_send_sync::(); + } + + #[test] + fn preserves_alignment_and_contents() { + let mut data = aligned_u8_vec(10, 4096); + data.extend_from_slice(&[1, 2, 3, 4]); + assert_eq!(data.as_ptr() as usize % 4096, 0); + assert_eq!(&*data, &[1, 2, 3, 4]); + } + + #[test] + #[should_panic(expected = "extend beyond fixed capacity")] + fn rejects_extension_when_capacity_is_zero() { + let mut data = aligned_u8_vec(0, 4); + data.extend_from_slice(&[1]); + } + + #[test] + fn accumulates_across_multiple_extends() { + let mut data = aligned_u8_vec(8, 4); + data.extend_from_slice(&[1, 2, 3]); + data.extend_from_slice(&[4, 5]); + assert_eq!(&*data, &[1, 2, 3, 4, 5]); + } + + #[test] + #[should_panic(expected = "extend beyond fixed capacity")] + fn rejects_extension_past_partial_fill() { + let mut data = aligned_u8_vec(4, 4); + data.extend_from_slice(&[1, 2, 3]); + data.extend_from_slice(&[4, 5]); + } +} 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/portable/Cargo.toml b/libs/portable/Cargo.toml index aacfdcf9bb5..4a5df2d3fbb 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.4.15" edition = "2021" description = "RustDesk Remote Desktop" diff --git a/libs/portable/app_metadata.toml b/libs/portable/app_metadata.toml new file mode 100644 index 00000000000..0f785c7bd4e --- /dev/null +++ b/libs/portable/app_metadata.toml @@ -0,0 +1 @@ +timestamp = 1786383547812 diff --git a/res/128x128.png b/res/128x128.png index 306d505e998..819bc10a45a 100644 Binary files a/res/128x128.png and b/res/128x128.png differ diff --git a/res/128x128@2x.png b/res/128x128@2x.png index 89abf23a68a..892843fd1c2 100644 Binary files a/res/128x128@2x.png and b/res/128x128@2x.png differ diff --git a/res/32x32.png b/res/32x32.png index f9b1fc6b649..1a6c5b34be8 100644 Binary files a/res/32x32.png and b/res/32x32.png differ diff --git a/res/64x64.png b/res/64x64.png index 5bc11ece0f3..fd5d8cc6573 100644 Binary files a/res/64x64.png and b/res/64x64.png differ diff --git a/res/dcs-norway-logo-source.png b/res/dcs-norway-logo-source.png new file mode 100644 index 00000000000..27e24d5f449 Binary files /dev/null and b/res/dcs-norway-logo-source.png differ diff --git a/res/icon.ico b/res/icon.ico index eedb92614ca..77a9a803d77 100644 Binary files a/res/icon.ico and b/res/icon.ico differ diff --git a/res/icon.png b/res/icon.png index 00bcf5a5f30..919b566f89a 100644 Binary files a/res/icon.png and b/res/icon.png differ diff --git a/res/logo-header.svg b/res/logo-header.svg index 9712636bf85..4d2998c6682 100644 --- a/res/logo-header.svg +++ b/res/logo-header.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/res/logo.svg b/res/logo.svg index 965218c95de..52a49fe5fbb 100644 --- a/res/logo.svg +++ b/res/logo.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/res/mac-icon.png b/res/mac-icon.png index e42ee2c420b..27e24d5f449 100644 Binary files a/res/mac-icon.png and b/res/mac-icon.png differ diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index ea7dd8a4059..baa421cb4d5 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -12,7 +12,7 @@ Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.s # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ %description -The best open-source remote desktop client software, written in Rust. +DCS Norway Remote Desktop Client. %prep # we have no source, so nothing here diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index 272148d9190..00833acb266 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -12,7 +12,7 @@ Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.s # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ %description -The best open-source remote desktop client software, written in Rust. +DCS Norway Remote Desktop Client. %prep # we have no source, so nothing here diff --git a/res/rustdesk-link.desktop b/res/rustdesk-link.desktop index c7a9bd5cb7a..f49889c14ac 100644 --- a/res/rustdesk-link.desktop +++ b/res/rustdesk-link.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Name=RustDesk +Name=DCS Norway NoDisplay=true MimeType=x-scheme-handler/rustdesk; TryExec=rustdesk diff --git a/res/rustdesk.desktop b/res/rustdesk.desktop index 4e7d14fe51d..ccf7de5745e 100644 --- a/res/rustdesk.desktop +++ b/res/rustdesk.desktop @@ -1,7 +1,7 @@ [Desktop Entry] -Name=RustDesk -GenericName=Remote Desktop -Comment=Remote Desktop +Name=DCS Norway +GenericName=Remote Desktop Client +Comment=DCS Norway Remote Desktop Client Exec=rustdesk %u Icon=rustdesk Terminal=false diff --git a/res/rustdesk.service b/res/rustdesk.service index 1b3feb194a9..826a2f83800 100644 --- a/res/rustdesk.service +++ b/res/rustdesk.service @@ -1,5 +1,5 @@ [Unit] -Description=RustDesk +Description=DCS Norway Requires=network.target After=systemd-user-sessions.service diff --git a/res/scalable.svg b/res/scalable.svg index 50cab67a3e0..52a49fe5fbb 100644 --- a/res/scalable.svg +++ b/res/scalable.svg @@ -1,88 +1,3 @@ - - + + + diff --git a/res/tray-icon.ico b/res/tray-icon.ico index df8bdaccbd9..a9c17e746d9 100644 Binary files a/res/tray-icon.ico and b/res/tray-icon.ico differ diff --git a/src/client.rs b/src/client.rs index e1e4c803443..de64922dde7 100644 --- a/src/client.rs +++ b/src/client.rs @@ -690,6 +690,11 @@ impl Client { connect_timeout = MIN; } } + // Direct peer connect is not usable in our deployments; fail fast to relay. + const DIRECT_TO_RELAY_TIMEOUT: u64 = 3_000; + if connect_timeout > DIRECT_TO_RELAY_TIMEOUT { + connect_timeout = DIRECT_TO_RELAY_TIMEOUT; + } log::info!("peer address: {}, timeout: {}", peer, connect_timeout); let start = std::time::Instant::now(); diff --git a/src/common.rs b/src/common.rs index 592ab2a45e3..a949939c751 100644 --- a/src/common.rs +++ b/src/common.rs @@ -889,6 +889,7 @@ pub fn get_sysinfo() -> serde_json::Value { "memory": format!("{memory}GB"), "os": os, "hostname": hostname, + "app_name": get_app_name(), }); #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -939,19 +940,64 @@ pub fn is_modifier(evt: &KeyEvent) -> bool { } pub fn check_software_update() { - if is_custom_client() { - return; - } let opt = LocalConfig::get_option(keys::OPTION_ENABLE_CHECK_UPDATE); + // Custom (DCS) clients always may check GitHub Releases when the option is on. + // Default for enable-check-update is Y unless user disabled it. if config::option2bool(keys::OPTION_ENABLE_CHECK_UPDATE, &opt) { std::thread::spawn(move || allow_err!(do_check_software_update())); } } -// No need to check `danger_accept_invalid_cert` for now. -// Because the url is always `https://api.rustdesk.com/version/latest`. +// Stock RustDesk posts to api.rustdesk.com; DCS clients query GitHub Releases instead. #[tokio::main(flavor = "current_thread")] pub async fn do_check_software_update() -> hbb_common::ResultType<()> { + if is_custom_client() { + return do_check_dcs_software_update().await; + } + do_check_stock_software_update().await +} + +async fn do_check_dcs_software_update() -> hbb_common::ResultType<()> { + #[cfg(target_os = "ios")] + { + *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + return Ok(()); + } + #[cfg(not(target_os = "ios"))] + { + match crate::dcs_update::check_dcs_github_update(crate::VERSION).await? { + Some(response_url) => { + #[cfg(feature = "flutter")] + { + let mut m = HashMap::new(); + m.insert("name", "check_software_update_finish"); + m.insert("url", response_url.as_str()); + if let Ok(data) = serde_json::to_string(&m) { + let _ = + crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); + } + } + *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; + } + None => { + *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + #[cfg(feature = "flutter")] + { + let mut m = HashMap::new(); + m.insert("name", "check_software_update_finish"); + m.insert("url", ""); + if let Ok(data) = serde_json::to_string(&m) { + let _ = + crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); + } + } + } + } + Ok(()) + } +} + +async fn do_check_stock_software_update() -> hbb_common::ResultType<()> { let (request, url) = hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); let proxy_conf = Config::get_socks(); diff --git a/src/dcs_update.rs b/src/dcs_update.rs new file mode 100644 index 00000000000..a4d745edd29 --- /dev/null +++ b/src/dcs_update.rs @@ -0,0 +1,274 @@ +//! DCS Norway custom-client update check against GitHub Releases. +//! Stock RustDesk queries api.rustdesk.com; branded builds skip that path. + +use hbb_common::{bail, get_version_number, log, ResultType}; +use serde::Deserialize; + +/// GitHub owner/repo that publishes DCS Windows installers. +pub const DCS_RELEASES_OWNER: &str = "Chesster1981"; +pub const DCS_RELEASES_REPO: &str = "RustDesk"; + +const RELEASES_API: &str = + "https://api.github.com/repos/Chesster1981/RustDesk/releases?per_page=10"; + +#[derive(Debug, Deserialize)] +struct GhRelease { + tag_name: String, + html_url: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GhAsset { + name: String, +} + +/// Strip optional `dcs-` / `v` prefixes so `get_version_number` can parse. +pub fn normalize_release_version(tag: &str) -> String { + let mut t = tag.trim(); + if let Some(rest) = t.strip_prefix("dcs-") { + t = rest; + } + if let Some(rest) = t.strip_prefix('v') { + t = rest; + } + t.to_string() +} + +fn is_dcs_client_asset(name: &str, tag: &str) -> bool { + let n = name.to_ascii_lowercase(); + let tag = tag.to_ascii_lowercase(); + let branded = n.starts_with("dcs-norway-rdc-"); + let stock = n.starts_with(&format!("rustdesk-{tag}-")) + || n.starts_with(&format!("rustdesk-{tag}.")); + if !(branded || stock) { + return false; + } + n.ends_with(".exe") + || n.ends_with(".apk") + || n.ends_with(".deb") + || n.ends_with(".dmg") + || n.ends_with(".rpm") + || n.ends_with(".appimage") +} + +fn release_has_client_asset(rel: &GhRelease) -> bool { + let tag = rel.tag_name.as_str(); + rel.assets.iter().any(|a| is_dcs_client_asset(&a.name, tag)) +} + +/// Pick the newest published release that has a DCS client installer asset. +pub fn pick_latest_windows_release(releases: &[GhRelease]) -> Option<&GhRelease> { + releases + .iter() + .find(|r| !r.draft && release_has_client_asset(r)) +} + +/// Query GitHub Releases and return the release page URL when a newer build exists. +pub async fn check_dcs_github_update(current_version: &str) -> ResultType> { + let client = reqwest::Client::builder() + .user_agent(format!( + "DCS-Norway-RD-updater/{}", + current_version.trim() + )) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let resp = client + .get(RELEASES_API) + .header("Accept", "application/vnd.github+json") + .send() + .await?; + if !resp.status().is_success() { + bail!("GitHub releases HTTP {}", resp.status()); + } + let releases: Vec = resp.json().await?; + let Some(latest) = pick_latest_windows_release(&releases) else { + log::debug!("No DCS client release asset found on GitHub"); + return Ok(None); + }; + + let remote = normalize_release_version(&latest.tag_name); + let local = normalize_release_version(current_version); + if get_version_number(&remote) > get_version_number(&local) { + log::info!( + "DCS update available: local={} remote={} url={}", + local, + remote, + latest.html_url + ); + Ok(Some(latest.html_url.clone())) + } else { + log::debug!( + "DCS client up to date: local={} remote={}", + local, + remote + ); + Ok(None) + } +} + +/// Whether a GitHub download URL is allowed for this branded client. +pub fn is_allowed_dcs_update_url(owner: &str, repo: &str) -> bool { + owner.eq_ignore_ascii_case(DCS_RELEASES_OWNER) && repo.eq_ignore_ascii_case(DCS_RELEASES_REPO) +} + +/// Local path for a GitHub release asset. Shared by Windows, Linux, and Android. +pub fn get_update_download_file_from_url(url: &str) -> Option { + use std::path::{Component, Path}; + + let parsed = url::Url::parse(url).ok()?; + if !url.starts_with("https://github.com/") + || parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + + let mut segments = parsed.path_segments()?; + let owner = segments.next()?; + let repo = segments.next()?; + let releases = segments.next()?; + let download = segments.next()?; + let tag = segments.next()?; + let filename = segments.next()?; + let no_extra_segments = segments.next().is_none(); + + let stock_ok = owner == "rustdesk" && repo == "rustdesk"; + let dcs_ok = is_allowed_dcs_update_url(owner, repo); + if !(stock_ok || dcs_ok) + || releases != "releases" + || download != "download" + || tag.is_empty() + || !no_extra_segments + || !is_plain_update_filename(filename) + { + return None; + } + + Some(std::env::temp_dir().join(filename)) +} + +fn is_plain_update_filename(filename: &str) -> bool { + use std::path::{Component, Path}; + + if filename.is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename.contains(':') + { + return false; + } + + let mut components = Path::new(filename).components(); + matches!( + components.next(), + Some(Component::Normal(name)) if name.to_str() == Some(filename) + ) && components.next().is_none() +} + +pub fn get_download_file_from_url(url: &str) -> Option { + get_update_download_file_from_url(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_prefixes() { + assert_eq!(normalize_release_version("1.4.10"), "1.4.10"); + assert_eq!(normalize_release_version("v1.4.10"), "1.4.10"); + assert_eq!(normalize_release_version("dcs-1.4.10"), "1.4.10"); + assert_eq!(normalize_release_version("dcs-v1.4.10"), "1.4.10"); + assert_eq!(normalize_release_version("1.4.9-2"), "1.4.9-2"); + } + + #[test] + fn pick_skips_draft_and_missing_asset() { + let releases = vec![ + GhRelease { + tag_name: "1.4.11".into(), + html_url: "https://github.com/Chesster1981/RustDesk/releases/tag/1.4.11".into(), + draft: true, + prerelease: false, + assets: vec![GhAsset { + name: "rustdesk-1.4.11-x86_64.exe".into(), + }], + }, + GhRelease { + tag_name: "1.4.10".into(), + html_url: "https://github.com/Chesster1981/RustDesk/releases/tag/1.4.10".into(), + draft: false, + prerelease: true, + assets: vec![GhAsset { + name: "rustdesk-1.4.10-x86_64.exe".into(), + }], + }, + ]; + let picked = pick_latest_windows_release(&releases).unwrap(); + assert_eq!(picked.tag_name, "1.4.10"); + } + + #[test] + fn pick_accepts_linux_and_macos_assets() { + let releases = vec![ + GhRelease { + tag_name: "1.4.11".into(), + html_url: "https://github.com/Chesster1981/RustDesk/releases/tag/1.4.11".into(), + draft: false, + prerelease: false, + assets: vec![GhAsset { + name: "rustdesk-1.4.11-x86_64.deb".into(), + }], + }, + ]; + assert_eq!( + pick_latest_windows_release(&releases).unwrap().tag_name, + "1.4.11" + ); + + let dmg = vec![GhRelease { + tag_name: "1.4.12".into(), + html_url: "https://github.com/Chesster1981/RustDesk/releases/tag/1.4.12".into(), + draft: false, + prerelease: false, + assets: vec![GhAsset { + name: "DCS-Norway-RDC-1.4.12-aarch64.dmg".into(), + }], + }]; + assert_eq!( + pick_latest_windows_release(&dmg).unwrap().tag_name, + "1.4.12" + ); + } + + #[test] + fn allowlist_owner_repo() { + assert!(is_allowed_dcs_update_url("Chesster1981", "RustDesk")); + assert!(is_allowed_dcs_update_url("chesster1981", "rustdesk")); + assert!(!is_allowed_dcs_update_url("rustdesk", "rustdesk")); + } + + #[test] + fn download_url_accepts_windows_linux_android_assets() { + for name in [ + "rustdesk-1.4.14-x86_64.exe", + "rustdesk-1.4.14-x86_64.deb", + "rustdesk-1.4.14-aarch64.apk", + ] { + let url = format!( + "https://github.com/Chesster1981/RustDesk/releases/download/1.4.14/{name}" + ); + let file = get_download_file_from_url(&url).expect(name); + assert_eq!(file.file_name().and_then(|n| n.to_str()), Some(name)); + } + } +} diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9b73c4cd4a1..20196bdec9c 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1756,7 +1756,8 @@ pub fn main_get_last_remote_id() -> String { } pub fn main_get_software_update_url() { - crate::common::check_software_update(); + // Explicit UI / timer trigger: always run the check (startup still honors enable-check-update). + std::thread::spawn(move || hbb_common::allow_err!(crate::common::do_check_software_update())); } pub fn main_get_home_dir() -> String { @@ -2885,7 +2886,34 @@ pub fn main_get_common(key: String) -> String { "error:unsupported".to_owned() }; } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] + { + return if cfg!(target_arch = "x86_64") { + format!("rustdesk-{_version}-x86_64.deb") + } else if cfg!(target_arch = "aarch64") { + format!("rustdesk-{_version}-aarch64.deb") + } else { + "error:unsupported".to_owned() + }; + } + #[cfg(target_os = "android")] + { + return if cfg!(target_arch = "aarch64") { + format!("rustdesk-{_version}-aarch64.apk") + } else if cfg!(target_arch = "arm") { + format!("rustdesk-{_version}-armv7.apk") + } else if cfg!(target_arch = "x86_64") { + format!("rustdesk-{_version}-x86_64.apk") + } else { + "error:unsupported".to_owned() + }; + } + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "android" + )))] { "error:unsupported".to_owned() } @@ -2932,9 +2960,9 @@ pub fn main_set_common(_key: String, _value: String) { ); }); } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(not(target_os = "ios"))] { - use crate::updater::get_download_file_from_url; + use crate::dcs_update::get_download_file_from_url; if _key == "download-new-version" { let download_url = _value.clone(); let event_key = "download-new-version".to_owned(); @@ -2968,7 +2996,7 @@ pub fn main_set_common(_key: String, _value: String) { // 1.4.0 does not support "--update" // But we can assume that the new version supports it. - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] match crate::platform::update_to(f) { Ok(_) => { log::info!("Update process is launched successfully!"); @@ -2978,6 +3006,15 @@ pub fn main_set_common(_key: String, _value: String) { fs::remove_file(f).ok(); } } + #[cfg(target_os = "android")] + { + let mut m = HashMap::new(); + m.insert("name", "install-update-file"); + m.insert("path", f); + if let Ok(data) = serde_json::to_string(&m) { + let _ = flutter::push_global_event(flutter::APP_TYPE_MAIN, data); + } + } } } } else if _key == "extract-update-dmg" { diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index d78f901945d..203b755af72 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -236,6 +236,8 @@ async fn start_hbbs_sync_async() { v["id"] = json!(id); v["uuid"] = json!(crate::encode64(hbb_common::get_uuid())); v["ver"] = json!(hbb_common::get_version_number(crate::VERSION)); + v["os"] = json!(std::env::consts::OS); + v["app_name"] = json!(crate::get_app_name()); if !conns.is_empty() { v["conns"] = json!(conns); } diff --git a/src/lib.rs b/src/lib.rs index 49cb2b7e97c..a333218c0bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,10 @@ mod whiteboard; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod updater; +/// DCS Norway GitHub Releases update check (custom client). +#[cfg(not(target_os = "ios"))] +mod dcs_update; + mod ui_cm_interface; mod ui_interface; mod ui_session_interface; diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 68a005ff75b..9cbcf722bd5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1557,6 +1557,11 @@ pub fn quit_gui() { unsafe { gtk_main_quit() }; } +pub fn update_to(file: &str) -> ResultType<()> { + Command::new("xdg-open").arg(file).spawn()?; + Ok(()) +} + /* pub fn exec_privileged(args: &[&str]) -> ResultType { Ok(Command::new("pkexec").args(args).spawn()?) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5253895ddc6..6bffc6b5e16 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1711,6 +1711,7 @@ copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\ chcp 65001 md \"{path}\" {copy_exe} +{rename_exe} reg add {subkey} /f reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{display_icon}\" reg add {subkey} /f /v DisplayName /t REG_SZ /d \"{app_name}\" @@ -1749,6 +1750,7 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" sleep = if debug { "timeout 300" } else { "" }, dels = if debug { "" } else { &dels }, copy_exe = copy_exe_cmd(&src_exe, &exe, &path)?, + rename_exe = rename_exe_cmd(&src_exe, &path)?, import_config = get_import_config(&exe), ); run_cmds(cmds, debug, "install")?; diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 94fde439263..f58609b244b 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -52,6 +52,8 @@ pub struct LoginDeviceInfo { pub os: String, pub r#type: String, pub name: String, + /// Sent to BetterDesk `/api/login` for Windows client allowlisting. + pub app_name: String, } lazy_static::lazy_static! { @@ -1330,6 +1332,7 @@ pub fn get_login_device_info() -> LoginDeviceInfo { os: std::env::consts::OS.to_owned(), r#type: "client".to_owned(), name: crate::common::hostname(), + app_name: crate::get_app_name(), } } diff --git a/src/updater.rs b/src/updater.rs index beab97e5375..96975cae4e3 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -2,7 +2,7 @@ use crate::{common::do_check_software_update, hbbs_http::create_http_client_with use hbb_common::{bail, config, log, ResultType}; use std::{ io::Write, - path::{Component, Path, PathBuf}, + path::PathBuf, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, @@ -352,62 +352,7 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { } } -pub fn get_update_download_file_from_url(url: &str) -> Option { - let parsed = url::Url::parse(url).ok()?; - // Check the raw prefix before Url normalizes default ports. - if !url.starts_with("https://github.com/") - || parsed.scheme() != "https" - || parsed.host_str() != Some("github.com") - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.port().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return None; - } - - let mut segments = parsed.path_segments()?; - let owner = segments.next()?; - let repo = segments.next()?; - let releases = segments.next()?; - let download = segments.next()?; - let tag = segments.next()?; - let filename = segments.next()?; - - if owner != "rustdesk" - || repo != "rustdesk" - || releases != "releases" - || download != "download" - || tag.is_empty() - || segments.next().is_some() - || !is_plain_update_filename(filename) - { - return None; - } - - Some(std::env::temp_dir().join(filename)) -} - -fn is_plain_update_filename(filename: &str) -> bool { - if filename.is_empty() - || filename.contains('/') - || filename.contains('\\') - || filename.contains(':') - { - return false; - } - - let mut components = Path::new(filename).components(); - matches!( - components.next(), - Some(Component::Normal(name)) if name.to_str() == Some(filename) - ) && components.next().is_none() -} - -pub fn get_download_file_from_url(url: &str) -> Option { - get_update_download_file_from_url(url) -} +pub use crate::dcs_update::{get_download_file_from_url, get_update_download_file_from_url}; /// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) /// from every logged-in user's --server process via IPC. @@ -671,6 +616,19 @@ mod tests { ); } + #[test] + fn update_download_file_accepts_dcs_fork_github_asset_urls() { + let file = get_download_file_from_url( + "https://github.com/Chesster1981/RustDesk/releases/download/1.4.10/rustdesk-1.4.10-x86_64.exe", + ) + .expect("valid DCS GitHub release asset URL"); + + assert_eq!( + file.file_name().and_then(|name| name.to_str()), + Some("rustdesk-1.4.10-x86_64.exe") + ); + } + #[test] fn update_download_file_rejects_untrusted_or_malformed_urls() { for url in [ diff --git a/tools/apply_dcs_logo.py b/tools/apply_dcs_logo.py new file mode 100644 index 00000000000..ddece392d4a --- /dev/null +++ b/tools/apply_dcs_logo.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Apply the DCS Norway logo to Windows, Android, and Linux icon assets.""" + +from __future__ import annotations + +import base64 +import io +import struct +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +LOCAL_SOURCE = ROOT / "res" / "dcs-norway-logo-source.png" +# User-uploaded original (RGB, white corners). Prefer this over a previously punched source. +ORIGINAL_UPLOAD = Path("/opt/cursor/artifacts/assets/dcs_norway_logo_source.png") + +IOS_ICONS = [ + ("Icon-App-20x20@1x.png", 20, False), + ("Icon-App-20x20@2x.png", 40, False), + ("Icon-App-20x20@3x.png", 60, False), + ("Icon-App-29x29@1x.png", 29, False), + ("Icon-App-29x29@2x.png", 58, False), + ("Icon-App-29x29@3x.png", 87, False), + ("Icon-App-40x40@1x.png", 40, False), + ("Icon-App-40x40@2x.png", 80, False), + ("Icon-App-40x40@3x.png", 120, False), + ("Icon-App-60x60@2x.png", 120, False), + ("Icon-App-60x60@3x.png", 180, False), + ("Icon-App-76x76@1x.png", 76, False), + ("Icon-App-76x76@2x.png", 152, False), + ("Icon-App-83.5x83.5@2x.png", 167, False), + ("Icon-App-1024x1024@1x.png", 1024, True), +] + + +def circular_knockout(img: Image.Image) -> Image.Image: + # Inscribed circle only. Flood-filling near-white also deletes the sky + # because the top of the ring is white and connects to the corners. + rgb = img.convert("RGB") + w, h = rgb.size + opaque = rgb.convert("RGBA") + mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(mask).ellipse((0, 0, w - 1, h - 1), fill=255) + out = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + out.paste(opaque, (0, 0), mask) + return out + + +def load_source() -> Image.Image: + src = ORIGINAL_UPLOAD if ORIGINAL_UPLOAD.exists() else LOCAL_SOURCE + if not src.exists(): + raise SystemExit(f"missing logo source {src}") + img = Image.open(src) + if img.size != (1024, 1024): + img = img.resize((1024, 1024), Image.Resampling.LANCZOS) + return circular_knockout(img) + + +def save_png(img: Image.Image, path: Path, size: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + out = img.resize((size, size), Image.Resampling.LANCZOS) + out.save(path, format="PNG", optimize=True) + print(f"PNG {size:4d} -> {path.relative_to(ROOT)} ({path.stat().st_size} bytes)") + + +def save_ico(img: Image.Image, path: Path, sizes: list[int]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + icons = [img.resize((s, s), Image.Resampling.LANCZOS) for s in sizes] + icons[-1].save(path, format="ICO", sizes=[(s, s) for s in sizes]) + print(f"ICO {sizes} -> {path.relative_to(ROOT)} ({path.stat().st_size} bytes)") + + +def write_icon_svg(img: Image.Image, path: Path, size: int = 128) -> None: + write_icon_svg_size(img, path, size) + + +def write_icon_svg_size(img: Image.Image, path: Path, size: int) -> None: + buf = io.BytesIO() + img.resize((size, size), Image.Resampling.LANCZOS).save(buf, format="PNG", optimize=True) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + path.write_text( + ( + f'\n' + f' \n' + f"\n" + ), + encoding="utf-8", + ) + print(f"SVG embed -> {path.relative_to(ROOT)} ({path.stat().st_size} bytes)") + + +def flatten_rgb(img: Image.Image, size: int) -> Image.Image: + rgba = img.resize((size, size), Image.Resampling.LANCZOS) + bg = Image.new("RGB", (size, size), (13, 17, 23)) + bg.paste(rgba, mask=rgba.split()[-1]) + return bg + + +def write_icns(img: Image.Image, path: Path) -> None: + specs = [ + (b"ic07", 128), + (b"ic08", 256), + (b"ic09", 512), + (b"ic10", 1024), + (b"ic11", 32), + (b"ic12", 64), + (b"ic13", 256), + (b"ic14", 512), + ] + chunks: list[bytes] = [] + for ostype, size in specs: + buf = io.BytesIO() + img.resize((size, size), Image.Resampling.LANCZOS).save(buf, format="PNG", optimize=True) + data = buf.getvalue() + chunks.append(ostype + struct.pack(">I", 8 + len(data)) + data) + body = b"".join(chunks) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"icns" + struct.pack(">I", 8 + len(body)) + body) + print(f"ICNS -> {path.relative_to(ROOT)} ({path.stat().st_size} bytes)") + + +ANDROID_LAUNCHER = [ + ("mipmap-mdpi", 48, 108, 24), + ("mipmap-hdpi", 72, 162, 36), + ("mipmap-xhdpi", 96, 216, 48), + ("mipmap-xxhdpi", 144, 324, 72), + ("mipmap-xxxhdpi", 192, 432, 96), +] + + +def apply_android_icons(img: Image.Image) -> None: + res = ROOT / "flutter" / "android" / "app" / "src" / "main" / "res" + for folder, launcher, foreground, status in ANDROID_LAUNCHER: + d = res / folder + d.mkdir(parents=True, exist_ok=True) + icon = img.resize((launcher, launcher), Image.Resampling.LANCZOS) + icon.save(d / "ic_launcher.png", format="PNG", optimize=True) + icon.save(d / "ic_launcher_round.png", format="PNG", optimize=True) + # Adaptive-icon safe zone is the inner 66% of the foreground canvas. + fg = Image.new("RGBA", (foreground, foreground), (0, 0, 0, 0)) + inner = int(foreground * 0.66) + placed = img.resize((inner, inner), Image.Resampling.LANCZOS) + off = (foreground - inner) // 2 + fg.paste(placed, (off, off), placed) + fg.save(d / "ic_launcher_foreground.png", format="PNG", optimize=True) + flatten_rgb(img, status).save(d / "ic_stat_logo.png", format="PNG", optimize=True) + print(f"Android {folder} launcher={launcher} fg={foreground}") + + +def apply_apple_icons(img: Image.Image) -> None: + ios_dir = ROOT / "flutter" / "ios" / "Runner" / "Assets.xcassets" / "AppIcon.appiconset" + for name, size, marketing in IOS_ICONS: + path = ios_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + if marketing: + flatten_rgb(img, size).save(path, format="PNG", optimize=True) + else: + img.resize((size, size), Image.Resampling.LANCZOS).save( + path, format="PNG", optimize=True + ) + print(f"iOS {size:4d} -> {path.relative_to(ROOT)} ({path.stat().st_size} bytes)") + write_icns(img, ROOT / "flutter" / "macos" / "Runner" / "AppIcon.icns") + + +def main() -> None: + img = load_source() + LOCAL_SOURCE.parent.mkdir(parents=True, exist_ok=True) + img.save(LOCAL_SOURCE, format="PNG", optimize=True) + print(f"source -> {LOCAL_SOURCE.relative_to(ROOT)} ({LOCAL_SOURCE.stat().st_size} bytes)") + assets = ROOT / "flutter" / "assets" + res = ROOT / "res" + + save_png(img, assets / "icon.png", 256) + save_png(img, assets / "logo.png", 256) + save_png(img, assets / "logo_light.png", 256) + save_png(img, assets / "logo_dark.png", 256) + save_png(img, assets / "dcs_norway_logo.png", 512) + save_ico(img, assets / "icon.ico", [16, 32, 48, 64, 128, 256]) + write_icon_svg(img, assets / "icon.svg") + + save_png(img, res / "icon.png", 512) + save_png(img, res / "32x32.png", 32) + save_png(img, res / "64x64.png", 64) + save_png(img, res / "128x128.png", 128) + save_png(img, res / "128x128@2x.png", 256) + save_png(img, res / "mac-icon.png", 1024) + save_ico(img, res / "icon.ico", [16, 32, 48, 64, 128, 256]) + save_ico(img, res / "tray-icon.ico", [16, 24, 32]) + write_icon_svg(img, res / "logo.svg") + write_icon_svg(img, res / "scalable.svg") + # Wider header slot historically; keep square brand mark at higher res. + write_icon_svg_size(img, res / "logo-header.svg", 256) + save_ico( + img, + ROOT / "flutter" / "windows" / "runner" / "resources" / "app_icon.ico", + [16, 32, 48, 64, 128, 256], + ) + + msi_icon = res / "msi" / "Package" / "Resources" / "icon.ico" + if msi_icon.parent.exists(): + save_ico(img, msi_icon, [16, 32, 48, 64, 128, 256]) + + apply_android_icons(img) + print("DONE") + + +if __name__ == "__main__": + main()