Compare commits

...
72 Commits
Author SHA1 Message Date
dijunkun da4ffa6f06 [fix] reuse host Python and capture iOS dependency failures 2026-11-07 03:55:29 +08:00
dijunkun 80760cab47 [build] cache Xmake toolchains and reuse mobile dependencies 2026-11-07 03:31:17 +08:00
dijunkun f5f8eb94d4 [fix] preserve macOS permissions and defer legacy migration to launch 2026-11-07 03:07:24 +08:00
dijunkun 458f9ef58d [fix] use native Windows shell for CLI dependencies 2026-11-07 02:01:06 +08:00
dijunkun e3ad683809 [fix] resolve cross-platform build warnings 2026-11-07 01:55:51 +08:00
dijunkun 33bc5bf13b [build] remove MCP workflow path filters 2026-11-07 01:39:42 +08:00
dijunkun 276d314780 [build] optimize CLI native dependency caching 2026-11-07 01:36:53 +08:00
dijunkun a663ebdb1a [build] update Android Gradle wrapper to 9.9.0 2026-11-07 01:17:17 +08:00
dijunkun 882982081a [fix] pass managed BSTR values to Windows UI Automation 2026-11-07 00:56:52 +08:00
dijunkun 84466ccea4 [refactor] migrate clients to unified MiniRTC APIs 2026-11-07 00:41:25 +08:00
dijunkun 1a12a1d7d5 [refactor] adopt MiniRtc API naming across clients 2026-11-06 23:55:00 +08:00
dijunkun a64a28a5a3 [refactor] use MiniRTC public APIs across clients 2026-11-06 23:44:54 +08:00
dijunkun 05782bce7c [build] bump iOS version to 1.7.5 and target iOS 16 2026-11-06 22:59:19 +08:00
dijunkun 2846c19733 [fix] use Float32 audio playback buffers on iOS 2026-11-06 22:59:19 +08:00
dijunkun 9c31acd4c9 [fix] preserve panel input across modifier and ownership changes 2026-11-06 22:15:57 +08:00
dijunkun 9ef7ecbc82 [feat] add native desktop inspection and accessibility actions 2026-11-06 22:15:03 +08:00
dijunkun 82b8cc11bc [feat] receive remote audio with panel playback and bounded WAV capture 2026-11-06 21:55:58 +08:00
dijunkun 02b10d0d8e [feat] add panel desktop takeover with exclusive input ownership 2026-11-06 21:52:47 +08:00
dijunkun b167b24cb3 [feat] recover authorized sessions with bounded reconnect attempts 2026-11-06 21:39:10 +08:00
dijunkun 0c1aa1339d [feat] support concurrent MCP sessions and multi-display operations 2026-11-06 21:15:31 +08:00
dijunkun 12afcce690 [feat] extend keyboard and pointer control with bounded held input 2026-11-06 20:54:28 +08:00
dijunkun dd4baf2fc4 [feat] expose system controls and verify remote unlock completion 2026-11-06 20:46:11 +08:00
dijunkun 6bfcd5cf3d [feat] add MCP file browsing and cancellable transfers 2026-11-06 20:36:48 +08:00
dijunkun 0aed57ed4d [feat] add acknowledged remote clipboard read and write 2026-11-06 20:20:25 +08:00
dijunkun 0eab020c38 [feat] acknowledge remote input and wait for visual changes 2026-11-06 20:12:41 +08:00
dijunkun a49eb7ad5b [feat] add persistent remote unlock credentials and local password dialogs 2026-11-06 20:03:05 +08:00
dijunkun 2535a04d2b [fix] recover initial MCP signaling connections and preserve failure reasons 2026-11-06 20:02:52 +08:00
dijunkun 09ef60dc6f [feat] add configurable video pause on minimize, refs #143 2026-11-06 16:42:31 +08:00
dijunkun 9ca4c3c7fe [fix] preserve Windows modifier state across keyboard capture recovery, refs #142 2026-11-06 15:00:59 +08:00
dijunkun 43b1d8fecb [fix] trust user-installed CA certificates on Android, refs #145 2026-11-06 14:40:44 +08:00
dijunkun 593bd550aa [chore] update MiniRTC signaling recovery 2026-11-06 14:10:10 +08:00
dijunkun 1b6248b05b [fix] position windows before showing to prevent flicker 2026-11-05 18:02:12 +08:00
dijunkun c0fa5e6474 [fix] improve file browser drag cursors and previews 2026-11-05 17:24:10 +08:00
dijunkun 5e34360d25 [fix] handle relative cache paths during device identity initialization 2026-11-05 16:25:05 +08:00
dijunkun 751cf04434 [feat] add dual-pane file transfers and directory browsing, refs #135 2026-11-05 15:27:53 +08:00
dijunkun 6f7c5a29ab [fix] fix OpenGL core rendering and video fallback, refs #138 2026-11-04 23:07:42 +08:00
dijunkun 4d8a650afc [fix] update MiniRTC for NVDEC runtime recovery 2026-11-04 19:41:10 +08:00
dijunkun dba7ac3203 Merge branch 'cli' into mcp 2026-11-04 15:08:35 +08:00
dijunkun 47e16622f8 [fix] expose retry and manual download after update failure 2026-11-04 13:59:06 +08:00
dijunkun 049cc0c8de [fix] avoid PRIME offload on NVIDIA primary displays, refs #137 2026-11-04 12:29:34 +08:00
dijunkun fc1529ee2b [fix] stabilize MCP sessions and extend input controls 2026-11-03 14:19:39 +08:00
dijunkun e1ae449d36 [feat] add MCP terminal views and agent cursor overlays 2026-11-03 03:00:57 +08:00
dijunkun f57b4cc4e3 [feat] add CrossDesk MCP with remote desktop panels 2026-11-03 02:44:55 +08:00
dijunkun 2fab61871e [build] bump CLI version to 0.1.6 2026-11-02 15:47:44 +08:00
dijunkun d776889562 [docs] remove DeskHub bold emphasis and clarify desktop CLI 2026-11-02 15:44:08 +08:00
dijunkun 30d409ff49 [feat] anchor CLI zoom to mouse and simplify view shortcuts 2026-11-02 15:32:16 +08:00
dijunkun 6de7165fb0 [fix] prioritize resolution in CLI previews 2026-11-02 15:18:53 +08:00
dijunkun 28398c0067 [build] bump CLI version to 0.1.5 2026-11-02 12:39:51 +08:00
dijunkun 0188f2e860 [fix] provide host pkg-config for Xcode Cloud dependencies 2026-11-02 12:35:23 +08:00
dijunkun 597c4971cf [feat] add Sixel rendering to CLI 2026-11-02 11:41:25 +08:00
dijunkun b1a0dcdb92 [fix] correct certificate errors and restore automatic reconnection 2026-11-02 11:07:19 +08:00
dijunkun 3382792bfd [fix] prebuild iOS native dependencies before Xcode Cloud archives 2026-11-02 10:34:34 +08:00
dijunkun bfa743fd1b [fix] correct mobile viewport safe areas and zoom reset 2026-11-02 03:17:17 +08:00
dijunkun d051dbb278 [fix] avoid X11 None macro collision in credential recovery 2026-11-02 03:03:53 +08:00
dijunkun 425698e22e [feat] authenticate controller sessions with device keys 2026-11-02 03:00:18 +08:00
dijunkun adcb0f17dd [ui] improve scrollbar spacing in the trusted devices dialog 2026-11-02 02:20:31 +08:00
dijunkun 49717f0f68 [feat] use device keys for server login 2026-11-02 01:15:11 +08:00
dijunkun bb48551d59 [docs] document trusted device setup in READMEs 2026-11-01 23:32:58 +08:00
dijunkun ea4db45dbf [ui] remove redundant IP connection hints 2026-11-01 23:27:17 +08:00
dijunkun 489491008a [feat] add trusted device pairing and management UI 2026-11-01 23:27:17 +08:00
dijunkun e15e2c33bd [feat] add application-layer trusted device authentication 2026-11-01 23:26:48 +08:00
dijunkun b999a01e66 [refactor] share private file storage helpers 2026-11-01 23:25:53 +08:00
dijunkun 53a3df6133 [chore] remove merged MiniRTC credential log patch 2026-11-01 03:26:26 +08:00
dijunkun 3597fd4b3e [fix] include OpenSSL resource symbols in iOS archives 2026-11-01 03:11:18 +08:00
dijunkun c2bfce03f2 [fix] update MiniRTC to correct inbound data bitrate 2026-11-01 02:57:42 +08:00
dijunkun 86d3a8cf72 [fix] adapt iOS keyboard switch widths to localized text 2026-11-01 02:49:59 +08:00
dijunkun 4e2e5b74dd [build] migrate CLI macOS runner and release 0.1.4 2026-10-31 05:34:29 +08:00
dijunkun 7bcda7a6fa [build] bump CLI version to 0.1.3 2026-10-31 05:27:31 +08:00
dijunkun baf48120bb [perf] optimize CLI ANSI output and refresh previews 2026-10-31 05:21:10 +08:00
dijunkun 1b716f3682 [docs] clarify platform and client badges in READMEs 2026-10-31 05:09:41 +08:00
dijunkun b361533d70 [docs] refresh CLI previews and clarify FPS capture 2026-10-31 04:58:15 +08:00
dijunkun af60fb2541 [docs] expand CLI guide with npm setup and previews 2026-10-31 04:49:13 +08:00
214 changed files with 19440 additions and 1613 deletions
+11
View File
@@ -5,6 +5,9 @@ inputs:
minimum-version: minimum-version:
description: Minimum supported Xmake version (major.minor.patch). description: Minimum supported Xmake version (major.minor.patch).
required: true required: true
cache-key:
description: Host environment identity, including the image when running in a container.
default: host
runs: runs:
using: composite using: composite
@@ -16,11 +19,19 @@ runs:
XMAKE_MIN_VERSION: ${{ inputs.minimum-version }} XMAKE_MIN_VERSION: ${{ inputs.minimum-version }}
run: bash "$GITHUB_ACTION_PATH/check-version.sh" run: bash "$GITHUB_ACTION_PATH/check-version.sh"
- name: Identify Xmake cache environment
if: steps.check.outputs.needs-upgrade == 'true'
id: cache-environment
shell: bash
run: printf 'image=%s-%s\n' "${ImageOS:-unknown}" "${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
- name: Install or upgrade Xmake - name: Install or upgrade Xmake
if: steps.check.outputs.needs-upgrade == 'true' if: steps.check.outputs.needs-upgrade == 'true'
uses: xmake-io/github-action-setup-xmake@v1 uses: xmake-io/github-action-setup-xmake@v1
with: with:
xmake-version: ${{ inputs.minimum-version }} xmake-version: ${{ inputs.minimum-version }}
actions-cache-folder: ${{ runner.temp }}/crossdesk-xmake-toolchain
actions-cache-key: crossdesk-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.cache-environment.outputs.image }}-${{ inputs.cache-key }}
- name: Verify Xmake meets the minimum version - name: Verify Xmake meets the minimum version
shell: bash shell: bash
+65
View File
@@ -0,0 +1,65 @@
import("core.base.bytes")
import("core.project.config")
import("core.project.project")
function digest(value)
-- Expand shared config tables so serialization never emits order-dependent refs.
local data = table.clone(value, math.huge)
return hash.sha256(bytes(string.serialize(data, {orderkeys = true, strip = true})))
end
function main(plat, arch, configfile)
assert(plat and arch and configfile, "expected platform, architecture and config file")
local outputfile = assert(os.getenv("GITHUB_OUTPUT"), "GITHUB_OUTPUT is required")
local options = {
plat = plat,
arch = arch,
mode = "release",
CROSSDESK_BUILD_DESKTOP = false,
CROSSDESK_BUILD_CLI = true,
USE_CUDA = false,
policies = "package.librarydeps.strict_compatibility"
}
if plat == "macosx" then
options.target_minver = "13.0"
end
-- Use the same options for dependency discovery and the subsequent configure step.
config.clear()
for name, value in pairs(options) do
config.set(name, value, {readonly = true, force = true})
end
project.check_options()
local requires, requires_extra = project.requires_str()
local requireconfs, requireconfs_extra = project.requireconfs_str()
assert(requires and #requires > 0, "CLI dependency declarations are missing")
-- These root settings are inherited by packages. Target source lists and
-- application versions are deliberately outside the dependency identity.
local package_policies = {}
for name, value in pairs(project.get("target.policy") or {}) do
if name:startswith("package.") then
package_policies[name] = value
end
end
local configuration = {
options = options,
runtimes = project.get("target.runtimes"),
toolchains = project.get("target.toolchains"),
lto = project.policy("build.optimization.lto"),
asan = project.policy("build.sanitizer.address"),
package_policies = package_policies
}
local dependencies = {
requires = requires,
requires_extra = requires_extra,
requireconfs = requireconfs,
requireconfs_extra = requireconfs_extra
}
io.save(configfile, options)
local output = io.open(outputfile, "a")
assert(output, "cannot open GitHub output file")
output:print("configuration=%s", digest(configuration))
output:print("dependencies=%s", digest(dependencies))
output:close()
end
+42 -5
View File
@@ -70,6 +70,7 @@ jobs:
java-version: "17" java-version: "17"
- name: Set up Python - name: Set up Python
id: python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
@@ -90,7 +91,9 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}" sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}"
fi fi
if ! python3 -c 'import setuptools, wheel, pip' >/dev/null 2>&1; then
python3 -m pip install setuptools wheel python3 -m pip install setuptools wheel
fi
# Use an isolated SDK directory so the cache contains only this toolchain, # Use an isolated SDK directory so the cache contains only this toolchain,
# rather than every SDK/NDK version preinstalled on the runner. # rather than every SDK/NDK version preinstalled on the runner.
@@ -134,13 +137,40 @@ jobs:
actions-cache-folder: .xmake-toolchain-cache actions-cache-folder: .xmake-toolchain-cache
actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }} actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }}
# Repository selection follows desktop Xmake behavior. Keep installed - name: Set native dependency cache key
# packages isolated; never reuse another platform's build/config. id: native-cache-key
- name: Cache native dependencies env:
uses: actions/cache@v5 CACHE_ENVIRONMENT: ${{ runner.os }}-${{ runner.arch }}-android-arm64-native-v2-ndk28.2.13676358-api26-xmake${{ env.XMAKE_MIN_VERSION }}-python${{ steps.python.outputs.python-version }}
RECIPE_HASH: ${{ hashFiles('apps/android/scripts/build_native.py', 'deps/submodules/minirtc/thirdparty/**') }}
DEPENDENCY_HASH: ${{ hashFiles('apps/android/native/xmake.lua', 'deps/submodules/minirtc/xmake.lua') }}
run: |
prefix="${CACHE_ENVIRONMENT}-image${ImageVersion:?Missing GitHub runner image version}-${RECIPE_HASH}-"
printf 'prefix=%s\nkey=%s%s\n' "$prefix" "$prefix" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
# Historical source_tag checkouts keep exact-only dependency reuse.
if grep -q -- '--configure-only' apps/android/scripts/build_native.py && \
grep -q 'package.librarydeps.strict_compatibility' apps/android/scripts/build_native.py; then
echo 'incremental=true' >> "$GITHUB_OUTPUT"
fi
# Cache installed packages separately from native targets and Xmake state.
- name: Restore native dependencies
id: native-cache
uses: actions/cache/restore@v5
with: with:
path: apps/android/.native/packages path: apps/android/.native/packages
key: ${{ runner.os }}-${{ runner.arch }}-android-arm64-native-v1-${{ hashFiles('apps/android/scripts/build_native.py', 'apps/android/native/xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }} key: ${{ steps.native-cache-key.outputs.key }}
restore-keys: ${{ steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache-key.outputs.prefix || '' }}
- name: Prepare native dependencies
if: steps.native-cache-key.outputs.incremental == 'true'
run: python3 -B apps/android/scripts/build_native.py --configure-only
- name: Save native dependencies
if: steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: apps/android/.native/packages
key: ${{ steps.native-cache.outputs.cache-primary-key }}
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
@@ -185,6 +215,13 @@ jobs:
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \ -PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
:app:assembleDebug :app:assembleRelease :app:assembleDebug :app:assembleRelease
- name: Save legacy native dependencies
if: steps.native-cache-key.outputs.incremental != 'true' && steps.native-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: apps/android/.native/packages
key: ${{ steps.native-cache.outputs.cache-primary-key }}
- name: Verify APKs - name: Verify APKs
working-directory: apps/android working-directory: apps/android
run: | run: |
+26 -13
View File
@@ -17,20 +17,25 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- runner: macos-14 - runner: macos-15
target: darwin-arm64 target: darwin-arm64
plat: macosx
arch: arm64 arch: arm64
- runner: macos-15-intel - runner: macos-15-intel
target: darwin-x64 target: darwin-x64
plat: macosx
arch: x86_64 arch: x86_64
- runner: ubuntu-22.04 - runner: ubuntu-22.04
target: linux-x64 target: linux-x64
plat: linux
arch: x86_64 arch: x86_64
- runner: ubuntu-22.04-arm - runner: ubuntu-22.04-arm
target: linux-arm64 target: linux-arm64
plat: linux
arch: arm64 arch: arm64
- runner: windows-2022 - runner: windows-2022
target: win32-x64 target: win32-x64
plat: windows
arch: x64 arch: x64
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 120 timeout-minutes: 120
@@ -90,23 +95,31 @@ jobs:
fi fi
- name: Prepare pinned dependency - name: Prepare pinned dependency
run: sh apps/cli/scripts/prepare-deps.sh run: sh apps/cli/scripts/prepare-deps.sh
# Exact matches only: changed local recipes/patches must not reuse old libraries. # Use the runner's native shell for Xmake. Git Bash sets MSYSTEM on
# Source, README and npm metadata edits do not change this key. # Windows, which makes host tools such as jom resolve for msys.
- name: Read native dependency configuration
id: dependency-config
run: xmake lua .github/scripts/cli-dependency-cache.lua "${{ matrix.plat }}" "${{ matrix.arch }}" "${{ runner.temp }}/crossdesk-cli-config.lua"
- name: Set native dependency cache key
id: dependency-key
shell: bash
env:
CACHE_PREFIX: cli-deps-v2-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ steps.dependency-config.outputs.configuration }}-${{ hashFiles('deps/thirdparty/openssl/**', 'deps/submodules/minirtc/thirdparty/**') }}
DEPENDENCY_HASH: ${{ steps.dependency-config.outputs.dependencies }}
run: |
printf 'prefix=%s-\n' "$CACHE_PREFIX" >> "$GITHUB_OUTPUT"
printf 'key=%s-%s\n' "$CACHE_PREFIX" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
# Reuse installed packages across dependency declaration changes within the
# same toolchain, build settings and local recipes/patches.
- name: Restore native dependencies - name: Restore native dependencies
id: native-cache id: native-cache
uses: actions/cache/restore@v5 uses: actions/cache/restore@v5
with: with:
path: ${{ env.XMAKE_PKG_INSTALLDIR }} path: ${{ env.XMAKE_PKG_INSTALLDIR }}
key: cli-deps-v1-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ hashFiles('xmake.lua', 'apps/cli/xmake.lua', 'apps/desktop/xmake/options.lua', 'deps/thirdparty/openssl/**', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**', 'deps/patches/**', '.github/workflows/build-cli.yml') }} key: ${{ steps.dependency-key.outputs.key }}
- name: Configure macOS restore-keys: ${{ steps.dependency-key.outputs.prefix }}
if: runner.os == 'macOS' - name: Configure native dependencies
run: xmake f -y -p macosx -a ${{ matrix.arch }} -m release --target_minver=13.0 --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n run: xmake f -y --import="${{ runner.temp }}/crossdesk-cli-config.lua"
- name: Configure Linux
if: runner.os == 'Linux'
run: xmake f -y -p linux -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
- name: Configure Windows
if: runner.os == 'Windows'
run: xmake f -y -p windows -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
# xmake f installs required packages. Save before compiling or packaging the CLI, # xmake f installs required packages. Save before compiling or packaging the CLI,
# so failures in those later steps do not discard successfully installed dependencies. # so failures in those later steps do not discard successfully installed dependencies.
- name: Save native dependencies - name: Save native dependencies
+68 -13
View File
@@ -155,30 +155,55 @@ jobs:
- name: Initialize submodules - name: Initialize submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
path: apps/ios/.xmake/packages
key: "${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-${{ github.run_id }}"
restore-keys: |
${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-
- name: Set up Python - name: Set up Python
id: python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
cache: pip
cache-dependency-path: .github/workflows/build-ios.yml
- name: Install Python build tools - name: Install Python build tools
shell: bash shell: bash
run: | run: |
# Xmake only accepts the host Python when all three modules exist; # Xmake only accepts the host Python when all three modules exist;
# otherwise it falls back to compiling another Python from source. # otherwise it falls back to compiling another Python from source.
python3 -m pip install --upgrade pip setuptools wheel if ! python3 -c 'import setuptools, wheel, pip' >/dev/null 2>&1; then
python3 -m pip install setuptools wheel
fi
- name: Set native dependency cache key
id: native-cache-key
shell: bash
env:
CACHE_ENVIRONMENT: ${{ runner.os }}-${{ runner.arch }}-ios-arm64-source-v3-xcode26.6-min16-xmake${{ env.XMAKE_MIN_VERSION }}-python${{ steps.python.outputs.python-version }}
RECIPE_HASH: ${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'deps/submodules/minirtc/thirdparty/**') }}
DEPENDENCY_HASH: ${{ hashFiles('xmake.lua', 'deps/submodules/minirtc/xmake.lua') }}
run: |
image="${ImageVersion:?Missing GitHub runner image version}"
prefix="${CACHE_ENVIRONMENT}-image${image}-${RECIPE_HASH}-"
printf 'image=%s\nprefix=%s\nkey=%s%s\n' "$image" "$prefix" "$prefix" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
# Historical source_tag checkouts keep exact-only dependency reuse.
if grep -q -- '--configure-only' apps/ios/scripts/build_minirtc_ios.sh && \
grep -q 'package.librarydeps.strict_compatibility' apps/ios/scripts/build_minirtc_ios.sh; then
echo 'incremental=true' >> "$GITHUB_OUTPUT"
fi
- name: Install xmake - name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1 uses: xmake-io/github-action-setup-xmake@v1
with: with:
xmake-version: "${{ env.XMAKE_MIN_VERSION }}" xmake-version: "${{ env.XMAKE_MIN_VERSION }}"
actions-cache-folder: ${{ runner.temp }}/crossdesk-ios-xmake-toolchain
actions-cache-key: ios-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-image${{ steps.native-cache-key.outputs.image }}-xcode26.6
# Keep recipe/patch and toolchain changes outside the fallback boundary.
- name: Restore native dependencies
id: native-cache
uses: actions/cache/restore@v5
with:
path: apps/ios/.xmake/packages
key: ${{ steps.native-cache-key.outputs.key }}
restore-keys: ${{ steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache-key.outputs.prefix || '' }}
- name: Verify minimum xmake version - name: Verify minimum xmake version
shell: bash shell: bash
@@ -187,6 +212,27 @@ jobs:
python3 -B apps/ios/scripts/verify_native_build.py \ python3 -B apps/ios/scripts/verify_native_build.py \
--xmake-version "${RUNNER_TEMP}/xmake-version.txt" --xmake-version "${RUNNER_TEMP}/xmake-version.txt"
- name: Prepare native dependencies
if: steps.native-cache-key.outputs.incremental == 'true'
timeout-minutes: 60
shell: bash
env:
CONFIGURATION: Release
PLATFORM_NAME: iphoneos
CURRENT_ARCH: arm64
CROSSDESK_NATIVE_VERBOSE: "1"
run: |
set -o pipefail
apps/ios/scripts/build_minirtc_ios.sh --configure-only 2>&1 | \
tee "${RUNNER_TEMP}/ios-native-prepare.log"
- name: Save native dependencies
if: steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: apps/ios/.xmake/packages
key: ${{ steps.native-cache.outputs.cache-primary-key }}
# Xcode buffers Run Script output until the phase finishes. Build the # Xcode buffers Run Script output until the phase finishes. Build the
# native archives here so dependency downloads and compiler commands are # native archives here so dependency downloads and compiler commands are
# streamed to Actions; the Xcode phase then reuses the same build products. # streamed to Actions; the Xcode phase then reuses the same build products.
@@ -216,6 +262,13 @@ jobs:
apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \ apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \
tee "${RUNNER_TEMP}/ios-native-build.log" tee "${RUNNER_TEMP}/ios-native-build.log"
- name: Save legacy native dependencies
if: steps.native-cache-key.outputs.incremental != 'true' && steps.native-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: apps/ios/.xmake/packages
key: ${{ steps.native-cache.outputs.cache-primary-key }}
- name: Build CrossDesk Mobile - name: Build CrossDesk Mobile
timeout-minutes: 30 timeout-minutes: 30
shell: bash shell: bash
@@ -241,6 +294,7 @@ jobs:
with: with:
name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
${{ runner.temp }}/ios-native-prepare.log
${{ runner.temp }}/ios-native-build.log ${{ runner.temp }}/ios-native-build.log
${{ runner.temp }}/ios-app-build.log ${{ runner.temp }}/ios-app-build.log
~/.xmake/cache/packages/**/logs/*.txt ~/.xmake/cache/packages/**/logs/*.txt
@@ -276,7 +330,8 @@ jobs:
if [[ "${CROSSDESK_RELEASE_BUILD:-NO}" == "YES" ]]; then if [[ "${CROSSDESK_RELEASE_BUILD:-NO}" == "YES" ]]; then
VERIFY_FLAGS+=(--require-release) VERIFY_FLAGS+=(--require-release)
fi fi
python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" "${VERIFY_FLAGS[@]}" python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" \
--dsym-dir "${DERIVED_DATA}/Build/Products/Release-iphoneos" "${VERIFY_FLAGS[@]}"
xcrun lipo -info "${EXECUTABLE_PATH}" xcrun lipo -info "${EXECUTABLE_PATH}"
echo "Minimum iOS version: ${MINIMUM_OS}" echo "Minimum iOS version: ${MINIMUM_OS}"
@@ -293,10 +348,10 @@ jobs:
cp apps/ios/licenses/OPEN_SOURCE_NOTICE.txt "${PACKAGE_DIR}/OPEN_SOURCE_NOTICE.txt" cp apps/ios/licenses/OPEN_SOURCE_NOTICE.txt "${PACKAGE_DIR}/OPEN_SOURCE_NOTICE.txt"
cp "${APP_PATH}/SourceMetadata.json" "${PACKAGE_DIR}/SourceMetadata.json" cp "${APP_PATH}/SourceMetadata.json" "${PACKAGE_DIR}/SourceMetadata.json"
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM" for DSYM_NAME in CrossDeskMobile.app.dSYM OpenSSL.framework.dSYM; do
if [[ -d "${DSYM_PATH}" ]]; then DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/${DSYM_NAME}"
cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/" cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/"
fi done
ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}" ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}"
echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}" echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}"
+32
View File
@@ -0,0 +1,32 @@
name: MCP package
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
package:
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: apps/mcp/package-lock.json
- run: npm ci
working-directory: apps/mcp
- run: npm run build
working-directory: apps/mcp
- run: npm run pack:npm
working-directory: apps/mcp
- uses: actions/upload-artifact@v6
with:
name: mcp-package
path: build/npm-mcp/*.tgz
if-no-files-found: error
+1
View File
@@ -132,6 +132,7 @@ jobs:
uses: ./.github/actions/ensure-xmake uses: ./.github/actions/ensure-xmake
with: with:
minimum-version: ${{ env.XMAKE_MIN_VERSION }} minimum-version: ${{ env.XMAKE_MIN_VERSION }}
cache-key: ${{ matrix.image }}
- name: Verify Linux build image - name: Verify Linux build image
shell: bash shell: bash
+99 -13
View File
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download) [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases) [![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml) [![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE) [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers) [![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -19,9 +22,9 @@
</div> </div>
CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。 CrossDesk 让你从电脑、浏览器、手机或终端(桌面 CLI)连接远端电脑,也能通过桌面墙(DeskHub)集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor) [主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [IP 直连](#lan-direct) · [可信设备](#trusted-devices) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
<a id="features"></a> <a id="features"></a>
@@ -29,10 +32,11 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。 - **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。 - **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。 - **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty / Sixel 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。 - 桌面墙:多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。 - **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。 - **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
- **可信设备:** 桌面端可选开启可信设备限制,在连接密码之外校验设备身份;支持复制、粘贴配对信息,提前添加允许访问本机的设备。
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。 - **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
<a id="download"></a> <a id="download"></a>
@@ -51,7 +55,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 | | 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 | | 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 | | Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
| 终端 CLI(开发版) | **仅控制**;构建目标为 macOS / Linux(x64、arm64)和 Windows(x64);从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) | | 终端 CLI(开发版) | **仅控制**;分发目标为 macOS / Linux(x64、arm64)和 Windows(x64);通过 [npm 安装](#cli-npm) 或从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) |
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。 系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。
@@ -69,7 +73,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。 2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。 3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。 使用自建服务器时,两端需要接入同一服务。新版桌面端使用设备密钥登录服务器,请先升级服务端:新 ID 注册时绑定公钥,旧 ID 首次升级时验证原密码后绑定;绑定后,针对该 ID 的错误密码尝试不会阻塞正常签名登录。远程控制仍需输入连接密码;新版控制端也验证设备签名,密码失败按控制端及“控制端 → 目标”计数,不再累计被控端的总失败次数,避免他人的错误尝试锁住目标。身份备份与兼容要求见 [设备登录说明](docs/connection-architecture.md#41-id-模式)。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="lan-direct"></a> <a id="lan-direct"></a>
@@ -81,6 +85,24 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IP 接口监听,支持 IPv4 和 IPv6,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。 两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IP 接口监听,支持 IPv4 和 IPv6,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
<a id="trusted-devices"></a>
### 仅允许可信设备连接(可选)
桌面端的可信设备限制**默认关闭**,适用于 ID 连接和 IP 直连。开启后,控制端仍需通过原有密码验证,并持有可信名单中设备的配对私钥;仅知道 ID 和密码不能通过这项限制。
1. **在控制端复制配对信息。** 打开 **设置 → 常规 → 隐私保护 → 管理可信设备**,在 **本机配对信息** 中点击 **复制配对信息**。
2. **在被控端添加控制端。** 打开同一管理界面,将收到的信息粘贴到 **添加可信设备** 输入框,点击 **添加** 即可加入,无需再次确认,也无需先建立远程连接。
3. **在被控端开启限制。** 返回设置,勾选 **仅允许可信设备控制** 并保存。之后照常通过 ID 或 IP 发起连接、验证密码。
配对信息只包含公钥、设备名称和 ID,不含私钥或密码,应通过可信渠道传递。添加后可点击 **查看指纹** 与控制端核对;也允许添加本机,同一公钥不会重复加入。信任是单向的,如需互相控制,应在双方分别添加对方。
添加和移除会立即保存;开启限制时,**移除信任** 会断开对应设备的会话,名单为空则拒绝所有控制端。也可以从管理界面的已连接设备中添加完成身份验证的控制端。
**兼容范围:** 两端均需使用支持可信设备认证的客户端。不支持该协议的旧版客户端无法连接开启限制的被控端;当前 CLI、iOS、Android 和鸿蒙移动端尚未实现该认证。关闭限制时沿用原有连接方式。
设备登录与配对共用缓存目录 `identity.json` 中的私钥,可信名单保存在配置目录的 `trusted-devices/devices.json`。迁移本机身份时应同时备份 ID/密码缓存与 `identity.json`;已绑定的 ID 无法仅凭密码恢复登录。清空整个缓存目录后需重新登记 ID,并让其他设备重新信任本机。备份、迁移及认证机制见 [可信设备技术说明](docs/connection-architecture.md#43-可选可信设备认证)。
<a id="preview"></a> <a id="preview"></a>
## 界面预览 ## 界面预览
@@ -118,7 +140,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
## 会话操作与设置 ## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。 远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、可信设备限制、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。 - [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。 - [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -128,7 +150,39 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
## 终端客户端 CLI ## 终端客户端 CLI
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。 `crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 或 Sixel 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
### 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 四象限全景:顶部显示只读模式和连接状态,底部显示操作快捷键" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 协议全景:同一远程桌面以像素画面显示,保留状态栏和操作快捷键" /> |
| 用彩色字符显示桌面轮廓,适合普通终端;查看局部可用 `+/-` 或滚轮以鼠标位置缩放。 | 直接显示像素画面,适合支持 Kitty 图形协议的终端,如 Ghostty。 |
两图采集于 **2026-10-31**,来自同一授权设备的实际只读连接,以 128 × 40 字符视口还原 CLI 的 ANSI / Kitty 协议输出。图中 FPS 是采集时的实际输出计数,静止画面下 ANSI 无变化可显示 `FPS 0`。图示为输出渲染,终端实窗效果受字体、窗口大小及协议支持影响;采集方法见 [图示来源](docs/images/README.md#cli-captures)。
<a id="cli-npm"></a>
### 通过 npm 安装并连接
准备 **Node.js 22 或以上**及 npm,从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装。npm 会按操作系统与 CPU 架构选择原生程序,无需安装 Xmake 或 C++ 编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将 `123456789` 换成被控电脑的 9 位设备 ID,连接时按提示隐藏输入密码。默认只读、静音;`doctor` 仅检查本机环境,不发起连接。更新到最新版时,重新执行上面的 `npm install -g @chunkdash/crossdesk-cli@latest`。
无需全局安装时,也可使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
请安装统一入口 `@chunkdash/crossdesk-cli`,并保留 npm 的可选依赖,不要添加 `--omit=optional`;仅安装平台包不会注册 `crossdesk-cli` 命令。平台运行要求、旧包迁移及故障排查见 [npm 分发说明](apps/cli/docs/npm-distribution.md)。
### 从源码构建并连接 ### 从源码构建并连接
@@ -146,7 +200,34 @@ xmake run crossdesk-cli 123456789
已有仓库时从仓库根目录执行依赖准备及后续命令。将 `123456789` 换成被控电脑的 ID,密码在连接时隐藏输入。`doctor` 只检查本机环境,不发起连接。仓库默认构建桌面端;以上配置只构建 CLI,需要同时构建桌面端时将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。产物位于 `build/<platform>/<arch>/release/`。 已有仓库时从仓库根目录执行依赖准备及后续命令。将 `123456789` 换成被控电脑的 ID,密码在连接时隐藏输入。`doctor` 只检查本机环境,不发起连接。仓库默认构建桌面端;以上配置只构建 CLI,需要同时构建桌面端时将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。产物位于 `build/<platform>/<arch>/release/`。
ANSI 全景受终端字符网格限制。阅读小字可添加 `--quality high --zoom native`,或进入画面后按 `d` 查看细节;`--renderer kitty` 需要终端支持对应协议。显示、鼠标报告、SSH 和终端兼容性见 [使用说明](apps/cli/docs/terminal-client.md)。 ### 选择画面与连接参数
默认 `--renderer auto` 按 Kitty → Sixel → ANSI 的顺序选择。macOS「终端」等普通终端可显式选择 `ansi`;支持对应图形协议的终端可选择 `kitty` 或 `sixel`。显式像素模式探测失败时会报错,此时改用 `ansi`。Xshell 是否能显示像素图片取决于终端自身的协议支持,CLI 无法单方面补齐。
以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。
```sh
# 普通终端:以细节模式查看局部
crossdesk-cli 123456789 --renderer ansi --zoom native
# 支持 Kitty 图形协议的终端:显示像素画面
crossdesk-cli 123456789 --renderer kitty
# 降低远端画质与刷新请求
crossdesk-cli 123456789 --quality balanced --fps 8
```
| 参数 | 用途 |
| --- | --- |
| `--renderer auto\|ansi\|kitty\|sixel` | 自动选择、字符画面或像素画面;默认 `auto` |
| `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 |
| `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 |
| `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 |
| `--display 1` | 选择第二个显示器,索引从 `0` 开始 |
| `--control` | 连接后直接启用键鼠控制;省略时默认只读 |
| `--server your-server.example --port 9099` | 使用自建信令服务,两端需接入同一服务 |
ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰;可扩大终端、缩小字体,再用 `+/-` 或滚轮查看局部,按 `f` 回到全景并重置平移。顶部状态栏显示模式、连接进度、渲染器、缩放、实时输出 FPS 和网络信息,窄窗口会省略部分字段。通过 SSH 使用时分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。更多显示、鼠标报告和兼容性说明见 [终端使用指南](apps/cli/docs/terminal-client.md)。
### 控制、查看与退出 ### 控制、查看与退出
@@ -156,11 +237,16 @@ ANSI 全景受终端字符网格限制。阅读小字可添加 `--quality high -
| 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 | | 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 |
| 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI | | 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI |
| `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 | | `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 |
| `+` / `=` / `-` | 在只读或本地命令模式中放大 / 缩小 | | `+` / `=` / `-` | 在只读或本地命令模式中以鼠标位置放大 / 缩小 |
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 | | 滚轮 / 点击 | 只读模式中,滚轮以鼠标位置缩放;点击目标进入原始细节 |
| `f` | 在只读或本地命令模式中恢复完整桌面,同时重置倍率、平移和细节模式 |
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 | | 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制。控制模式中的普通 `c`、`q` 和 `Ctrl+C` 会发送到远端,需要先按 `Ctrl+]` 执行本地操作;再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。 鼠标下的画面内容在缩放时尽量保持原位,到达桌面边缘时限制裁剪范围;没有有效鼠标位置时沿用当前中心。本地命令模式中鼠标仅更新缩放锚点,点击和滚轮不执行视图操作。
**控制模式下退出:先按 `Ctrl+]`,再按 `q`。** 普通 `c`、`q` 和 `Ctrl+C` 在控制模式中会发送到远端。
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制;按 `Esc` / `Ctrl+C` 手动返回。再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
### Pi 扩展与功能范围 ### Pi 扩展与功能范围
@@ -173,7 +259,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。 其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建配置与 npm 打包流程不代表全部平台已验收或包已发布。 CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty / Sixel 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
[CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md) [CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md)
+99 -13
View File
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download) [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases) [![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml) [![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE) [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers) [![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -19,9 +22,9 @@
</div> </div>
CrossDesk connects your computers from desktop, browser, mobile, and terminal clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting. CrossDesk connects your computers from desktop, browser, mobile, and terminal clients, with DeskHub for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Terminal CLI](#cli) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor) [Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Terminal CLI](#cli) · [Direct IP](#lan-direct) · [Trusted devices](#trusted-devices) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
<a id="features"></a> <a id="features"></a>
@@ -29,10 +32,11 @@ CrossDesk connects your computers from desktop, browser, mobile, and terminal cl
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development. - **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode. - **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI. - **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty / Sixel rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution. - DeskHub: live multi-device previews, groups, enlarged control, group input, and batch file distribution.
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device. - **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer. - **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
- **Trusted devices:** optionally restrict desktop access to trusted device identities in addition to the connection password. Copy and paste pairing information to approve devices before they connect.
- **Deployment:** direct P2P connections, TURN relay, SRTP media encryption, self-hosting, Windows access before sign-in, and Linux headless operation. - **Deployment:** direct P2P connections, TURN relay, SRTP media encryption, self-hosting, Windows access before sign-in, and Linux headless operation.
<a id="download"></a> <a id="download"></a>
@@ -51,7 +55,7 @@ Get a package matching your OS and CPU architecture from the [website](https://w
| HarmonyOS PC (development) | **Control + host**. API 26, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing | | HarmonyOS PC (development) | **Control + host**. API 26, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| HarmonyOS phones / tablets (development) | **Control only**. API 20+, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing | | HarmonyOS phones / tablets (development) | **Control only**. API 20+, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device | | Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device |
| Terminal CLI (development) | **Control only**. Build targets: macOS / Linux (x64, arm64) and Windows (x64). Build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status | | Terminal CLI (development) | **Control only**. Distribution targets: macOS / Linux (x64, arm64) and Windows (x64). [Install from npm](#cli-npm) or build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status |
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Terminal CLI](#cli) and [Mobile and HarmonyOS](#mobile). Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Terminal CLI](#cli) and [Mobile and HarmonyOS](#mobile).
@@ -69,7 +73,7 @@ Requirements follow the current source and build configuration. Check the releas
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect. 2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**. 3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems. For self-hosting, both ends must use the same service. Upgrade the server before using the new desktop client: device login uses a private key, with its public key bound at registration or after a one-time password check for an existing ID. Once bound, wrong-password attempts against that ID cannot block valid signature login. Remote control still requires the connection password. Signed controllers have separate per-controller and controller-to-destination failure budgets; failures from one controller no longer lock the destination for others. See the [device login guide](docs/connection-architecture.md#41-id-模式) (Chinese) for identity backups and compatibility, the [user guide](docs/USER_GUIDE_EN.md#quick-start) for controls, or the [FAQ](docs/FAQ.md#english) for connection problems.
<a id="lan-direct"></a> <a id="lan-direct"></a>
@@ -81,6 +85,24 @@ For self-hosting, both ends must use the same service. See the [user guide](docs
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. **Listen on all IP interfaces** is enabled by default for IPv4, IPv6, VPN, and other local interfaces on a shared port; change it under **Settings → General → Network**. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct). Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. **Listen on all IP interfaces** is enabled by default for IPv4, IPv6, VPN, and other local interfaces on a shared port; change it under **Settings → General → Network**. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
<a id="trusted-devices"></a>
### Allow only trusted devices (optional)
The desktop trusted-device restriction is **off by default** and applies to both ID and direct IP connections. When enabled, the controller must pass the existing password verification and prove possession of a pairing private key recognized by the host. Knowing the ID and password alone does not satisfy this restriction.
1. **Copy pairing information on the controller.** Open **Settings → General → Privacy → Manage trusted devices**. Under **This device’s pairing info**, click **Copy pairing info**.
2. **Add the controller on the host.** Open the same management dialog, paste the information into **Add a trusted device**, and click **Add**. It is saved immediately, with no second confirmation or prior remote connection required.
3. **Enable the restriction on the host.** Return to settings, select **Only allow trusted devices**, and save. Connect by ID or IP and verify the password as usual.
Pairing information contains only the public key, device name, and ID, with no private key or password. Exchange it through a trusted channel. After adding a device, use **View fingerprint** to compare it with the controller. You can also add the local device; the same public key is not added twice. Trust is one-way: to control each other, add the other device on both computers.
Adding or removing a device takes effect immediately. With the restriction enabled, **Remove trust** disconnects that device’s sessions, and an empty list rejects all controllers. The dialog also lets you add connected controllers that have completed identity verification.
**Compatibility:** both clients must support trusted-device authentication. Older clients without the protocol cannot connect to a restricted host. The CLI, iOS, Android, and HarmonyOS mobile clients do not currently implement it. With the restriction disabled, the existing connection behavior applies.
Device login and pairing share the private key in the cache directory's `identity.json`; the trusted list is stored in `trusted-devices/devices.json` in the configuration directory. Back up the ID/password cache and `identity.json` together when migrating a device: a bound ID cannot be recovered with its password alone. Clearing the entire cache requires a new ID and renewed trust from other devices. See the [trusted-device technical guide](docs/connection-architecture.md#43-可选可信设备认证) (Chinese) for details.
<a id="preview"></a> <a id="preview"></a>
## Preview ## Preview
@@ -118,7 +140,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
## Session controls and settings ## Session controls and settings
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget. Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, trusted-device restrictions, codecs, relay, self-hosting, and the DeskHub video budget.
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens. - [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks. - [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
@@ -128,7 +150,39 @@ Remote sessions support monitor switching, video quality and frame-rate controls
## Terminal client CLI ## Terminal client CLI
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities. `crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty or Sixel graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
### Display preview
| ANSI character rendering | Kitty pixel graphics |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI ANSI quadrant overview, with read-only mode and connection status at the top and shortcuts at the bottom" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI Kitty protocol overview of the same remote desktop, rendered as pixels with the status bar and shortcuts" /> |
| Colored characters show the desktop in standard terminals; use `+/-` or the wheel to zoom at the mouse position. | Pixel graphics display the desktop in terminals supporting the Kitty graphics protocol, such as Ghostty. |
Both images were captured on **2026-10-31** from real read-only connections to the same authorized host. They render the CLI's ANSI / Kitty protocol output in a 128 × 40 character viewport. FPS counts actual output during capture; an unchanged ANSI view can show `FPS 0`. Actual terminal appearance depends on the font, window size, and protocol support. See [capture notes](docs/images/README.md#cli-captures) for the method.
<a id="cli-npm"></a>
### Install from npm and connect
Install **Node.js 22 or later** and npm, then get [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli). npm selects the native executable for your OS and CPU architecture; Xmake and a C++ toolchain are not required.
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
Replace `123456789` with the host's 9-digit device ID and enter its password at the hidden prompt. Connections start in read-only, silent mode; `doctor` checks the local environment without connecting. To update, run `npm install -g @chunkdash/crossdesk-cli@latest` again.
You can also use `npx` without a global installation. The first run downloads the matching platform package:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
Install the main `@chunkdash/crossdesk-cli` package with npm optional dependencies enabled; do not add `--omit=optional`. Installing a platform package alone does not register the `crossdesk-cli` command. See the [npm distribution guide](apps/cli/docs/npm-distribution.md) (Chinese) for platform requirements, migration from older packages, and troubleshooting.
### Build and connect ### Build and connect
@@ -146,7 +200,34 @@ xmake run crossdesk-cli 123456789
For an existing checkout, run the dependency preparation and subsequent commands from the repository root. Replace `123456789` with the host's ID; the password prompt hides your input. `doctor` reports the local environment without connecting. The repository builds the desktop client by default; this configuration builds only the CLI. Set `CROSSDESK_BUILD_DESKTOP=y` to also build the desktop client. Binaries are placed in `build/<platform>/<arch>/release/`. For an existing checkout, run the dependency preparation and subsequent commands from the repository root. Replace `123456789` with the host's ID; the password prompt hides your input. `doctor` reports the local environment without connecting. The repository builds the desktop client by default; this configuration builds only the CLI. Set `CROSSDESK_BUILD_DESKTOP=y` to also build the desktop client. Binaries are placed in `build/<platform>/<arch>/release/`.
An ANSI overview is limited by the terminal's character grid. Add `--quality high --zoom native`, or press `d` in the viewer, to inspect small text. `--renderer kitty` requires a terminal supporting that protocol. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, SSH, and terminal compatibility. ### Rendering and connection options
The default `--renderer auto` selects Kitty, then Sixel, then ANSI according to terminal capability replies. Use `ansi` in standard terminals such as macOS Terminal, or `kitty` / `sixel` in terminals supporting the corresponding protocol. Explicit pixel modes report an error if the probe fails; use `ansi` in that case. Sixel output alone does not guarantee Xshell compatibility: the terminal itself must support the protocol.
These examples use the command installed globally by npm. For a source build, replace `crossdesk-cli` with `xmake run crossdesk-cli` and run it from the repository root.
```sh
# Standard terminal: start with a detail view
crossdesk-cli 123456789 --renderer ansi --zoom native
# Terminal supporting the Kitty graphics protocol: display pixel graphics
crossdesk-cli 123456789 --renderer kitty
# Request lower video quality and refresh rate
crossdesk-cli 123456789 --quality balanced --fps 8
```
| Option | Purpose |
| --- | --- |
| `--renderer auto\|ansi\|kitty\|sixel` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
| `--zoom native` | Start in native detail mode; `--zoom 2` zooms to twice the full-desktop scale |
| `--quality low\|balanced\|high\|ultra` | Request up to 360p / 720p / 1080p / 2160p; default: `high`. The host determines actual quality |
| `--fps 8` | Fix the local output target and corresponding remote preview request; accepts 1–30. The default local target is 30, with adaptive remote requests when omitted |
| `--display 1` | Select the second monitor; indexes start at `0` |
| `--control` | Enable keyboard/mouse control immediately after connecting; omitted by default for read-only viewing |
| `--server your-server.example --port 9099` | Use a self-hosted signaling service; both ends must use the same service |
ANSI overviews are limited by the character grid: higher video quality alone cannot make full-desktop text legible. Enlarge the terminal or reduce its font size, use `+/-` or the wheel for detail, and `f` to restore the full desktop and reset panning. The top bar shows input mode, connection progress, renderer, zoom, live output FPS, and network information; narrow windows omit some fields. Allocate a PTY over SSH (`ssh -t`), and use ANSI inside tmux / screen. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, and compatibility details.
### Control, navigation, and exit ### Control, navigation, and exit
@@ -156,11 +237,16 @@ An ANSI overview is limited by the terminal's character grid. Add `--quality hig
| Control mode | Keyboard/mouse input goes to the host; `Ctrl+]` enters local command mode and pauses remote input | | Control mode | Keyboard/mouse input goes to the host; `Ctrl+]` enters local command mode and pauses remote input |
| Local command mode | Stays active after commands; `c` selects whether control resumes when you leave; `q` exits the CLI | | Local command mode | Stays active after commands; `c` selects whether control resumes when you leave; `q` exits the CLI |
| `Esc` / `Ctrl+C` | Manually leave local command mode and return to the selected control or read-only mode | | `Esc` / `Ctrl+C` | Manually leave local command mode and return to the selected control or read-only mode |
| `+` / `=` / `-` | Zoom in / out in read-only or local command mode | | `+` / `=` / `-` | Zoom in / out at the mouse position in read-only or local command mode |
| `d` / `f` | Switch to native detail / full-desktop view in read-only or local command mode; clicking in read-only mode also opens detail at that point | | Wheel / click | In read-only mode, zoom at the mouse position with the wheel; click a point to open native detail |
| `f` | Restore the full desktop and reset zoom, pan, and native detail mode in read-only or local command mode |
| Arrow keys / `i j k l` | Pan in read-only or local command mode: `i` up, `j` left, `k` down, `l` right | | Arrow keys / `i j k l` | Pan in read-only or local command mode: `i` up, `j` left, `k` down, `l` right |
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume. In control mode, ordinary `c`, `q`, and `Ctrl+C` go to the host, so enter local command mode first for local actions. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes. Zoom keeps the content under the pointer in place where possible, clamping the crop at desktop edges. Without a valid mouse position, zoom uses the current center. In local command mode, mouse reports only update the zoom anchor; clicks and the wheel do not change the view.
**To quit while controlling the host, press `Ctrl+]`, then `q`.** Ordinary `c`, `q`, and `Ctrl+C` go to the host in control mode.
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume; press `Esc` / `Ctrl+C` to return manually. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
### Pi extension and feature scope ### Pi extension and feature scope
@@ -173,7 +259,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope. On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope.
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform build configurations and npm packaging do not establish that every platform is validated or that packages have been published. The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty / Sixel terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
[CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese) [CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese)
+2 -2
View File
@@ -71,7 +71,7 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存: 首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存:
- Gradle 9.3.1、AGP 9.1.0、NDK r28c 固定在工程中。 - Gradle 9.9.0、AGP 9.1.0、NDK r28c 固定在工程中。
- Xmake 依赖仓库沿用 PC 端策略:使用默认仓库或调用环境指定的仓库,不固定或校验仓库提交。Android 编译产物和安装包缓存仍独立保存。 - Xmake 依赖仓库沿用 PC 端策略:使用默认仓库或调用环境指定的仓库,不固定或校验仓库提交。Android 编译产物和安装包缓存仍独立保存。
- `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。 - `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。
- MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。 - MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。
@@ -149,7 +149,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
最近连接卡片与 iOS 一样,在设备 ID 旁显示已保存密码的钥匙图标和在线/离线状态,在线设备优先,同一状态内保留原来的最近连接顺序。授权并登录服务器后,通过身份信令订阅 `recent_connections_presence`,接收完整快照与 `presence_update`;每 30 秒刷新,状态超过 60 秒未更新则显示离线。断线、切换服务器或进入后台会清除在线状态,重连后等待新的快照。自托管服务需要支持相同的设备在线状态协议。 最近连接卡片与 iOS 一样,在设备 ID 旁显示已保存密码的钥匙图标和在线/离线状态,在线设备优先,同一状态内保留原来的最近连接顺序。授权并登录服务器后,通过身份信令订阅 `recent_connections_presence`,接收完整快照与 `presence_update`;每 30 秒刷新,状态超过 60 秒未更新则显示离线。断线、切换服务器或进入后台会清除在线状态,重连后等待新的快照。自托管服务需要支持相同的设备在线状态协议。
证书信任来自 Android 默认 TrustManager 导出的根证书。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器应使用受系统信任的证书。当前没有在应用内导入自签名证书的入口。 证书信任来自 Android 默认 TrustManager 导出的根证书,应用的 Network Security Configuration 显式信任系统 CA 和用户安装的 CA。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器可使用受系统信任的证书,或通过 Android 系统设置安装签发服务器证书的 CA。安装或移除 CA 后,重启 CrossDesk 再连接;服务器证书须在有效期内,且包含与连接地址匹配的域名或 IP。
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
+12 -12
View File
@@ -1,27 +1,27 @@
plugins { id 'com.android.application' } plugins { id 'com.android.application' }
android { android {
namespace 'cn.crossdesk.mobile' namespace = 'cn.crossdesk.mobile'
compileSdk 36 compileSdk = 36
ndkVersion '28.2.13676358' ndkVersion = '28.2.13676358'
defaultConfig { defaultConfig {
applicationId 'cn.crossdesk.mobile' applicationId = 'cn.crossdesk.mobile'
minSdk 26 minSdk = 26
targetSdk 36 targetSdk = 36
versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger() versionCode = providers.gradleProperty('crossdeskVersionCode').get().toInteger()
versionName providers.gradleProperty('crossdeskVersionName').get() versionName = providers.gradleProperty('crossdeskVersionName').get()
ndk { abiFilters 'arm64-v8a' } ndk { abiFilters 'arm64-v8a' }
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')] sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')]
sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')] sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')]
sourceSets.androidTest.java.srcDirs = ['../test/android/java'] sourceSets.androidTest.java.srcDirs = ['../test/android/java']
sourceSets.androidTest.assets.srcDirs = ['../test/android/assets'] sourceSets.androidTest.assets.srcDirs = ['../test/android/assets']
buildTypes.release { minifyEnabled false } buildTypes.release { minifyEnabled = false }
packaging.jniLibs.useLegacyPackaging = false packaging.jniLibs.useLegacyPackaging = false
} }
@@ -11,6 +11,7 @@
android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:allowBackup="false"
android:fullBackupContent="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="false" android:usesCleartextTraffic="false"> android:supportsRtl="false" android:usesCleartextTraffic="false">
<service android:name=".RemoteSessionService" android:exported="false" <service android:name=".RemoteSessionService" android:exported="false"
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse">
@@ -34,7 +34,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow; private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow;
private TextView aboutUpdateLabel; private TextView aboutUpdateLabel;
private boolean navigatingBack; private boolean navigatingBack;
private FrameLayout canvas; private FrameLayout canvas, safeArea;
private LinearLayout root; private LinearLayout root;
private EditText remote; private EditText remote;
private TextView status, signalBadge, identitySummary; private TextView status, signalBadge, identitySummary;
@@ -118,6 +118,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private String serverKey(){return host+":"+port;} private String serverKey(){return host+":"+port;}
private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();} private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();}
private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();} private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();}
// Legacy inset handling also includes the IME on non-session pages. The
// decor-fitting call below is restricted to Android 11–14.
@SuppressWarnings("deprecation")
private void installRoot(String nextPage,boolean dark){ private void installRoot(String nextPage,boolean dark){
PageHost.Motion motion=PageHost.Motion.NONE; PageHost.Motion motion=PageHost.Motion.NONE;
if(canvas!=null&&foreground&&!page.equals(nextPage)){ if(canvas!=null&&foreground&&!page.equals(nextPage)){
@@ -133,10 +136,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null; settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND); canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1)); safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1));
root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720); root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL)); safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
safeArea.setOnApplyWindowInsetsListener((view,insets)->{ safeArea.setOnApplyWindowInsetsListener((view,insets)->{
if(dark&&(pictureInPictureUi||isInPictureInPictureMode())){view.setPadding(0,0,0,0);return insets;}
if(dark&&android.os.Build.VERSION.SDK_INT>=30){ if(dark&&android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()); android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());
view.setPadding(bars.left,bars.top,bars.right,bars.bottom); view.setPadding(bars.left,bars.top,bars.right,bars.bottom);
@@ -146,6 +150,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion); root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
applySystemBars(dark); applySystemBars(dark);
} }
// Android 8–10 require system UI flags instead of WindowInsetsController.
@SuppressWarnings("deprecation")
private void applySystemBars(boolean dark){ private void applySystemBars(boolean dark){
if(android.os.Build.VERSION.SDK_INT>=30){ if(android.os.Build.VERSION.SDK_INT>=30){
android.view.WindowInsetsController controller=getWindow().getInsetsController(); android.view.WindowInsetsController controller=getWindow().getInsetsController();
@@ -301,7 +307,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret); rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48)); });connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48));
ScrollView scroll=new ScrollView(this);scroll.addView(sheet); ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error); // This floating dialog uses window-managed IME resizing on all supported APIs.
@SuppressWarnings("deprecation") int resizeMode=WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(resizeMode);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;}); dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
} }
private void sizePasswordDialog(){ private void sizePasswordDialog(){
@@ -328,7 +336,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2); dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
} }
private void sessionScreen(boolean autoLandscape){ private void sessionScreen(boolean autoLandscape){
installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));
// Video, cursor and touch input must share the area inside system bars
// and display cutouts; fitting to the outer canvas hides image edges.
safeArea.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){ controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();} public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
@@ -711,6 +722,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}} private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();} private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);} @Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
// API 26–32 use this callback; API 33+ registers OnBackInvokedCallback in onCreate.
@SuppressWarnings("deprecation")
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();} @android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
@Override public void onConfigurationChanged(Configuration config){ @Override public void onConfigurationChanged(Configuration config){
super.onConfigurationChanged(config); super.onConfigurationChanged(config);
@@ -803,6 +816,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(value){releaseKeys();hideKeyboard();pageHost.finish();if(disconnectDialog!=null)disconnectDialog.dismiss();if(remoteUpdateDialog!=null)remoteUpdateDialog.dismiss();} if(value){releaseKeys();hideKeyboard();pageHost.finish();if(disconnectDialog!=null)disconnectDialog.dismiss();if(remoteUpdateDialog!=null)remoteUpdateDialog.dismiss();}
if(controls!=null)controls.setPictureInPicture(value); if(controls!=null)controls.setPictureInPicture(value);
if(video!=null)video.setPictureInPicture(value); if(video!=null)video.setPictureInPicture(value);
if(safeArea!=null)safeArea.requestApplyInsets();
} }
@Override protected void onUserLeaveHint(){ @Override protected void onUserLeaveHint(){
super.onUserLeaveHint(); super.onUserLeaveHint();
@@ -90,6 +90,8 @@ final class MobileUi {
} }
@Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);invalidateSelf(); } @Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);invalidateSelf(); }
@Override public void setColorFilter(ColorFilter filter) { bitmapPaint.setColorFilter(filter);invalidateSelf(); } @Override public void setColorFilter(ColorFilter filter) { bitmapPaint.setColorFilter(filter);invalidateSelf(); }
// Drawable still requires this abstract method, including on API 26.
@SuppressWarnings("deprecation")
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
} }
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML. @android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
@@ -42,6 +42,9 @@ final class RemoteViewport {
void zoomBy(float factor, float focusX, float focusY) { void zoomBy(float factor, float focusX, float focusY) {
if (baseWidth == 0 || baseHeight == 0 || !Float.isFinite(factor) || factor <= 0) return; if (baseWidth == 0 || baseHeight == 0 || !Float.isFinite(factor) || factor <= 0) return;
float nextScale = clamp(scale * factor, 1, MAXIMUM_SCALE); float nextScale = clamp(scale * factor, 1, MAXIMUM_SCALE);
// Returning to fit must clear cursor-edge panning too, including a
// pinch that starts at 1x with an already shifted image.
if (nextScale == 1) { reset(); return; }
float ratio = nextScale / scale; float ratio = nextScale / scale;
float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f); float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f);
float focusFromCenterY = focusY - (baseTop + baseHeight / 2f); float focusFromCenterY = focusY - (baseTop + baseHeight / 2f);
@@ -75,10 +78,9 @@ final class RemoteViewport {
} }
void finishGesture() { void finishGesture() {
if (!isZoomed()) { // Ordinary pointer gestures at 1x retain edge panning. Only a zoom
scale = 1; // snapping back to its minimum restores the complete centered image.
constrainOffset(); if (scale > 1 && !isZoomed()) reset();
}
} }
void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; } void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; }
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip distributionSha256Sum=7aaba33a6aa7d03e0c7890869da1f1b5e1765c4d71db981a9d5db5cc47a10a6d
distributionSha256Sum=17f277867f6914d61b1aa02efab1ba7bb439ad652ca485cd8ca6842fccec6e43 distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-all.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+3 -3
View File
@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
+52 -33
View File
@@ -19,12 +19,39 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
goto fail "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,35 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 1>&2 echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. 1>&2 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 1>&2 echo location of your Java installation.
goto fail "%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute :execute
@rem Setup the command line @rem Setup the command line
@rem Execute Gradle @rem Execute gradlew
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
:end @rem This label must not be changed. We rely on old scripts being able to jump to this point.
@rem End local scope for the variables with windows NT shell :exitWithErrorLevel
if %ERRORLEVEL% equ 0 goto mainEnd @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+37 -39
View File
@@ -2,7 +2,6 @@
#include <android/native_window_jni.h> #include <android/native_window_jni.h>
#include <libyuv.h> #include <libyuv.h>
#include <minirtc.h> #include <minirtc.h>
#include <log.h>
#include <remote_action.h> #include <remote_action.h>
#include <app_version.h> #include <app_version.h>
#include <stream_names.h> #include <stream_names.h>
@@ -19,7 +18,6 @@
#include <vector> #include <vector>
#include "controller_protocol.h" #include "controller_protocol.h"
#include "file_transfer_session.h" #include "file_transfer_session.h"
#include "android_codec_support.h"
#include "latest_frame_worker.h" #include "latest_frame_worker.h"
#include "video_picture.h" #include "video_picture.h"
#include "video_timing.h" #include "video_timing.h"
@@ -52,8 +50,8 @@ struct CallbackContext { Session* owner; bool controller; };
struct Session { struct Session {
jobject owner = nullptr; jobject owner = nullptr;
jmethodID event_method, audio_method, frame_method; jmethodID event_method, audio_method, frame_method;
PeerPtr* identity = nullptr; MiniRtcPeerPtr* identity = nullptr;
PeerPtr* controller = nullptr; MiniRtcPeerPtr* controller = nullptr;
CallbackContext identity_context{this, false}, controller_context{this, true}; CallbackContext identity_context{this, false}, controller_context{this, true};
std::string host, log_path, identity_login, controller_login, remote; std::string host, log_path, identity_login, controller_login, remote;
int port; int port;
@@ -80,7 +78,7 @@ struct Session {
android_controller::FileTransferSession files{ android_controller::FileTransferSession files{
[this](const char* stream, const char* data, size_t size) { [this](const char* stream, const char* data, size_t size) {
return controller && !stopping return controller && !stopping
? SendReliableDataFrame(controller, data, size, stream) : -1; ? MiniRtcSendReliableDataFrame(controller, data, size, stream) : -1;
}, },
[this](const std::string& name, double progress, bool sending, const std::string& path) { [this](const std::string& name, double progress, bool sending, const std::string& path) {
Event(10, {{"name", name}, {"progress", progress}, {"sending", sending}, {"path", path}}); Event(10, {{"name", name}, {"progress", progress}, {"sending", sending}, {"path", path}});
@@ -102,7 +100,7 @@ struct Session {
} }
void Send(const char* stream, const std::string& data) { void Send(const char* stream, const std::string& data) {
if (controller && !stopping && !data.empty()) if (controller && !stopping && !data.empty())
SendReliableDataFrame(controller, data.data(), data.size(), stream); MiniRtcSendReliableDataFrame(controller, data.data(), data.size(), stream);
} }
void Surface(JNIEnv* env, jobject surface) { void Surface(JNIEnv* env, jobject surface) {
std::lock_guard<std::mutex> lock(render_mutex); std::lock_guard<std::mutex> lock(render_mutex);
@@ -133,25 +131,25 @@ struct Session {
stopping = true; stopping = true;
renderer.Stop(); renderer.Stop();
if (controller) { if (controller) {
LeaveConnection(controller, remote.c_str()); MiniRtcLeaveConnection(controller, remote.c_str());
DestroyPeer(&controller); MiniRtcDestroyPeer(&controller);
} }
if (identity) DestroyPeer(&identity); if (identity) MiniRtcDestroyPeer(&identity);
Environment scope; Environment scope;
Surface(scope.env, nullptr); Surface(scope.env, nullptr);
if (scope.env) scope.env->DeleteGlobalRef(owner); if (scope.env) scope.env->DeleteGlobalRef(owner);
} }
}; };
void Signal(SignalStatus status, const char*, size_t, void* user) { void Signal(MiniRtcSignalStatus status, const char*, size_t, void* user) {
auto* ctx = static_cast<CallbackContext*>(user); auto* ctx = static_cast<CallbackContext*>(user);
if (!ctx->controller && status == SignalStatus::SignalConnected && if (!ctx->controller && status == MiniRtcSignalStatus::MiniRtcSignalConnected &&
!ctx->owner->stopping && ctx->owner->identity) { !ctx->owner->stopping && ctx->owner->identity) {
// Report after every identity login, including reconnects. The server // Report after every identity login, including reconnects. The server
// ignores client_info sent through the temporary C- controller peer. // ignores client_info sent through the temporary C- controller peer.
constexpr char info[] = constexpr char info[] =
R"({"type":"client_info","version":"android-native","platform":"android"})"; R"({"type":"client_info","version":"android-native","platform":"android"})";
SendSignalMessage(ctx->owner->identity, info, sizeof(info) - 1); MiniRtcSendSignalMessage(ctx->owner->identity, info, sizeof(info) - 1);
} }
if (!ctx->controller && ctx->owner->signal_status.exchange(status) != status) if (!ctx->controller && ctx->owner->signal_status.exchange(status) != status)
++ctx->owner->signal_generation; ++ctx->owner->signal_generation;
@@ -171,14 +169,14 @@ void SignalMessage(const char* data, size_t size, void* user) {
ctx->owner->Event(9, message); ctx->owner->Event(9, message);
} }
} }
void Connection(ConnectionStatus status, const char*, size_t, void* user) { void Connection(MiniRtcConnectionStatus status, const char*, size_t, void* user) {
auto* ctx = static_cast<CallbackContext*>(user); auto* ctx = static_cast<CallbackContext*>(user);
if (ctx->controller) ctx->owner->Event(2, {{"status", status}}); if (ctx->controller) ctx->owner->Event(2, {{"status", status}});
} }
void Network(const char* id, size_t size, TraversalMode mode, void Network(const char* id, size_t size, MiniRtcTraversalMode mode,
const MiniRtcNetTrafficStats* stats, const char*, size_t, void* user) { const MiniRtcNetTrafficStats* stats, const char*, size_t, void* user) {
auto* ctx = static_cast<CallbackContext*>(user); auto* ctx = static_cast<CallbackContext*>(user);
if (!ctx->controller && mode == UnknownMode && id && size) { if (!ctx->controller && mode == MiniRtcTraversalUnknown && id && size) {
ctx->owner->Event(3, id, size); ctx->owner->Event(3, id, size);
} else if (ctx->controller && stats) { } else if (ctx->controller && stats) {
ctx->owner->Event(7, android_controller::NetworkReport(*stats, mode)); ctx->owner->Event(7, android_controller::NetworkReport(*stats, mode));
@@ -252,7 +250,7 @@ void RenderVideo(Session* s, VideoPicture picture) {
s->Event(5, {{"width", w}, {"height", h}}); s->Event(5, {{"width", w}, {"height", h}});
} }
// Measure through display submission, using MiniRTC's calibrated capture clock. // Measure through display submission, using MiniRTC's calibrated capture clock.
const auto now = GetSystemTimeMicros(s->controller); const auto now = MiniRtcGetSystemTimeMicros(s->controller);
const double latency = android_controller::VideoLatencyMilliseconds( const double latency = android_controller::VideoLatencyMilliseconds(
picture.captured_us, picture.received_us, now); picture.captured_us, picture.received_us, now);
Environment scope; Environment scope;
@@ -261,8 +259,8 @@ void RenderVideo(Session* s, VideoPicture picture) {
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear(); if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
} }
} }
Params Parameters(Session* s, bool controller) { MiniRtcParams Parameters(Session* s, bool controller) {
Params p{}; MiniRtcParams p{};
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s", s->host.c_str()); std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s", s->host.c_str());
std::snprintf(p.log_path, sizeof(p.log_path), "%s", s->log_path.c_str()); std::snprintf(p.log_path, sizeof(p.log_path), "%s", s->log_path.c_str());
p.signal_server_port = s->port; p.signal_server_port = s->port;
@@ -270,12 +268,12 @@ Params Parameters(Session* s, bool controller) {
// MediaCodec returns retained Surface buffers; software fallback returns // MediaCodec returns retained Surface buffers; software fallback returns
// pooled CPU NV12 with the same ownership contract. // pooled CPU NV12 with the same ownership contract.
p.native_video_output = controller; p.native_video_output = controller;
p.turn_mode = TurnAutoUdpTcp; p.turn_mode = MiniRtcTurnAutoUdpTcp;
p.enable_srtp = true; p.enable_srtp = true;
p.video_content_type = VideoContentType::ScreenContent; p.video_content_type = MiniRtcVideoContentType::ScreenContent;
p.video_quality = QualityMedium; p.video_quality = MiniRtcVideoQualityMedium;
p.video_frame_rate = 30; p.video_frame_rate = 30;
p.video_degradation_preference = VideoDegradationPreference::Balanced; p.video_degradation_preference = MiniRtcVideoDegradationPreference::Balanced;
p.on_signal_status = Signal; p.on_connection_status = Connection; p.on_signal_status = Signal; p.on_connection_status = Connection;
p.on_signal_message = SignalMessage; p.on_signal_message = SignalMessage;
p.on_net_status_report = Network; p.on_receive_data_buffer = Data; p.on_net_status_report = Network; p.on_receive_data_buffer = Data;
@@ -289,7 +287,7 @@ Session* From(jlong handle) { return reinterpret_cast<Session*>(handle); }
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) { extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) {
vm = java_vm; vm = java_vm;
minirtc::android::SetJavaVm(java_vm); if (MiniRtcSetAndroidJavaVm(java_vm) != 0) return JNI_ERR;
return JNI_VERSION_1_6; return JNI_VERSION_1_6;
} }
#define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name #define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name
@@ -343,11 +341,11 @@ extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)(
throw std::runtime_error("Unable to configure Android trust bundle"); throw std::runtime_error("Unable to configure Android trust bundle");
// MiniRTC's diagnostic log includes login identities. Keep credentials // MiniRTC's diagnostic log includes login identities. Keep credentials
// solely in Keystore-backed storage; do not persist its session logging. // solely in Keystore-backed storage; do not persist its session logging.
minirtc::InitLogger(s->log_path); if (MiniRtcConfigureLogging(MiniRtcLogOff, nullptr, nullptr) != 0)
minirtc::get_logger()->set_level(spdlog::level::off); throw std::runtime_error("Unable to configure MiniRTC logging");
auto params = Parameters(s.get(), false); auto params = Parameters(s.get(), false);
s->identity = CreatePeer(&params); s->identity = MiniRtcCreatePeer(&params);
if (!s->identity || Init(s->identity) != 0) return 0; if (!s->identity || MiniRtcInit(s->identity) != 0) return 0;
return reinterpret_cast<jlong>(s.release()); return reinterpret_cast<jlong>(s.release());
} catch (const std::exception&) { return 0; } } catch (const std::exception&) { return 0; }
} }
@@ -358,15 +356,15 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
s->controller_login = "C-" + String(env, identity); s->controller_login = "C-" + String(env, identity);
s->remote = String(env, remote); s->remote = String(env, remote);
auto params = Parameters(s, true); auto params = Parameters(s, true);
s->controller = CreatePeer(&params); s->controller = MiniRtcCreatePeer(&params);
if (!s->controller) return false; if (!s->controller) return false;
AddAudioStream(s->controller, kAudioStream); MiniRtcAddAudioStream(s->controller, kAudioStream);
AddDataStream(s->controller, kDataStream, false); MiniRtcAddDataStream(s->controller, kDataStream, false);
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream, for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
kClipboardStream, kFileStream, kFileFeedbackStream}) AddDataStream(s->controller, name, true); kClipboardStream, kFileStream, kFileFeedbackStream}) MiniRtcAddDataStream(s->controller, name, true);
if (Init(s->controller) != 0) return false; if (MiniRtcInit(s->controller) != 0) return false;
const auto join = s->remote + "@" + String(env, password); const auto join = s->remote + "@" + String(env, password);
return JoinConnection(s->controller, join.c_str()) == 0; return MiniRtcJoinConnection(s->controller, join.c_str()) == 0;
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) { extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); if (!s || !s->controller) return; auto* s = From(handle); if (!s || !s->controller) return;
@@ -422,13 +420,13 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)(
if (!s || !s->controller || s->stopping || request_id < 0 || request_id > UINT32_MAX) return false; if (!s || !s->controller || s->stopping || request_id < 0 || request_id > UINT32_MAX) return false;
const auto data = android_controller::VideoSettingsMessage(quality, rate, preference, static_cast<uint32_t>(request_id)); const auto data = android_controller::VideoSettingsMessage(quality, rate, preference, static_cast<uint32_t>(request_id));
if (data.empty()) return false; if (data.empty()) return false;
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0; return MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
} }
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) { extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); auto* s = From(handle);
if (!s || !s->controller || s->stopping) return false; if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::SecureAttentionMessage(); const auto data = android_controller::SecureAttentionMessage();
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0; return MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
} }
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)( extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) { JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
@@ -436,7 +434,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
if (!s || !s->controller || s->stopping) return false; if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::PrivacyMessage(flag, block_input); const auto data = android_controller::PrivacyMessage(flag, block_input);
return !data.empty() && return !data.empty() &&
SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0; MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) { extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory)); auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
@@ -468,7 +466,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)(
auto message = json::parse(bytes, nullptr, false); auto message = json::parse(bytes, nullptr, false);
if (!message.is_object() || !message.contains("type") || if (!message.is_object() || !message.contains("type") ||
!message["type"].is_string() || message["type"] != "announcements_list") return false; !message["type"].is_string() || message["type"] != "announcements_list") return false;
return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0; return MiniRtcSendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
} }
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)( extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
JNIEnv* env, jclass, jlong handle, jbyteArray data) { JNIEnv* env, jclass, jlong handle, jbyteArray data) {
@@ -485,7 +483,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
!message.contains("user_id") || !message["user_id"].is_string() || !message.contains("user_id") || !message["user_id"].is_string() ||
!message.contains("devices") || !message["devices"].is_array() || !message.contains("devices") || !message["devices"].is_array() ||
!message.contains("subscribe") || message["subscribe"] != true) return false; !message.contains("subscribe") || message["subscribe"] != true) return false;
return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0; return MiniRtcSendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)( extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)(
JNIEnv* env, jclass, jlong handle, jbyteArray data) { JNIEnv* env, jclass, jlong handle, jbyteArray data) {
@@ -500,7 +498,7 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nSurface)(JNIEnv* env, jclass, jlon
if (auto* s = From(handle)) { if (auto* s = From(handle)) {
s->Surface(env, surface); s->Surface(env, surface);
if (surface && s->controller && !s->stopping) if (surface && s->controller && !s->stopping)
RequestVideoKeyFrame(s->controller, MakeDisplayStreamId(s->display.load()).c_str()); MiniRtcRequestVideoKeyFrame(s->controller, MakeDisplayStreamId(s->display.load()).c_str());
} }
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nDestroy)(JNIEnv*, jclass, jlong handle) { delete From(handle); } extern "C" JNIEXPORT void JNICALL JNI_METHOD(nDestroy)(JNIEnv*, jclass, jlong handle) { delete From(handle); }
+1 -3
View File
@@ -9,11 +9,9 @@ includes("../../../deps/submodules/minirtc")
target("crossdesk_android") target("crossdesk_android")
set_kind("shared") set_kind("shared")
add_deps("minirtc") add_deps("minirtc")
add_packages("nlohmann_json", "libyuv", "spdlog") add_packages("nlohmann_json", "libyuv")
add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp") add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp")
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include") add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include")
-- This source-level JNI adapter is the only application layer using MiniRTC internals.
add_includedirs("../../../deps/submodules/minirtc/src/media/mediacodec")
add_syslinks("android", "log") add_syslinks("android", "log")
add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true}) add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true})
set_targetdir("../app/build/native/arm64-v8a") set_targetdir("../app/build/native/arm64-v8a")
+11 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build the native controller in isolation from desktop and iOS caches.""" """Build the native controller in isolation from desktop and iOS caches."""
import argparse
import fcntl import fcntl
import os import os
from pathlib import Path from pathlib import Path
@@ -26,7 +27,7 @@ def find_xmake():
return xmake return xmake
def main(): def main(configure_only=False):
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
or Path.home() / 'Library/Android/sdk') or Path.home() / 'Library/Android/sdk')
ndk = sdk / 'ndk/28.2.13676358' ndk = sdk / 'ndk/28.2.13676358'
@@ -45,13 +46,20 @@ def main():
# and updates. Only installed Android packages/build products are isolated. # and updates. Only installed Android packages/build products are isolated.
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a', subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26', '-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
'--runtimes=c++_static', '--policies=package.precompiled:n', '--runtimes=c++_static',
'--policies=package.precompiled:n,package.librarydeps.strict_compatibility',
'-o', str(CACHE / 'build')], cwd=work, env=env, check=True) '-o', str(CACHE / 'build')], cwd=work, env=env, check=True)
if configure_only:
return
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True) subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--configure-only', action='store_true',
help='Install native dependencies before compiling application targets.')
args = parser.parse_args()
CACHE.mkdir(parents=True, exist_ok=True) CACHE.mkdir(parents=True, exist_ok=True)
with (CACHE / 'build.lock').open('w') as lock: with (CACHE / 'build.lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX) fcntl.flock(lock, fcntl.LOCK_EX)
main() main(configure_only=args.configure_only)
@@ -27,13 +27,13 @@ int main() {
stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000; stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000;
stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000; stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000;
stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true; stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true;
auto json = android_controller::NetworkReport(stats, Relay); auto json = android_controller::NetworkReport(stats, MiniRtcTraversalRelay);
assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40); assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40);
assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000); assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000);
assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000); assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000);
assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX); assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX);
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001); assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
stats.rtt_ms = -1; stats.srtp_active = false; stats.rtt_ms = -1; stats.srtp_active = false;
json = android_controller::NetworkReport(stats, UnknownMode); json = android_controller::NetworkReport(stats, MiniRtcTraversalUnknown);
assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1); assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1);
} }
+64 -5
View File
@@ -1,15 +1,39 @@
# CrossDesk CLI # CrossDesk CLI
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。 CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty / Sixel 图形后端,以及 Pi 自定义 TUI 扩展。
分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)的原生终端客户端。npm 包发布后可统一使用: ## 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="../../docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 全景输出,包含只读状态栏与快捷键提示" /> | <img src="../../docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 像素全景输出,显示同一远程桌面" /> |
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 依次选择 Kitty、Sixel、ANSI;普通终端可指定 `--renderer ansi`,支持对应协议的终端可指定 `--renderer kitty` 或 `--renderer sixel`。显式像素模式需要通过能力探测;增加 Sixel 输出不代表所有 Xshell 版本均可显示图片,终端本身也必须支持该协议。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
## 安装与连接
### 通过 npm 安装
需要 **Node.js 22 或以上**和 npm。统一入口为 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli),分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)。安装时自动选择对应的原生程序,无需 Xmake 或 C++ 编译工具链:
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将示例 ID 替换为被控电脑的 9 位 ID,按提示隐藏输入密码。`doctor` 仅检查本机环境,不发起连接。更新到最新版时重新执行上述安装命令。
无需全局安装时,使用 `npx`,首次运行会下载对应平台包:
```sh ```sh
npx @chunkdash/crossdesk-cli@latest doctor --json npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789 npx @chunkdash/crossdesk-cli@latest 123456789
``` ```
统一入口包为 `@chunkdash/crossdesk-cli`,安装后命令仍为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择。旧的无 scope 平台包不属于这套依赖;仅安装平台包不会注册命令。是否可安装以 npm 上完整版本的发布结果为准,三端实机验收尚未全部完成。运行要求、打包、本地安装与发布步骤见 [npm 分发](docs/npm-distribution.md)。 安装后命令为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择;请保留 npm 的可选依赖,不要使用 `--omit=optional`。旧的无 scope 平台包不属于这套依赖,仅安装平台包不会注册命令。三端实机验收尚未全部完成;运行要求、旧包迁移、打包与发布步骤见 [npm 分发](docs/npm-distribution.md)。
### 从源码构建
在 CrossDesk 仓库根目录执行以下命令构建 CLI: 在 CrossDesk 仓库根目录执行以下命令构建 CLI:
@@ -21,8 +45,43 @@ xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli 123456789 --quality high --zoom native xmake run crossdesk-cli 123456789 --quality high --zoom native
``` ```
`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时 `c` 开启控制、`q` 或 `Ctrl+C` 退出;点击目标或按 `d` 进入原始细节模式,`+/-` 或滚轮缩放,方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移,`f` 回到完整桌面。任何模式下先按 `Ctrl+]` 进入持续本地命令模式,再按 `c` 选择返回后的控制状态、`q` 退出或视图按键连续调整画面,`Esc` / `Ctrl+C` 手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。 构建后可直接运行 `build/<platform>/<arch>/release/crossdesk-cli`,也可将对应目录加入 PATH;Windows 的文件名为 `crossdesk-cli.exe`。
### 常用连接命令
以下示例适用于 npm 全局安装,或已将原生程序加入 PATH 的环境。`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。设备 ID 是格式示例,使用时替换为你有权访问的设备,密码通过隐藏提示输入:
```sh
crossdesk-cli 123456789 --renderer kitty
crossdesk-cli 123456789 --renderer sixel --fps 8
crossdesk-cli 123456789 --renderer ansi --zoom native
crossdesk-cli 123456789 --quality balanced --fps 8
crossdesk-cli 123456789 --display 1
```
默认请求 `high` 画质(最高 1920 × 1080)和清晰度优先策略,`--display` 的索引从 `0` 开始。交互 CLI 默认本地输出目标为 30 FPS,远端供帧会随输出耗时自适应;显式 `--fps` 固定目标和相应预览请求,各帧率下均优先保持分辨率,实际画质与帧率仍取决于设备、终端与链路。更多参数见 [主 README](../../README.md#cli) 和 [终端使用指南](docs/terminal-client.md)。
## 查看、控制与退出
| 操作 | 按键 |
| --- | --- |
| 开启远端控制 | 只读模式下按 `c` |
| 缩放 | 只读或本地命令模式下用 `+` / `=` / `-` 以鼠标位置缩放;只读时也可用滚轮 |
| 回到全景 | 只读或本地命令模式下按 `f`,同时重置倍率、平移和细节模式 |
| 平移 | 只读或本地命令模式下用方向键或 `i` 上、`j` 左、`k` 下、`l` 右 |
| 原始细节 | 只读时点击目标位置,或以 `--zoom native` 启动 |
| 暂停远端输入并进入本地命令模式 | `Ctrl+]` |
| 选择返回后的控制状态 | 本地命令模式下按 `c`,再按 `Esc` / `Ctrl+C` 返回 |
| 退出连接 | 只读模式下按 `q` / `Ctrl+C`;控制模式下先按 `Ctrl+]`,再按 `q` |
缩放尽量保持鼠标下的画面内容原位;没有有效鼠标位置时沿用当前中心。底部提示随模式和窗口宽度调整,`Zoom at mouse` 表示鼠标锚点缩放,`Full view` / `Full` 表示恢复全景。
本地命令模式在缩放、平移和按 `c` 后持续保持,直到手动返回。此模式中鼠标仅更新缩放锚点,点击和滚轮不执行视图操作。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。再次按 `Ctrl+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。
## 更多文档与功能范围
- [MCP 服务与实时面板](../mcp/README.md):Agent 截图和结构化远程操作,不要求控制端安装桌面客户端。
- [原生自动化协议](docs/automation-protocol.md):独立 `agent --stdio` 模式。
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md) - [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
- [私有桥接协议](docs/bridge-protocol.md) - [私有桥接协议](docs/bridge-protocol.md)
- [实际验证记录](docs/validation.md) - [实际验证记录](docs/validation.md)
@@ -30,4 +89,4 @@ xmake run crossdesk-cli 123456789 --quality high --zoom native
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。 CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。 终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。CLI 不创建被控端虚拟桌面,不提供音频播放、文件传输或剪贴板同步;MCP 服务由独立的 `apps/mcp` 包提供。
+1227
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-02
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _AGENT_H_
#define _AGENT_H_
#include <controller_session.h>
namespace crossdesk::cli {
int Agent(int output_fd, SessionOptions options, int password_fd,
bool video_websocket = false);
}
#endif
+324
View File
@@ -0,0 +1,324 @@
#include "automation_protocol.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <set>
#include <stdexcept>
namespace crossdesk::cli::automation {
void Fields(const json &j, std::initializer_list<const char *> names) {
if (!j.is_object())
throw std::invalid_argument("INVALID_REQUEST");
for (auto it = j.begin(); it != j.end(); ++it)
if (std::none_of(names.begin(), names.end(),
[&](const char *s) { return it.key() == s; }))
throw std::invalid_argument("UNKNOWN_FIELD");
}
int Number(const json &j, const char *key, int low, int high, int fallback) {
if (!j.contains(key))
return fallback;
if (!j[key].is_number_integer())
throw std::invalid_argument("INVALID_REQUEST");
const auto n = j[key].get<int64_t>();
if (n < low || n > high)
throw std::invalid_argument("INVALID_REQUEST");
return int(n);
}
uint64_t Sequence(const json &j, const char *key, uint64_t fallback) {
if (!j.contains(key))
return fallback;
if (!j[key].is_number_unsigned())
throw std::invalid_argument("INVALID_REQUEST");
return j[key].get<uint64_t>();
}
bool Flag(const json &j, const char *key, bool fallback) {
if (!j.contains(key))
return fallback;
if (!j[key].is_boolean())
throw std::invalid_argument("INVALID_REQUEST");
return j[key].get<bool>();
}
std::string String(const json &j, const char *key, size_t limit) {
if (!j.contains(key) || !j[key].is_string())
throw std::invalid_argument("INVALID_REQUEST");
auto s = j[key].get<std::string>();
if (s.empty() || s.size() > limit || s.find('\0') != std::string::npos)
throw std::invalid_argument("INVALID_REQUEST");
return s;
}
namespace {
int KeyCode(const std::string &key) {
if (key.size() == 1 &&
((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= '0' && key[0] <= '9')))
return key[0];
static const std::map<std::string, int> keys = {{"BACKSPACE", 8},
{"TAB", 9},
{"ENTER", 13},
{"SHIFT", 16},
{"CTRL", 17},
{"ALT", 18},
{"PAUSE", 19},
{"CAPS_LOCK", 20},
{"ESC", 27},
{"SPACE", 32},
{"PAGE_UP", 33},
{"PAGE_DOWN", 34},
{"END", 35},
{"HOME", 36},
{"LEFT", 37},
{"UP", 38},
{"RIGHT", 39},
{"DOWN", 40},
{"INSERT", 45},
{"DELETE", 46},
{"META", 91},
{"F1", 112},
{"F2", 113},
{"F3", 114},
{"F4", 115},
{"F5", 116},
{"F6", 117},
{"F7", 118},
{"F8", 119},
{"F9", 120},
{"F10", 121},
{"F11", 122},
{"F12", 123},
{"F13", 124},
{"F14", 125},
{"F15", 126},
{"F16", 127},
{"F17", 128},
{"F18", 129},
{"F19", 130},
{"F20", 131},
{"F21", 132},
{"F22", 133},
{"F23", 134},
{"F24", 135},
{"NUMPAD0", 96},
{"NUMPAD1", 97},
{"NUMPAD2", 98},
{"NUMPAD3", 99},
{"NUMPAD4", 100},
{"NUMPAD5", 101},
{"NUMPAD6", 102},
{"NUMPAD7", 103},
{"NUMPAD8", 104},
{"NUMPAD9", 105},
{"NUM_LOCK", 144},
{"SCROLL_LOCK", 145},
{"PRINT_SCREEN", 44},
{"MENU", 93},
{"MULTIPLY", 106},
{"ADD", 107},
{"SUBTRACT", 109},
{"DECIMAL", 110},
{"DIVIDE", 111},
{"VOLUME_MUTE", 173},
{"VOLUME_DOWN", 174},
{"VOLUME_UP", 175},
{"MEDIA_NEXT", 176},
{"MEDIA_PREVIOUS", 177},
{"MEDIA_STOP", 178},
{"MEDIA_PLAY_PAUSE", 179},
{"SEMICOLON", 186},
{"EQUAL", 187},
{"COMMA", 188},
{"MINUS", 189},
{"PERIOD", 190},
{"SLASH", 191},
{"BACKQUOTE", 192},
{"BRACKET_LEFT", 219},
{"BACKSLASH", 220},
{"BRACKET_RIGHT", 221},
{"QUOTE", 222}};
auto it = keys.find(key);
if (it == keys.end())
throw std::invalid_argument("INVALID_KEY");
return it->second;
}
} // namespace
std::vector<Step> BuildInputSteps(const json &a, const InputGeometry &o) {
const auto type = String(a, "type", 32);
std::vector<Step> steps;
auto point = [&](const json &p, int delay, int flag = 0, int wheel = 0) {
auto coordinate = [&](const char *key, int size, int offset, int span,
int source) {
if (!p.contains(key) || !p[key].is_number())
throw std::invalid_argument("INVALID_COORDINATE");
const auto value = p[key].get<double>();
if (!std::isfinite(value) || value < 0 || value >= size)
throw std::invalid_argument("INVALID_COORDINATE");
const auto center = std::min(value + .5, double(size) - .5);
return float(std::clamp((offset + center * span / size) / source, 0.0, 1.0));
};
Step step;
step.delay = delay;
step.flag = flag;
step.wheel = wheel;
step.x =
coordinate("x", o.width, o.region.x, o.region.width, o.source_width);
step.y =
coordinate("y", o.height, o.region.y, o.region.height, o.source_height);
steps.push_back(std::move(step));
};
auto key = [&](int code, bool down, int delay) {
Step step;
step.kind = StepKind::Key;
step.key = code;
step.down = down;
step.delay = delay;
steps.push_back(std::move(step));
};
if (type == "mouse_down" || type == "mouse_up") {
Fields(a, {"type", "x", "y", "button"});
const auto button = a.contains("button") ? String(a, "button", 8) : "left";
const int flag = button == "left" ? 1 : button == "right" ? 3 : button == "middle" ? 5 : -1;
if (flag < 0) throw std::invalid_argument("INVALID_BUTTON");
point(a, 0);
point(a, 10, flag + (type == "mouse_up" ? 1 : 0));
} else if (type == "key_down" || type == "key_up") {
Fields(a, {"type", "key"});
key(KeyCode(String(a, "key", 16)), type == "key_down", 0);
} else if (type == "move" || type == "click" || type == "double_click") {
Fields(a, type == "move"
? std::initializer_list<const char *>{"type", "x", "y",
"modifiers"}
: std::initializer_list<const char *>{"type", "x", "y",
"button", "modifiers"});
point(a, 0);
if (type != "move") {
const auto button =
a.contains("button") ? String(a, "button", 8) : "left";
const int flag = button == "left" ? 1
: button == "right" ? 3
: button == "middle" ? 5
: -1;
if (flag < 0)
throw std::invalid_argument("INVALID_BUTTON");
point(a, 10, flag);
point(a, 40, flag + 1);
if (type == "double_click") {
point(a, 110, flag);
point(a, 140, flag + 1);
}
}
} else if (type == "scroll") {
Fields(a, {"type", "x", "y", "direction", "ticks", "modifiers"});
const auto direction = String(a, "direction", 8);
if (direction != "up" && direction != "down" && direction != "left" &&
direction != "right")
throw std::invalid_argument("INVALID_DIRECTION");
const int ticks = Number(a, "ticks", 1, 10, 1);
point(a, 0);
point(a, 10, direction == "left" || direction == "right" ? 8 : 7,
(direction == "up" || direction == "right" ? 120 : -120) * ticks);
} else if (type == "drag") {
Fields(a, {"type", "path", "duration_ms", "button", "modifiers"});
const auto button = a.contains("button") ? String(a, "button", 8) : "left";
const int flag = button == "left" ? 1
: button == "right" ? 3
: button == "middle" ? 5
: -1;
if (flag < 0)
throw std::invalid_argument("INVALID_BUTTON");
const auto &path = a.at("path");
if (!path.is_array() || path.size() < 2 || path.size() > 128)
throw std::invalid_argument("INVALID_PATH");
const int duration = Number(a, "duration_ms", 100, 30000, 500);
for (const auto &p : path)
Fields(p, {"x", "y"});
point(path.front(), 0);
point(path.front(), 10, flag);
for (size_t i = 1; i < path.size(); ++i)
point(path[i], 10 + int(i * duration / (path.size() - 1)));
point(path.back(), duration + 30, flag + 1);
} else if (type == "key_press") {
Fields(a, {"type", "key", "modifiers", "duration_ms"});
const auto code = KeyCode(String(a, "key", 16));
key(code, true, 0);
key(code, false, Number(a, "duration_ms", 10, 30000, 40));
} else if (type == "hotkey") {
Fields(a, {"type", "keys", "duration_ms"});
const auto &keys = a.at("keys");
if (!keys.is_array() || keys.size() < 2 || keys.size() > 6)
throw std::invalid_argument("INVALID_KEYS");
std::set<int> seen;
std::vector<int> codes;
for (const auto &item : keys) {
if (!item.is_string())
throw std::invalid_argument("INVALID_KEY");
int code = KeyCode(item.get<std::string>());
if (!seen.insert(code).second)
throw std::invalid_argument("DUPLICATE_KEY");
codes.push_back(code);
}
int delay = 0;
for (int code : codes) {
key(code, true, delay);
delay += 15;
}
delay += Number(a, "duration_ms", 10, 30000, 30);
for (auto it = codes.rbegin(); it != codes.rend(); ++it) {
key(*it, false, delay);
delay += 15;
}
} else if (type == "type_text") {
Fields(a, {"type", "text"});
Step step;
step.kind = StepKind::Text;
step.text = String(a, "text", 16384);
steps.push_back(std::move(step));
} else
throw std::invalid_argument("UNSUPPORTED_ACTION");
if (a.contains("modifiers")) {
const auto &modifiers = a["modifiers"];
if (!modifiers.is_array() || modifiers.size() > 4)
throw std::invalid_argument("INVALID_KEYS");
std::set<int> seen;
std::vector<Step> prefix;
for (const auto &item : modifiers) {
if (!item.is_string())
throw std::invalid_argument("INVALID_KEY");
const auto name = item.get<std::string>();
if (name != "CTRL" && name != "ALT" && name != "SHIFT" && name != "META")
throw std::invalid_argument("INVALID_KEY");
const int code = KeyCode(name);
if (!seen.insert(code).second ||
(type == "key_press" && String(a, "key", 16) == name))
throw std::invalid_argument("DUPLICATE_KEY");
Step step;
step.kind = StepKind::Key;
step.key = code;
step.down = true;
step.delay = int(prefix.size()) * 15;
prefix.push_back(step);
}
const int offset = int(prefix.size()) * 15;
for (auto &step : steps)
step.delay += offset;
steps.insert(steps.begin(), prefix.begin(), prefix.end());
int delay = steps.back().delay + 15;
for (auto it = prefix.rbegin(); it != prefix.rend(); ++it, delay += 15)
key(it->key, false, delay);
}
return steps;
}
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos) {
if (password.empty() || password.size() > 256 ||
password.find('\0') != std::string::npos || password.find_first_of("\r\n\t") != std::string::npos)
throw std::invalid_argument("INVALID_CREDENTIAL");
const InputGeometry geometry{};
auto steps = BuildInputSteps({{"type", "hotkey"}, {"keys", {macos ? "META" : "CTRL", "A"}}}, geometry);
auto text = BuildInputSteps({{"type", "type_text"}, {"text", password}}, geometry);
text.front().delay = 150;
steps.insert(steps.end(), text.begin(), text.end());
auto enter = BuildInputSteps({{"type", "key_press"}, {"key", "ENTER"}}, geometry);
for (auto &step : enter) step.delay += 350;
steps.insert(steps.end(), enter.begin(), enter.end());
return steps;
}
} // namespace crossdesk::cli::automation
+42
View File
@@ -0,0 +1,42 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-03
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _AUTOMATION_PROTOCOL_H_
#define _AUTOMATION_PROTOCOL_H_
#include <frame_image.h>
#include <initializer_list>
#include <string>
#include <vector>
namespace crossdesk::cli::automation {
using nlohmann::json;
void Fields(const json &, std::initializer_list<const char *> names);
int Number(const json &, const char *key, int low, int high, int fallback);
uint64_t Sequence(const json &, const char *key, uint64_t fallback = 0);
bool Flag(const json &, const char *key, bool fallback = false);
std::string String(const json &, const char *key, size_t limit = 128);
struct InputGeometry {
int width, height, source_width, source_height;
ImageRegion region;
};
enum class StepKind { Pointer, Key, Text };
struct Step {
StepKind kind = StepKind::Pointer;
int delay = 0, key = 0, flag = 0, wheel = 0;
float x = 0, y = 0;
bool down = false;
std::string text;
};
// Pure validation and translation: no session access or input side effects.
std::vector<Step> BuildInputSteps(const json &action,
const InputGeometry &geometry);
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos);
} // namespace crossdesk::cli::automation
#endif
+175
View File
@@ -0,0 +1,175 @@
# Native automation protocol
`crossdesk-cli agent --stdio --password-fd 3 --video-ws` is the private native adapter used by CrossDesk MCP. It is separate from the existing terminal-oriented `bridge` protocol. This is **not MCP on stdin**: the TypeScript service owns the public MCP transport and tool schemas.
Optional native arguments are `--server HOST`, `--port PORT`, `--quality PRESET` and `--log-dir DIRECTORY`. The caller creates the log directory. Diagnostic stdout is redirected to stderr; only protocol responses use the preserved output descriptor. Passwords travel over descriptor 3, terminated by newline or EOF, and never appear in JSON requests. `--video-ws` starts an authenticated, native loopback WebSocket listener on an ephemeral port. There is no video file descriptor. Agent mode selects the high-frame-rate preview tier at up to 1920 × 1080 by default; negotiated remote capabilities and network conditions determine the delivered rate. Each process accepts one connection attempt; retry authentication with a new process and credential pipe.
## Envelope
One UTF-8 JSON object per line, maximum request 64 KiB and nesting depth 32. Use nonnegative integer request IDs. Responses may complete out of order; correlate by ID.
```json
{"id":1,"method":"hello","protocol":"crossdesk-automation/1"}
{"id":1,"ok":true,"result":{"protocol":"crossdesk-automation/1","image_formats":["image/png"],"action_completion":"submitted","human_pause":true,"max_request_bytes":65536,"max_response_bytes":4194304,"operation_cache_entries":128,"operation_cache_ms":300000}}
```
Unknown fields and malformed arguments are rejected. Error responses contain a stable code, retryability and whether input may already have been submitted:
```json
{"id":2,"ok":false,"error":{"code":"CONTROL_DENIED","retryable":false,"side_effect_possible":false}}
```
The output queue is bounded to 64 messages and 8 MiB; a blocked output pipe terminates after 10 seconds without progress. EOF, signals and `exit` disconnect and release held input. A caller that needs responses must keep stdin open until they arrive.
## Methods
| Method | Parameters in addition to `id`/`method` |
| --- | --- |
| `hello` | `protocol: "crossdesk-automation/1"` |
| `connect` | `device` (9 digits), `mode: "observe" | "control"`, `display` (0–63), optional `decode_path: "browser" / "minirtc"`, `decoder: "auto" / "software"` |
| `status` | none |
| `screenshot` | optional `region: {x,y,width,height}`, `max_edge` (64–2048), `after_frame_id`, `generation`, `timeout_ms` (0–5000) |
| `video_open` | `ticket`, `view`, `view_generation`, `session`, `origin`, `apps`; requires `--video-ws` and a connected session |
| `video_revoke` | optional `view`; omit to revoke all video grants and sockets |
| `preview` | optional `max_edge` (64–960), `after_frame_id`, `generation`, `timeout_ms` (0–5000); no region |
| `action` | `operation_id`, `observation_id`, `action` |
| `unlock_prepare` | `observation_id`; validates control, fresh frames and text-input support, returns `generation` and `geometry_epoch` |
| `unlock` | `operation_id`, `observation_id`, `password`; trusted local credential submission to the focused system password field |
| `control` | `enabled` boolean; cannot override observe-only permission or human pause |
| `viewer_control` | `operation: "pause" | "resume" | "disconnect"`; restricted to trusted UI by the MCP adapter |
| `terminal_frame` | `columns` (1–160), `rows` (1–60), `colors` (8/24/256), `cell_aspect` (0.2–2); private terminal viewer only |
| `display` | `index` (0–63) |
| `cancel` | `request_id`; cancels queued/in-flight captures or input sequences |
| `disconnect`, `exit` | none; `exit` also ends the process |
`connect` returns immediately after initiating transport. Poll `status` until `state == "ready"`; this requires a frame from the current display generation. Status includes `transport_state`, `geometry_epoch`, `frame_age_ms`, `source_size`, `control_allowed`, `human_paused`, host platform, displays and transport diagnostics.
During initial `registering`, `MiniRtcSignalReconnecting` keeps the existing signaling peer and its backoff worker alive within the original 30-second connection deadline. Expiry in that phase reports `signaling_timeout`; expiry after submitting a device join reports `connection_timeout`. Certificate validation and login rejection terminate the attempt immediately. A signaling interruption after join submission also terminates the attempt. Explicit cancellation closes the peer and its retry worker. The MCP adapter forwards recognized status errors as an optional `error.reason` alongside its public error category.
## Live video
`doctor --json` advertises `automation_video_transport: "loopback-websocket/1"`, `automation_video_protocols: ["h264-annexb/1", "nv12/1"]`, and `automation_video_decoders: ["auto", "software"]`. `hello` repeats these without the `automation_` prefix. The MCP adapter requires the transport capability, so an old pipe-only CLI is rejected. Node spawns only stdin, stdout, stderr, and the credential pipe; live video never enters Node.
The terminal viewer additionally requires `automation_terminal_protocol: "ansi/1"` in `doctor` and `terminal_protocol: "ansi/1"` in `hello`. `terminal_frame` asynchronously converts the current decoded frame using the existing ANSI quadrant renderer and returns `columns`, `rows`, `lines`, `generation`, `geometry_epoch`, and a nullable `cursor` with zero-based terminal `column`/`row`. Only one render may be in flight; obsolete geometry or frames older than two seconds are rejected. These rendered text rows travel through the private authenticated terminal IPC, never through public MCP tools or model content. The viewer requests at most 8 FPS and waits for terminal writes before requesting another frame. It shares the automation connection; no second media session is opened.
Status includes `cursor` with normalized full-frame `x`/`y`, `visible`, `generation`, `geometry_epoch`, `width`, and `height`. The native layer updates it after each successfully sent pointer step, including drag steps, using the same ROI-to-source mapping as remote input. It hides on pause/control disable and clears on geometry changes. It is an Agent position indicator, not remote cursor telemetry or execution confirmation; observation PNGs and source video remain unchanged.
Authenticated video sockets also receive small JSON text messages with `type: "cursor"` and these fields, independently of binary video frames. Pointer updates are coalesced to at most 30 Hz, retained for newly authorized viewers, and deferred under socket backpressure; they require no video ACK. Browser/Apps render a separate transparent overlay with the same aspect-ratio fitting as the video, including when no new video frame arrives.
`connect.decode_path` defaults to `browser` (compressed H.264), or selects `minirtc` (decoded NV12). `connect.decoder` defaults to `auto` (hardware preferred, software when unavailable); `software` selects the native software decoder. Both paths maintain native screenshot frames with the selected decoder. These choices are fixed for the connection. Status exposes `video` and the reported `native_video_decoder` backend name. PNG encoding runs only when requested. The legacy `preview` image method remains available to private protocol callers but is not used by MCP panels.
### Authorization and transport
The trusted adapter generates 24 random bytes as a 32-character base64url `ticket` and sends `video_open` over private JSON-RPC. `view` and `session` are also 32-character base64url IDs; `view_generation` is positive. `origin` is the Node panel's exact `http://127.0.0.1:PORT` origin. `apps` permits an opaque (`null`) or HTTPS MCP Apps origin, but still requires the same one-time ticket. No password is included.
The response contains `{url, transport, protocol, decoder, expires_in_ms:15000}`. Node supplies the ticket to the authorized UI. The browser opens the returned `ws://127.0.0.1:PORT/api/video` directly and sends `{"ticket":"..."}` as its first text message within 3 seconds. Tickets expire after 15 seconds, are consumed once, and are bound to a view/session plus the native display generation. The native service checks the exact Host and path, RFC 6455 version, and authorized Origin. It does not serve HTTP content or accept remote input commands. Logs are disabled for this listener; tickets never go in URLs or model-visible results.
The listener uses its own I/O thread, at most 16 WebSocket candidates and 8 authorized viewers. Client messages are limited to 1024 bytes. Revocation, disconnect, native generation changes, or shutdown cancel sockets and invalidate tickets, including partially sent frames. Pause only disables Agent control; viewing continues. The adapter explicitly revokes a removed or replaced view, and terminates the native session if revocation cannot be confirmed. Native processes own ephemeral ports, so panel CSP allows `ws://127.0.0.1:*`, restricted to the literal loopback address; the control HTTP origin remains exact.
### Binary messages and flow control
Each binary WebSocket message contains one complete frame, **without the old pipe length prefix**. Integers are big-endian. For `h264-annexb/1`:
| Offset | Type | Value |
| --- | --- | --- |
| 0 | u32 | `0x43445631` (`CDV1`) |
| 4 | u32 | Display/connection generation |
| 8 | u32 | Receive sequence; gaps require a new keyframe |
| 12 | u64 | Local monotonic receive timestamp in microseconds |
| 20 | u32 | Flags: 1 = IDR keyframe, 0 = delta |
| 24 | bytes | Annex B H.264; keyframes include SPS and PPS |
Access units are capped at 4 MiB. Both the receive tap and the I/O handoff are bounded to 60 frames / 8 MiB. Overflow discards dependent frames until an IDR. After receiving each message the browser sends `{"type":"ack","generation":N,"sequence":N}` directly to the native socket, in order. This acknowledges receipt, not decode, presentation, or remote execution. Per-viewer unacknowledged output is capped at 8 frames; at 1 MiB outstanding, further frames are skipped until space is available (a single keyframe may exceed that threshold). Dropping a dependent picture requires a new keyframe; `{"type":"keyframe"}` requests recovery, rate-limited to once per second.
For `nv12/1`, magic is `0x43444e31` (`CDN1`). Offsets 4, 8, 12 and 20 remain generation, decoded sequence, local monotonic decoded-frame queue time in microseconds, and flags (always 1). Offsets 24 and 28 are u32 width and height; offset 32 starts tightly packed Y followed by interleaved UV. Dimensions must be even, at most 8192 per axis / 16777216 pixels. Message size must equal `32 + width * height * 3 / 2` (at most 25165856 bytes). Color is limited-range BT.601, matching the existing PNG conversion. Sequence gaps are allowed.
NV12 retains one latest picture in the I/O handoff and at most one unacknowledged picture per viewer. New pictures are skipped for a slow viewer without blocking others. Each prepared WebSocket message is immutable and shared between recipients, avoiding per-viewer payload copies. There is still a native copy into the WebSocket payload and raw pixel transfer to the browser; this is not GPU zero-copy. A viewer whose oldest outstanding frame remains unacknowledged for 10 seconds is disconnected. Hidden/unloaded pages close their native socket. The browser replaces pending raw pictures and does not decode H.264 in this mode.
`status.video_output` includes `transport`, `viewers`, I/O handoff `queued` / `queued_bytes`, aggregate `unacknowledged_bytes`, and `dropped` (handoff replacements plus per-viewer congestion skips). These diagnostics contain no video payload. The original receive-tap SPS/PPS and keyframe state remain available for H.264.
The MiniRTC `MiniRtcParams` structure appends `on_receive_encoded_video`, changing its ABI size. Rebuild consumers and MiniRTC together; zero-initialize `MiniRtcParams`. The callback borrows frame bytes on the receive/decode queue and must not retain the pointer. Returning nonzero requests a remote keyframe. No remote wire-protocol change is required.
## Images and observations
Images return `{mime_type:"image/png",data:"<base64>",metadata:{...}}`. Metadata includes `generation`, `geometry_epoch`, `frame_id`, `display_index`, `source_size`, `source_rect`, `image_size` and `frame_age_ms`; only screenshots create `observation_id`. Preview images cannot authorize model input.
NV12 frames must be tightly packed, even dimensions, at most 8192 per axis and 16 million pixels. Conversion/PNG encoding runs on one worker with no `ControllerSession` calls. The owner loop continues processing cancellation and input release. Screenshots have priority over queued previews. An encoding from an obsolete generation or geometry epoch is discarded. Frames older than two seconds are not used for a new image. PNG limits are 2 MiB for observations and 256 KiB for previews; the encoder reduces dimensions when necessary.
`after_frame_id` requires `generation`. A screenshot waits up to `timeout_ms` for a newer frame; a preview can return `{unchanged:true, frame_age_ms, stale, generation, frame_id}`. Neither path treats an image as an input acknowledgement.
`timeout_ms` bounds frame/worker availability, not the subsequent PNG encoding. With `timeout_ms: 0`, a fresh eligible frame is encoded immediately if the worker and queue are idle; otherwise return `FRAME_TIMEOUT` or `BUSY`. Captures of the same priority retain arrival order. Encoding still rejects obsolete geometry and frames that become older than two seconds. Terminal rendering also honors cancellation without sending a second result when the worker finishes.
## Actions
All coordinates refer to the immutable returned observation image. The native layer maps them through its source rectangle to normalized remote coordinates, without terminal layout padding. Coordinates must satisfy `0 <= x < image_width` and `0 <= y < image_height`. Observations expire after 30 seconds, retain at most 32 entries, and are invalidated by geometry/display generation changes.
```json
{"id":10,"method":"action","operation_id":"click-1","observation_id":"obs_2_1","action":{"type":"click","x":120,"y":80,"button":"left"}}
```
Supported payloads:
- `move`: `x`, `y`.
- `click`, `double_click`: `x`, `y`, optional `button` (`left`, `right`, `middle`).
- `scroll`: `x`, `y`, `direction` (`up`, `down`, `left`, `right`), optional `ticks` (1–10).
- `drag`: `path` of 2–128 `{x,y}` points, optional `duration_ms` (100–2000), optional `button` (`left`, `right`, `middle`; default `left`).
- `key_press`: `key`; `hotkey`: 2–6 unique `keys`.
- `type_text`: `text`, at most 16 KiB UTF-8; requires negotiated remote text-input capability.
All pointer actions and `key_press` accept optional `modifiers`, an array of at most four unique names from `CTRL`, `ALT`, `SHIFT`, `META`. They are pressed before the action and released afterwards in reverse order; a `key_press` cannot also list its own key as a modifier. `hotkey` already describes the complete chord and does not accept `modifiers`. Punctuation VK names are `SEMICOLON`, `EQUAL`, `COMMA`, `MINUS`, `PERIOD`, `SLASH`, `BACKQUOTE`, `BRACKET_LEFT`, `BACKSLASH`, `BRACKET_RIGHT`, `QUOTE`; these name keys, not layout-independent text.
Only one input sequence runs at once. Timed steps are advanced by the owner loop; cancellation, disconnect, display changes and pause release held buttons/keys. A failed send is conservatively considered potentially submitted.
New actions require a current frame no older than two seconds as well as a valid observation. Cached operation lookups remain available with a stalled feed or paused control. Failed key/button releases remain tracked for subsequent cleanup, including after control is disabled; a successful local send still does not prove remote execution. Invalid display indices leave existing observations and input sequences intact.
Results use `status: submitted | in_progress | failed_before_submit | outcome_unknown`, with `operation_id`, `retryable`, `side_effect_possible` and, for completed actions, `generation`/`after_frame_id`. The same operation ID and arguments retrieve the cached result; different arguments conflict. Retention is bounded to 128 entries, with entries older than five minutes eligible for removal on a new action. Never retry an uncertain effect with a new ID. `submitted` does not claim remote execution.
The native process trusts its parent. The parent must authenticate panel-only `viewer_control` calls and protect descriptor 3; this private pipe does not isolate arbitrary programs running as the same OS user.
## System unlock
`doctor` advertises `automation_unlock_protocol: "password/1"`; `hello` advertises `unlock_protocol: "password/1"`. The adapter enables the additional unlock flow when this capability is present.
After the Agent identifies the system lock screen and focuses its password field, `unlock_prepare` validates the supplied observation and returns its display generation. The trusted adapter may then ask the user in its local panel, or retrieve the separately configured system credential. Before submission it captures a fresh observation and verifies that the display generation and geometry still match.
`unlock` accepts a password of 1–256 UTF-8 bytes, rejecting NUL, newline, carriage return and tab. It uses the existing input scheduler to select the focused field contents, type the password and press Enter. Selection uses Command+A on macOS and Ctrl+A on other platforms. Control permission, human pause, observation freshness and cancellation follow the action rules. The operation cache stores the observation and unlock operation kind, with secret text confined to the transient input request and scheduled text step. Completed or cancelled steps clear their text buffers.
The result follows the existing action result format. Repeated operation IDs retrieve the first result, and the caller verifies the visible desktop using a subsequent screenshot. Password persistence and the five-minute user-input request lifetime belong to the TypeScript adapter.
## Input receipts and visual conditions
Updated desktop hosts answer the versioned `automation` capability handshake. When `capabilities.input_ack` is true, input travels through the host input dispatcher and the action result includes `remote_execution` with `applied`, `failed` or `pending`. A receipt confirms native input processing. The caller checks the target application's resulting state separately. Legacy hosts return `remote_execution` as `unsupported`. Missing receipts terminate the bounded wait with `REMOTE_ACK_TIMEOUT` and an uncertain outcome.
`wait_frame` accepts an `observation_id`, `condition` (`changed` or `stable`), `stable_ms` (100–5000, default 500), and `timeout_ms` (0–25000, default 5000). It watches the observation's source region, checks both luma and chroma, and returns `matched`, `wait_timed_out`, `generation`, and `frame_id`. Display changes, disconnection and explicit cancellation terminate pending waits. `crossdesk_wait` exposes this operation through MCP and includes a fresh screenshot when its condition matches.
## Clipboard
`clipboard` accepts `operation: get | set`. Writes additionally require `operation_id` and `text` (up to 16384 UTF-8 bytes, including an empty string to clear). Reads return up to 128 KiB of text. The existing chunked `web-clipboard` stream carries values and applied/rejected replies. Additive `read`, `empty`, and `clear` frames are enabled by the remote `clipboard` capability. Clipboard access requires control permission and respects human pause. Cancellation of an issued write reports an uncertain effect. Repeated write operation IDs retrieve the cached result.
## File operations
`files` supports `list`, `list_result`, `list_cancel`, `upload`, `download`, `status`, and `cancel`. Listing requests return a numeric `request_id`. Transfer requests require an `operation_id`, absolute `paths`, and an existing absolute `destination` directory. Progress and listing pages use `offset` and return `next_offset` with at most 256 entries.
The desktop and CLI share `FileBrowser` and the existing file transfer engine. Preparation and completion messages carry a transfer identifier to bind incoming files to the requested destination. A cancelled transfer cleans unfinished staging files. The capability handshake advertises `files` on compatible hosts. Queries and cancellation of existing tasks remain available during human pause.
## System control
`system` accepts `command: status | lock | send_sas`. Commands that change system state require an `operation_id` and control permission. Status queries are available during human pause. The host executes system operations through its application-thread queue, checks connection authorization again, and expires queued operations after two seconds. Windows secure attention uses the existing service integration.
`lock_state` is `locked`, `unlocked`, or `unknown`. macOS queries the current console session, Windows uses its session lock state, and Linux queries the current logind session's `LockedHint`. The MCP unlock workflow verifies this state on compatible hosts and returns `verified` when the system reports an unlocked session.
## Held input
`key_down`, `key_up`, `mouse_down`, and `mouse_up` extend the action union. Each successful action renews a fifteen-second lease for held input. Existing held modifiers survive subsequent clicks, movement and hotkeys. `release_inputs` releases the controller's tracked keys/buttons and cancels current input without requiring an observation. Errors, human pause, display changes and disconnection release held input.
`key_press` and `hotkey` accept `duration_ms` from 10 to 30000. Drags accept durations up to 30000 milliseconds. Extended key names include F13–F24, NUMPAD0–NUMPAD9, arithmetic keypad keys, lock/menu/print keys and media controls. Target platform key mapping determines availability. Text input requires held keys/buttons to be released first.
## Display management
`video_settings` accepts optional `fps`, even `width`/`height`, `quality`, and `max_bitrate_bps`. The controller checks negotiated preview capabilities and publishes the requested cadence and acceptance state. Supported MCP frame-rate tiers are 1, 8, 15, 30 and 60.
The trusted adapter may use `display` with `preserve_input: true` during a cross-display drag. It captures a fresh observation after each switch, maps the requested point from its source display image, and releases input in a final cleanup step. Ordinary display selection releases input. Multi-display capture returns sequential observations and restores the previous display.
## MCP connection recovery
The MCP adapter optionally monitors saved, persistently authorized devices. Eligible failures receive bounded backoff retries within sixty seconds. Each replacement has a new session ID, device authorization is checked again, and disabled control remains disabled. User pause and explicit disconnect stop recovery. Queries against an old session can discover its replacement, while mutations require the replacement's ID and a fresh observation. MCP observation IDs are namespaced by session and translated back to native observation IDs at the adapter boundary.
+1 -1
View File
@@ -12,7 +12,7 @@
| connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 | | connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 |
| status | 无 | 返回当前状态 | | status | 无 | 返回当前状态 |
| control | enabled:boolean | 显式启停控制;关闭时释放输入 | | control | enabled:boolean | 显式启停控制;关闭时释放输入 |
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,d/鼠标点击进入细节,+/-/滚轮缩放,方向键或 i 上、j 左、k 下、l 右平移,f 全景;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] | | input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,鼠标点击进入细节,+/-/滚轮以鼠标位置缩放,方向键或 i 上、j 左、k 下、l 右平移,f 恢复全景并重置倍率与平移;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] |
| display | index:0..63 | 选择已广告的零起始显示器,提升 generation | | display | index:0..63 | 选择已广告的零起始显示器,提升 generation |
| ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 | | ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 |
| disconnect | 无 | 释放输入、结束当前媒体会话 | | disconnect | 无 | 释放输入、结束当前媒体会话 |
+15 -7
View File
@@ -2,16 +2,24 @@
## 使用方式 ## 使用方式
完整版本发布后的统一入口如下。构建产物或部分平台包上传成功,不代表入口包和全部平台依赖已经可用;发布者须完成本文的注册表检查。 从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装统一入口,npm 会选择对应系统和架构的原生平台包。需要 Node.js 22 或以上和 npm,无需本机编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
更新时重新执行同一条 `npm install -g @chunkdash/crossdesk-cli@latest`。将示例 ID 替换为被控电脑的 9 位 ID,密码在连接时隐藏输入;`doctor` 只检查本机环境,不建立远程连接。
无需全局安装时使用 `npx`,首次运行会下载对应平台包:
```sh ```sh
npx @chunkdash/crossdesk-cli@latest doctor --json npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789 npx @chunkdash/crossdesk-cli@latest 123456789
npm install -g @chunkdash/crossdesk-cli
crossdesk-cli 123456789
``` ```
需要 Node.js 22 或以上和 npm。启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。安装过程不下载外部可执行文件、不执行 postinstall、不在用户机器编译。不要使用 `--omit=optional`,否则对应平台包不会安装。 启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。原生可执行文件随 npm 平台包下载,安装过程不执行 postinstall,也不在本机编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。 入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
@@ -31,7 +39,7 @@ crossdesk-cli 123456789
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。 这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。 普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty / Sixel 图形协议是可选增强,需终端自身支持并通过能力探测。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。 CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
@@ -49,7 +57,7 @@ codesign --force --sign - build/macosx/arm64/release/crossdesk-cli
npm --prefix apps/cli run pack:npm npm --prefix apps/cli run pack:npm
``` ```
脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;确认源码中的 MiniRTC 日志脱敏补丁已应用,再检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。 脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。
默认从 `build/<platform>/<arch>/release/` 读取原生程序,将两个 `.tgz` 写入 `build/npm/`。可指定路径: 默认从 `build/<platform>/<arch>/release/` 读取原生程序,将两个 `.tgz` 写入 `build/npm/`。可指定路径:
@@ -59,7 +67,7 @@ npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --ou
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `chunkdash-crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。 每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `chunkdash-crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。
`apps/cli/npm/platforms.json` 集中定义五个平台;脚本校验平台包名合法且不重复,启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。生成的六个包都包含作者、源码仓库、主页、问题反馈地址,以及面向 npm 官方注册表的公开发布配置。原生包包含二进制、运行要求、SHA-256,以及当前控制端和已应用修补的 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。 `apps/cli/npm/platforms.json` 集中定义五个平台;脚本校验平台包名合法且不重复,启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。生成的六个包都包含作者、源码仓库、主页、问题反馈地址,以及面向 npm 官方注册表的公开发布配置。原生包包含二进制、运行要求、SHA-256,以及当前控制端和 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。
`apps/cli/package.json` 的 `private: true` 表示源码目录不是发布产物。只发布打包脚本生成的 tarball;生成的包不带 private 标记。 `apps/cli/package.json` 的 `private: true` 表示源码目录不是发布产物。只发布打包脚本生成的 tarball;生成的包不带 private 标记。
+4 -2
View File
@@ -7,9 +7,11 @@ CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。 - `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。 - 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。 - PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
- MCP 实时面板新增 MiniRTC 的 `on_receive_encoded_video` 回调,转发解码前的完整 H.264 Annex B 帧;回调及关键帧请求由当前子模块源码提供。`MiniRtcParams` 新增字段改变 ABI 大小,CLI 与 MiniRTC 必须一起重建;发布时同步记录包含该改动的子模块提交。
- 自动化图像编码复用 MiniRTC 已有的 libyuv 2025.8.14,并通过 Xmake 固定静态 libpng v1.6.59;libpng 的压缩依赖沿用平台 zlib。新增依赖的许可证和对应源码也应纳入正式发行的依赖材料。
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 按主仓库记录初始化子模块并幂等应用补丁,不另行校验提交哈希;更新 MiniRTC 时无需修改脚本中的版本。CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。 MiniRTC 已在提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 中合入凭据日志脱敏:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 仅按主仓库记录初始化子模块,不再应用独立补丁;更新 MiniRTC 时只需更新子模块提交。共享子模块中的脱敏同样适用于桌面程序;该修改没有改变鉴权、TLS、TURN 或媒体协议。
npm 原生包携带 CLI、共享库、构建配置和已修补 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。 npm 原生包携带 CLI、共享库、构建配置和 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。 LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
+27 -16
View File
@@ -11,7 +11,7 @@ xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json xmake run crossdesk-cli doctor --json
``` ```
`prepare-deps.sh` 按主仓库记录的提交初始化 MiniRTC 子模块,并应用凭据日志脱敏补丁,可重复执行。脚本不另行校验提交哈希,升级 MiniRTC 时只需更新子模块提交;如果补丁与新版源码不兼容,准备步骤仍会失败,需要同步调整补丁。构建会检查补丁,缺失时失败。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。 `prepare-deps.sh` 按主仓库记录的提交初始化 MiniRTC 子模块,可重复执行。凭据日志脱敏已合入 MiniRTC,无需额外应用补丁;升级 MiniRTC 时只需更新子模块提交。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。 构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
@@ -35,13 +35,13 @@ crossdesk-cli 123456789 --server your-server.example --port 9099
需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。 需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。 流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。MiniRtcInit 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
交互界面区分 `Connecting to server`(连接信令)、`Connecting to device`(连接设备)、`Connected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `P2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。 交互界面区分 `MiniRtcConnectionConnecting to server`(连接信令)、`MiniRtcConnectionConnecting to device`(连接设备)、`MiniRtcConnectionConnected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `MiniRtcTraversalP2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。 保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
## ANSI、Kitty 与 SSH ## ANSI、Kitty、Sixel 与 SSH
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。 ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
@@ -57,13 +57,13 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina
缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。 缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。 快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。`Zoom at mouse` 表示鼠标锚点缩放,`Full view` / `Full` 表示恢复全景,`Pan` 表示平移。只读模式提示滚轮缩放,宽窗口还显示点击进入细节;本地命令模式提示返回、控制切换及键盘视图操作,宽窗口显示鼠标选择锚点和 `=` 放大。宽窗口中的分组分隔线按列对齐,窄窗口保留完整按键标签,优先保留缩放和全景,按可用宽度合并或减少分组。
顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。 顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。 ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读时可用 `+/-` 或滚轮以鼠标位置缩放,也可点击目标位置直接进入该区域的细节模式。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。 auto 模式发送 Kitty 查询与主设备属性查询(DA1),等待最多 250ms;优先选择 Kitty,否则在 DA1 的能力参数中找到 `4` 时选择 Sixel,再回退为 ANSI。显式 `kitty` 或 `sixel` 模式探测失败时报错,不强制向未知终端输出图像。tmux/screen 中 auto 使用 ANSI,显式像素模式报不支持;不修改用户终端配置。
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。 本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
@@ -71,16 +71,26 @@ auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。 依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
Sixel 使用终端字节流传图,无需额外图像库或临时图片文件:每六行像素按颜色平面编码,使用重复压缩。每帧最多 248 色(RGB 色立方体加灰阶),复杂画面超过约 1.9MB 时逐级降低到 43 色、16 色,必要时降到 4 级灰阶,以保留原有 2MiB 单消息上限。Sixel 有调色板量化损失;它没有 Kitty 的图像缩放 placement,因此依据字符像素尺寸限制画面行列,使图像保持在 1920×1080 内,缩放、细节模式和鼠标映射使用相同视口。每帧覆盖整个图像区域(包括黑边),最后不足六行的部分不会绘制到状态栏;通过保存/恢复 DECSDM 模式使图像从视频区光标位置开始,退出时恢复。未收到像素尺寸时按 10×20 字符估算,实际对齐仍取决于终端尺寸报告。高熵画面或慢速 SSH 建议先用 `--fps 8`。
```sh
crossdesk-cli 123456789 --renderer sixel --fps 8
```
CLI 支持 Sixel 不等于 Xshell 必然支持。查阅的 [Xshell 官方完整功能列表](https://www.netsarang.com/en/xshell-all-features/) 与 [更新记录](https://www.netsarang.com/en/xshell-update-history/) 尚未提供 Sixel 支持说明;以终端实际能力回应及显示测试为准,不能仅修改 `TERM` 就获得像素图像支持。无能力回应时可继续使用 `--renderer ansi --zoom native`。Sixel 协议与 DA1 能力位依据 [XTerm 控制序列文档](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Sixel-Graphics)。
```sh ```sh
ssh -t your-server ssh -t your-server
crossdesk-cli 123456789 --renderer ansi --fps 8 crossdesk-cli 123456789 --renderer ansi --fps 8
``` ```
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。 用户本地终端负责显示 SSH 字节流。ANSI、Kitty / Sixel over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
## 预览与背压 ## 预览与背压
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。 `--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、Sixel、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
独立 CLI 与 bridge 的远端预览统一请求清晰度优先(保持分辨率),包括默认 30 FPS、显式 `--fps` 和自动切换预览帧率。提高帧率不会将编码策略切回帧率优先;实际分辨率仍受画质上限、源画面尺寸、被控端支持及编码/传输条件限制。
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。 未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
@@ -99,7 +109,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。 媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,Kitty 保持 1MiB/s,ANSI 和 Pi 桥接也保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。 转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI、Kitty 和 Sixel 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,三者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。 测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
@@ -117,12 +127,13 @@ macOS 自带 Terminal 还需要在当前窗口勾选“显示 → 允许鼠标
| 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` | | 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` |
| 进入本地命令模式 | `?` 或 `Ctrl+]` | `Ctrl+]` | | 进入本地命令模式 | `?` 或 `Ctrl+]` | `Ctrl+]` |
| 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 | | 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 |
| 原始细节/完整画面 | `d`(detail)/ `f`(fit),相邻字母键 | `Ctrl+]`,再按对应键 | | 恢复完整画面 | `f`,重置倍率、平移和细节模式 | `Ctrl+]`,再按 `f` |
| 鼠标查看 | 滚轮以鼠标位置缩放;点击进入原始细节 | 进入本地命令模式后仅更新缩放锚点,使用缩放键调整 |
| 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 | | 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 |
进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件被忽略,bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。 进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件仅更新本地缩放锚点,不执行点击、拖动或滚轮操作;bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。
只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可连续调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。 只读时,在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍)。缩放键与滚轮均以鼠标位置为锚点,尽量保持鼠标下的画面内容在原位置;到达桌面边缘时限制裁剪范围。没有收到鼠标位置,或鼠标位于黑边、状态栏时,缩放键沿用当前中心;窗口失焦会清除鼠标位置。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面并重置倍率、平移和细节模式。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可移动鼠标选择锚点并连续按缩放键调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。 支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。
@@ -145,7 +156,7 @@ export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts pi -e ./apps/cli/plugins/pi/index.ts
``` ```
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。 在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的缩放、全景和平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监听接口查询字符尺寸或完整文本区尺寸,每两秒及窗口变化时刷新;尺寸回复不会传给远端。得到的宽高比通过 bridge 的 `viewport.cell_aspect` 传入,比例变化时重绘静止画面,并丢弃仍在途的旧比例帧。自动检测不可用或不准确时,可在启动 Pi 前设置环境变量 `CROSSDESK_CELL_ASPECT=0.45`(宽/高,允许 0.2~2;`auto` 恢复自动检测)。视图关闭或 bridge 停止时移除查询定时器和输入监听。 Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监听接口查询字符尺寸或完整文本区尺寸,每两秒及窗口变化时刷新;尺寸回复不会传给远端。得到的宽高比通过 bridge 的 `viewport.cell_aspect` 传入,比例变化时重绘静止画面,并丢弃仍在途的旧比例帧。自动检测不可用或不准确时,可在启动 Pi 前设置环境变量 `CROSSDESK_CELL_ASPECT=0.45`(宽/高,允许 0.2~2;`auto` 恢复自动检测)。视图关闭或 bridge 停止时移除查询定时器和输入监听。
@@ -169,9 +180,9 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。 - 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。
- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。 - `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。
- 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。 - 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。 - `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。 - 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。 终端模式无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker 或 IP 直连。终端宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端。Agent 的 PNG 截图和结构化操作由独立的 [自动化模式](automation-protocol.md) 和 [MCP 服务](../../mcp/README.md) 提供。
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。 每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
+29 -4
View File
@@ -18,16 +18,23 @@
- Release 构建、实际二进制 bridge/PTY 回归通过。ASan/UBSan 检查覆盖原有会话和渲染行为,以及无效帧保留、NV12 缓冲复用、预览参数去重、ACK 状态、重复断开和重连网络统计复位。 - Release 构建、实际二进制 bridge/PTY 回归通过。ASan/UBSan 检查覆盖原有会话和渲染行为,以及无效帧保留、NV12 缓冲复用、预览参数去重、ACK 状态、重复断开和重连网络统计复位。
- 生产 bridge 配合工程外假传输验证静止画面重绘、细节模式、质量切换、窗口变化、无效配置原子性和帧 ACK 背压,通过。 - 生产 bridge 配合工程外假传输验证静止画面重绘、细节模式、质量切换、窗口变化、无效配置原子性和帧 ACK 背压,通过。
- ANSI 渲染前后对比覆盖 8/24/256 色、UTF-8/ASCII、half/quadrant、全景/细节模式,输出校验值一致。在本机 240×100 网格连续执行 200 次差分生成,静止网格约 447.4 ms → 3.6 ms,单行变化约 422.8 ms → 9.5 ms。该结果只衡量差分文本生成,不包含视频解码、图像缩放和终端显示时间。 - ANSI 渲染前后对比覆盖 8/24/256 色、UTF-8/ASCII、half/quadrant、全景/细节模式,输出校验值一致。在本机 240×100 网格连续执行 200 次差分生成,静止网格约 447.4 ms → 3.6 ms,单行变化约 422.8 ms → 9.5 ms。该结果只衡量差分文本生成,不包含视频解码、图像缩放和终端显示时间。
- npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出、缺失脱敏补丁时拒绝打包。 - npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出。
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。 新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
## 控制按键调整(2026-10-30) ## 鼠标锚点缩放与快捷键提示(2026-11-02)
- 移除 `d` 本地快捷键,保留鼠标点击进入细节及 `--zoom native`。`f` 恢复完整桌面并重置倍率、平移和细节模式;`+/-`、`=` 和只读滚轮使用鼠标锚点缩放。
- 工程外 ASan/UBSan 检查通过 900 次缩放几何检查,覆盖 ASCII、ANSI half/quadrant、Kitty/Sixel 视口、全景/细节、连续输入、边缘限制、SGR/传统鼠标报告、失焦与模式切换;确认 `d` 不再改变本地视图,控制模式仍转发普通字母。
- 四种控制/本地模式组合、1~240 列共 960 组状态栏布局检查通过,覆盖完整按键标签、缩放/全景提示及模式可用操作。Pi 提示通过 Node.js 语法检查;没有进行真实 Pi TUI 或远端设备连接验证。
- 新增验证代码及产物均保存在工程外临时目录。
## 控制按键调整(2026-10-30,历史记录)
- 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。 - 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。
- 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。 - 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。
- 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。 - 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。
- 细节/全景采用相邻的 `d`/`f`;工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。 - 当时细节/全景采用相邻的 `d`/`f`(`d` 已于 2026-11-02 移除);工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。
- 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。 - 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。
- Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。 - Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。
@@ -122,8 +129,26 @@
- SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。 - SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。
- 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。 - 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。
## ANSI 多片段差分与本地吞吐(2026-10-31)
- 本地交互 ANSI 的持续输出预算由 1MiB/s 提高到 8MiB/s;SSH 与 bridge 仍为 1MiB/s。保留 2MiB 单消息、1MiB 突发、非阻塞短写、最新帧替换和完整输出后提交差分基准的约束。
- 同一行的分散修改按多个片段输出,短间隔按实际编码字节成本合并,比较包含下一单元的颜色设置。差分忽略色板量化后相同的颜色、空格前景色以及 ASCII 模式不显示的字形/背景字段;未修改采样、象限拟合及 Kitty 传输。
- macOS arm64 CLI debug/release 构建及两种产物的 `doctor --json` 通过,完成后工程配置恢复为桌面与 CLI 同时启用的 debug。工程外 ASan/UBSan 检查通过:全部四象限字形、8/24/256 色、ASCII、首尾单元、长短间隔、色板等价、多种尺寸及偏移变化、600 帧连续随机更新与整帧重绘的屏幕一致性;140 组转换逐格一致,bridge 行输出及 Kitty 数据保持一致。
- `-O2`、240×100 合成网格:10 行各两处分散修改由 90,058 降到 931 字节;隔列修改由 919,260 降到 562,601 字节;量化后颜色相同的整屏变化由 72,716 降到 0 字节。高色彩变化整帧仍为 922,929 字节,2×3 光标修改仍为 262 字节。多片段选择会增加部分交错修改场景的编码计算,以减少终端输出量。
- 使用生产 CLI/渲染器的 `-O3` 构建、1920×1080 连续变化合成 NV12、240×100 字符视口、固定目标 30 FPS 的本地 PTY 对比:跳过启动阶段后的完整帧接收速率约 1.64 → 6.42 FPS,约 3.9 倍;平均每帧约 640 KB。分别设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时预算均保持 1MiB/s;设置 `TMUX` 的本地 ANSI 路径使用 8MiB/s。
- 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。
- 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。
## Sixel 图形输出(2026-11-02)
- 新增 `--renderer sixel`;auto 按 Kitty → Sixel → ANSI 选择,Sixel 通过 DA1 能力参数 `4` 探测。显式像素模式没有有效回应或处于 tmux/screen 时拒绝启动;`doctor --json` 增加 `sixel=probe_on_view`,不主动探测。
- macOS arm64 CLI debug 构建、帮助信息和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖能力回复解析、20,000 组随机尺寸的视口限制及重复计算一致性、7 组合成 NV12 图像(纯黑、纯白、渐变、彩色、部分六行、裁剪缩放和大视口)。
- 工程外独立 Sixel 解码器验证完整像素覆盖、颜色量化误差、图像边界和光标保存/恢复;1920×1080 高熵样本触发 4 级灰阶回退,输出约 1.30MB,保持原尺寸且低于单消息上限。
- 生产 CLI/终端 I/O 配合工程外假会话的 11 组 POSIX PTY 检查通过:Kitty 优先、Sixel 自动及显式选择、分片 DA1 回应、错误/无回应回退、显式模式失败、tmux/screen 限制、窗口变化和细节缩放;SSH 字节预算保持 1MiB/s,退出后 termios 与备用屏幕恢复,Sixel 路径发送 DECSDM 恢复命令。
- 未连接真实远端,未进行 Xshell、其他 Sixel 终端或真实 SSH 显示验收,不保证任何 Xshell 版本支持该协议。额外测试源码、脚本、数据及产物均保存在工程外系统临时目录,未新增或修改工程内测试文件/用例。
## 实测边界 ## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。 本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/Sixel/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。 CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
+66 -24
View File
@@ -1,4 +1,5 @@
#include "terminal_io.h" #include "terminal_io.h"
#include "agent.h"
#include <algorithm> #include <algorithm>
#include <cerrno> #include <cerrno>
#include <clocale> #include <clocale>
@@ -143,6 +144,7 @@ private:
}; };
struct Tty { struct Tty {
int out; int out;
bool sixel = false;
RawTerminal terminal; RawTerminal terminal;
Tty(int input, int output) : out(output), terminal(input, output) { Tty(int input, int output) : out(output), terminal(input, output) {
// Fall back to button/drag reporting if all-motion tracking is unsupported. // Fall back to button/drag reporting if all-motion tracking is unsupported.
@@ -153,6 +155,7 @@ struct Tty {
~Tty() { ~Tty() {
// Cancel any incomplete string/control sequence before restoration. // Cancel any incomplete string/control sequence before restoration.
std::string end = "\030\033\\" + std::string(kKittyDelete) + std::string end = "\030\033\\" + std::string(kKittyDelete) +
(sixel ? "\033[?80r" : "") +
"\033[0m\033[?1003l\033[?1002l\033[?1000l" "\033[0m\033[?1003l\033[?1002l\033[?1000l"
"\033[?1006l\033[?1004l\033[?2004l\033[" "\033[?1006l\033[?1004l\033[?2004l\033["
"?7h\033[?25h\033[?1049l"; "?7h\033[?25h\033[?1049l";
@@ -332,9 +335,9 @@ int View(SessionOptions options, const std::string &device,
return 130; return 130;
Tty tty(0, fd); Tty tty(0, fd);
Output output(fd); Output output(fd);
bool kitty = false; bool kitty = false, sixel = false;
if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) { if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) {
output.Put(std::string(kKittyQuery) + "\033[c"); output.Put((renderer == "sixel" ? std::string{} : kKittyQuery) + "\033[c");
std::string reply; std::string reply;
auto until = Clock::now() + std::chrono::milliseconds(250); auto until = Clock::now() + std::chrono::milliseconds(250);
while (Clock::now() < until && !interrupted) { while (Clock::now() < until && !interrupted) {
@@ -345,17 +348,28 @@ int View(SessionOptions options, const std::string &device,
if (n > 0 && reply.size() < 8192) if (n > 0 && reply.size() < 8192)
reply.append(b, n); reply.append(b, n);
} }
kitty = KittyReply(reply); kitty = renderer != "sixel" && KittyReply(reply);
sixel = renderer != "kitty" && !kitty && SixelReply(reply);
} }
if (renderer == "kitty" && !kitty) if (renderer == "kitty" && !kitty)
throw std::runtime_error( throw std::runtime_error(
"Kitty probe failed or multiplexer active; use --renderer ansi"); "Kitty probe failed or multiplexer active; use --renderer ansi");
// Local pixel frames need more throughput than character output. Keep the if (renderer == "sixel" && !sixel)
// conservative budget for SSH and the existing one-message/burst limits. throw std::runtime_error(
"Sixel probe failed or multiplexer active; use --renderer ansi");
if (sixel) {
// DECSDM reset places graphics at the cursor. Save the user's setting
// and restore it on exit, including interrupted image writes.
output.Put("\033[?80s\033[?80l");
tty.sixel = true;
}
// Both pixel frames and large true-colour character grids need local
// throughput. Keep SSH conservative and retain the one-message/burst limits
// and nonblocking writes so slow terminals still apply backpressure.
const bool local_terminal = Env("SSH_CONNECTION").empty() && const bool local_terminal = Env("SSH_CONNECTION").empty() &&
Env("SSH_CLIENT").empty() && Env("SSH_CLIENT").empty() &&
Env("SSH_TTY").empty(); Env("SSH_TTY").empty();
if (kitty && local_terminal) if (local_terminal)
output.SetByteRate(8 * 1024 * 1024); output.SetByteRate(8 * 1024 * 1024);
SharedImage shared_image; SharedImage shared_image;
bool shared_enabled = false; bool shared_enabled = false;
@@ -519,10 +533,12 @@ int View(SessionOptions options, const std::string &device,
auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect); auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect);
v.detail = detail; v.detail = detail;
v.native = input.native; v.native = input.native;
v.pixel_mode = kitty; v.pixel_mode = kitty || sixel;
v.zoom = input.zoom; v.zoom = input.zoom;
v.center_x = input.center_x; v.center_x = input.center_x;
v.center_y = input.center_y; v.center_y = input.center_y;
if (sixel)
v = SixelViewport(v);
if (input.view_revision != view_revision) { if (input.view_revision != view_revision) {
view_revision = input.view_revision; view_revision = input.view_revision;
redraw = true; redraw = true;
@@ -590,9 +606,10 @@ int View(SessionOptions options, const std::string &device,
bar.command_mode = input.command_mode; bar.command_mode = input.command_mode;
bar.fps = "FPS " + std::to_string(output_fps); bar.fps = "FPS " + std::to_string(output_fps);
bar.view = std::string(kitty ? "KITTY" bar.view = std::string(kitty ? "KITTY"
: sixel ? "SIXEL"
: detail == AnsiDetail::Quadrant ? "QUAD" : detail == AnsiDetail::Quadrant ? "QUAD"
: "HALF") + : "HALF") +
(input.native ? " | Detail x" : " | Fit x") + (input.native ? " | Detail x" : " | Zoom x") +
std::to_string(int(input.zoom)) + std::to_string(int(input.zoom)) +
(last_frame ? " | " + std::to_string(last_frame->width) + "x" + (last_frame ? " | " + std::to_string(last_frame->width) + "x" +
std::to_string(last_frame->height) std::to_string(last_frame->height)
@@ -600,8 +617,8 @@ int View(SessionOptions options, const std::string &device,
const auto bps = st["network"]["video_bps"].get<uint64_t>(); const auto bps = st["network"]["video_bps"].get<uint64_t>();
const auto traversal = st["network"]["traversal"].get<int>(); const auto traversal = st["network"]["traversal"].get<int>();
bar.network = bar.network =
std::string(traversal == P2P ? "P2P " std::string(traversal == MiniRtcTraversalP2P ? "P2P "
: traversal == Relay ? "RELAY " : traversal == MiniRtcTraversalRelay ? "RELAY "
: "Video ") + : "Video ") +
(bps < 1000 ? std::to_string(bps) + " bps" (bps < 1000 ? std::to_string(bps) + " bps"
: bps < 1000000 ? std::to_string(bps / 1000) + " kbps" : bps < 1000000 ? std::to_string(bps / 1000) + " kbps"
@@ -650,15 +667,15 @@ int View(SessionOptions options, const std::string &device,
now - last_frame->queued) now - last_frame->queued)
.count(); .count();
} }
pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v); pending = v.pixel_mode ? Layout(*last_frame, v) : Convert(*last_frame, v);
// Clear waiting text and Kitty margins without erasing the status bars. // Clear waiting text and image margins without erasing the status bars.
std::string clear; std::string clear;
if (waiting_visible) if (waiting_visible)
for (int row = 0; row < v.rows; ++row) for (int row = 0; row < v.rows; ++row)
clear += "\033[" + std::to_string(v.row_offset + row + 1) + clear += "\033[" + std::to_string(v.row_offset + row + 1) +
";1H\033[0m\033[2K"; ";1H\033[0m\033[2K";
if (shared_enabled) { if (shared_enabled) {
const auto pixels = KittyPixels(*last_frame, v); const auto pixels = ImagePixels(*last_frame, v);
if (shared_image.Assign(pixels.rgb.data(), pixels.rgb.size())) { if (shared_image.Assign(pixels.rgb.data(), pixels.rgb.size())) {
output.Put(KittySharedUpload(shared_image.Name(), pixels.width, output.Put(KittySharedUpload(shared_image.Name(), pixels.width,
pixels.height, submitted % 2 != 0)); pixels.height, submitted % 2 != 0));
@@ -672,6 +689,7 @@ int View(SessionOptions options, const std::string &device,
if (fresh && !frame_started) if (fresh && !frame_started)
frame_started = Clock::now(); frame_started = Clock::now();
output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0) output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0)
: sixel ? Sixel(*last_frame, v)
: Difference(*pending, displayed))); : Difference(*pending, displayed)));
waiting_visible = false; waiting_visible = false;
pending_frame = true; pending_frame = true;
@@ -728,7 +746,7 @@ int View(SessionOptions options, const std::string &device,
{"reported", input.terminal_metrics.Known()}, {"reported", input.terminal_metrics.Known()},
{"manual_aspect", cell_aspect > 0}}}, {"manual_aspect", cell_aspect > 0}}},
{"submitted", submitted}, {"submitted", submitted},
{"renderer", kitty ? "kitty" : "ansi"}, {"renderer", kitty ? "kitty" : sixel ? "sixel" : "ansi"},
{"kitty_transport", !kitty ? "none" {"kitty_transport", !kitty ? "none"
: shared_enabled ? "shared_memory" : shared_enabled ? "shared_memory"
: "inline"}, : "inline"},
@@ -1010,27 +1028,34 @@ int Bridge(int fd, SessionOptions options, int password_fd) {
s.Disconnect(); s.Disconnect();
return interrupted ? 130 : 0; return interrupted ? 130 : 0;
} }
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30] const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty|sixel] [--fps 1..30]
[--display 0..63] [--control] [--server host] [--port 9099] [--display 0..63] [--control] [--server host] [--port 9099]
[--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native] [--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native]
[--cell-aspect auto|0.2..2] [--cell-aspect auto|0.2..2]
crossdesk-cli view <9-digit-device-id> [options] (compatibility alias) crossdesk-cli view <9-digit-device-id> [options] (compatibility alias)
crossdesk-cli doctor [--json] crossdesk-cli doctor [--json]
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
crossdesk-cli agent --stdio [--password-fd 3] [--video-ws] [--server host] [--port 9099]
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal. Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands. Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
Ctrl+] enters persistent local mode: c selects control on return; q quits; Esc/Ctrl+C returns. Ctrl+] enters persistent local mode: c selects control on return; q quits; Esc/Ctrl+C returns.
View keys: click/d native detail, wheel/+/- zoom, arrows/ijkl pan (i up, j left, k down, l right), f fit. View keys (read-only/local): +/- zoom at mouse (= also zooms in); f restores the full desktop and resets pan.
Pan: arrows or i up, j left, k down, l right. Read-only mouse: wheel zooms at pointer; click opens native detail.
Local mode: move the mouse to choose the zoom anchor; clicks and wheel do not change the view.
Zoom uses the current center when no mouse position is available or the pointer is outside the picture.
Local mode stays active after commands, paste or focus loss; remote input remains paused. Local mode stays active after commands, paste or focus loss; remote input remains paused.
Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode and forwards Ctrl+] if control is enabled. Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode and forwards Ctrl+] if control is enabled.
Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160. Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160.
Remote previews prioritize resolution at every frame rate.
Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate. Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate.
Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale. Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale.
Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers require a successful probe.
--fps controls local output (default 30). Without it, remote preview adapts to measured output speed. --fps controls local output (default 30). Without it, remote preview adapts to measured output speed.
Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged. Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged.
Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS. Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS.
bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor. bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor.
agent: crossdesk-automation/1, structured actions and PNG screenshots; no terminal required.
No IP direct connection, audio playback, clipboard or file transfer support. No IP direct connection, audio playback, clipboard or file transfer support.
)"; )";
} // namespace } // namespace
@@ -1063,13 +1088,21 @@ int main(int argc, char **argv) {
{"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()}, {"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()},
{"software_decode", true}, {"software_decode", true},
{"kitty", "probe_on_view"}, {"kitty", "probe_on_view"},
{"sixel", "probe_on_view"},
{"audio_playback", false}, {"audio_playback", false},
{"bridge_version", 1}}; {"bridge_version", 1},
{"automation_terminal_protocol", "ansi/1"},
{"automation_protocol", "crossdesk-automation/1"},
{"automation_unlock_protocol", "password/1"},
{"automation_video", "h264-annexb/1"},
{"automation_video_transport", "loopback-websocket/1"},
{"automation_video_protocols", {"h264-annexb/1", "nv12/1"}},
{"automation_video_decoders", {"auto", "software"}}};
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n"; std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
return 0; return 0;
} }
int i = 2; int i = 2;
if (command != "view" && command != "bridge") { if (command != "view" && command != "bridge" && command != "agent") {
if (command.find_first_not_of("0123456789") != std::string::npos) if (command.find_first_not_of("0123456789") != std::string::npos)
throw std::invalid_argument( throw std::invalid_argument(
"expected a 9-digit device ID or a command; use --help"); "expected a 9-digit device ID or a command; use --help");
@@ -1082,6 +1115,8 @@ int main(int argc, char **argv) {
double zoom = 1, cell_aspect = 0; double zoom = 1, cell_aspect = 0;
bool control = false, stdio = false, native = false, fps_explicit = false; bool control = false, stdio = false, native = false, fps_explicit = false;
int secret_fd = 3; int secret_fd = 3;
bool video_websocket = false;
if (command == "agent") options.fps = 30;
if (command == "view") { if (command == "view") {
if (i >= argc) if (i >= argc)
throw std::invalid_argument("device ID required"); throw std::invalid_argument("device ID required");
@@ -1101,8 +1136,10 @@ int main(int argc, char **argv) {
std::string a = argv[i]; std::string a = argv[i];
if (a == "--control" && command == "view") if (a == "--control" && command == "view")
control = true; control = true;
else if (a == "--stdio" && command == "bridge") else if (a == "--stdio" && command != "view")
stdio = true; stdio = true;
else if (a == "--video-ws" && command == "agent")
video_websocket = true;
else { else {
if (i + 1 >= argc) if (i + 1 >= argc)
throw std::invalid_argument("option value missing"); throw std::invalid_argument("option value missing");
@@ -1111,6 +1148,8 @@ int main(int argc, char **argv) {
options.host = value; options.host = value;
else if (a == "--port") else if (a == "--port")
options.port = number(value, 1, 65535); options.port = number(value, 1, 65535);
else if (a == "--log-dir" && command == "agent")
options.log_directory = value;
else if (a == "--fps" && command == "view") { else if (a == "--fps" && command == "view") {
options.fps = number(value, 1, 30); options.fps = number(value, 1, 30);
fps_explicit = true; fps_explicit = true;
@@ -1135,22 +1174,23 @@ int main(int argc, char **argv) {
} else if (a == "--zoom" && command == "view") { } else if (a == "--zoom" && command == "view") {
native = value == "native"; native = value == "native";
zoom = native ? 1 : number(value, 1, 32); zoom = native ? 1 : number(value, 1, 32);
} else if (a == "--password-fd" && command == "bridge") } else if (a == "--password-fd" && command != "view")
secret_fd = number(value, 3, 1024); secret_fd = number(value, 3, 1024);
else else
throw std::invalid_argument("unsupported option"); throw std::invalid_argument("unsupported option");
} }
} }
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty") if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
renderer != "sixel")
throw std::invalid_argument("unknown renderer"); throw std::invalid_argument("unknown renderer");
if (command == "bridge" && !stdio) if (command != "view" && !stdio)
throw std::invalid_argument("bridge requires --stdio"); throw std::invalid_argument("automation and bridge require --stdio");
Descriptor output(Duplicate(1)); Descriptor output(Duplicate(1));
if (output.value < 0) if (output.value < 0)
throw std::runtime_error("cannot preserve output channel"); throw std::runtime_error("cannot preserve output channel");
// All library stdout writes go to diagnostics. Only Output uses the saved // All library stdout writes go to diagnostics. Only Output uses the saved
// fd. // fd.
options.log_directory = Logs(); if (options.log_directory.empty()) options.log_directory = Logs();
if (command == "view") { if (command == "view") {
Descriptor logs(OpenLog(options.log_directory + "/client.log")); Descriptor logs(OpenLog(options.log_directory + "/client.log"));
if (logs.value < 0 || Redirect(logs.value, 2) < 0) if (logs.value < 0 || Redirect(logs.value, 2) < 0)
@@ -1169,6 +1209,8 @@ int main(int argc, char **argv) {
return command == "view" ? View(options, device, renderer, control, return command == "view" ? View(options, device, renderer, control,
output.value, detail, zoom, native, output.value, detail, zoom, native,
cell_aspect, fps_explicit) cell_aspect, fps_explicit)
: command == "agent"
? Agent(output.value, options, secret_fd, video_websocket)
: Bridge(output.value, options, secret_fd); : Bridge(output.value, options, secret_fd);
} catch (const std::exception &e) { } catch (const std::exception &e) {
if (command == "view") { if (command == "view") {
+6 -4
View File
@@ -36,9 +36,10 @@ function main() {
return; return;
} }
const stdio = ['inherit', 'inherit', 'inherit']; const stdio = ['inherit', 'inherit', 'inherit'];
if (args[0] === 'bridge') { if (args[0] === 'bridge' || args[0] === 'agent') {
const index = args.lastIndexOf('--password-fd'); for (const [option, fallback] of [['--password-fd', 3]]) {
const fd = index === -1 ? 3 : Number(args[index + 1]); const index = args.lastIndexOf(option);
const fd = index === -1 ? fallback : Number(args[index + 1]);
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) { if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
try { try {
const stat = fs.fstatSync(fd); const stat = fs.fstatSync(fd);
@@ -46,7 +47,8 @@ function main() {
while (stdio.length <= fd) stdio.push('ignore'); while (stdio.length <= fd) stdio.push('ignore');
stdio[fd] = fd; stdio[fd] = fd;
} }
} catch { /* The native bridge reports a missing credential descriptor. */ } } catch { /* Native code reports unavailable descriptors. */ }
}
} }
} }
const child = spawn(binary, args, { stdio, shell: false }); const child = spawn(binary, args, { stdio, shell: false });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chunkdash/crossdesk-cli", "name": "@chunkdash/crossdesk-cli",
"version": "0.1.2", "version": "0.2.0",
"private": true, "private": true,
"description": "CrossDesk remote desktop controller for the terminal", "description": "CrossDesk remote desktop controller for the terminal",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
+3 -3
View File
@@ -227,10 +227,10 @@ export default function crossdesk(pi: ExtensionAPI) {
const ack = frame; frame = undefined; const ack = frame; frame = undefined;
if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp. if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp.
const hint = bridgeStopped ? "q / Esc / Ctrl+C close" const hint = bridgeStopped ? "q / Esc / Ctrl+C close"
: commandMode ? "Esc/Ctrl+C return | c toggle q quit | arrows/ijkl pan +/- zoom f fit d detail" : commandMode ? "Esc/Ctrl+C return | c toggle | q quit | +/- zoom | f full | arrows/ijkl pan"
: control ? "Keys -> remote | Ctrl+] then c release, q quit" : control ? "Keys -> remote | Ctrl+] then c release, q quit"
: "c control q quit ? help | i up j left k down l right | +/- zoom f fit d detail"; : "c control | q quit | +/- zoom | f full | arrows/ijkl pan | ? commands";
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] commands`, width), return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "zoom"} x${zoom} | Ctrl+] commands`, width),
...lines.slice(0, h).map(line => truncateToWidth(line, width)), ...lines.slice(0, h).map(line => truncateToWidth(line, width)),
truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)]; truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)];
}, },
+5 -5
View File
@@ -84,6 +84,8 @@ function inspect(binary, target) {
} }
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 })); const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.'); if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
if (doctor.automation_protocol !== 'crossdesk-automation/1')
throw new Error('Native binary does not report the expected automation protocol.');
return info; return info;
} }
function pack(directory, output) { function pack(directory, output) {
@@ -132,8 +134,6 @@ try {
const filename = tarballName(name, version); const filename = tarballName(name, version);
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`); if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
} }
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli'; const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable); binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
const info = inspect(binary, target); const info = inspect(binary, target);
@@ -144,7 +144,7 @@ try {
fs.copyFileSync(binary, path.join(native, 'bin', executable)); fs.copyFileSync(binary, path.join(native, 'bin', executable));
fs.chmodSync(path.join(native, 'bin', executable), 0o755); fs.chmodSync(path.join(native, 'bin', executable), 0o755);
copy('LICENSE', path.join(native, 'LICENSE')); copy('LICENSE', path.join(native, 'LICENSE'));
// Include the controller and patched MiniRTC sources used by this checkout. // Include the controller and MiniRTC sources used by this checkout.
// External Xmake dependency source/licenses still belong to the release's // External Xmake dependency source/licenses still belong to the release's
// dependency bundle; see source/apps/cli/docs/source-provenance.md. // dependency bundle; see source/apps/cli/docs/source-provenance.md.
// Keep desktop build definitions for Xmake's initial option discovery; // Keep desktop build definitions for Xmake's initial option discovery;
@@ -153,11 +153,11 @@ try {
'libs/controller/include', 'libs/controller/include',
'libs/controller/src', 'libs/terminal', 'libs/wire/include', 'libs/controller/src', 'libs/terminal', 'libs/wire/include',
'libs/wire/src', 'deps/submodules/minirtc', 'libs/wire/src', 'deps/submodules/minirtc',
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua', 'deps/submodules/xmake.lua', 'deps/thirdparty', 'xmake.lua',
'LICENSE', '.gitmodules']) 'LICENSE', '.gitmodules'])
copy(entry, path.join(native, 'source', entry)); copy(entry, path.join(native, 'source', entry));
json(path.join(native, 'build-info.json'), { version, ...info }); json(path.join(native, 'build-info.json'), { version, ...info });
fs.writeFileSync(path.join(native, 'README.md'), `# ${platform.packageName}\n\nNative ${target} dependency for [${project.name}](https://www.npmjs.com/package/${project.name}) ${version}, published under the ${platform.packageName.split('/')[0]} scope.\n\nInstall the cross-platform entry package with \`npm install -g ${project.name}\` or run \`npx ${project.name} doctor --json\`. This platform package contains the native binary and does not register a shell command.\n\nSource repository: [CrossDesk](${project.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}). Report problems at ${project.bugs.url}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`); fs.writeFileSync(path.join(native, 'README.md'), `# ${platform.packageName}\n\nNative ${target} dependency for [${project.name}](https://www.npmjs.com/package/${project.name}) ${version}, published under the ${platform.packageName.split('/')[0]} scope.\n\nInstall the cross-platform entry package with \`npm install -g ${project.name}\` or run \`npx ${project.name} doctor --json\`. This platform package contains the native binary and does not register a shell command.\n\nSource repository: [CrossDesk](${project.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}). Report problems at ${project.bugs.url}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
const metadata = { const metadata = {
license: project.license, author: project.author, repository: project.repository, license: project.license, author: project.author, repository: project.repository,
homepage: project.homepage, bugs: project.bugs, homepage: project.homepage, bugs: project.bugs,
-6
View File
@@ -2,9 +2,3 @@
set -eu set -eu
cd "$(dirname "$0")/../../.." cd "$(dirname "$0")/../../.."
git submodule update --init --recursive git submodule update --init --recursive
patch_file="$(pwd)/deps/patches/minirtc-credential-logs.patch"
if git -C deps/submodules/minirtc apply --reverse --check "$patch_file" 2>/dev/null; then
exit 0
fi
git -C deps/submodules/minirtc apply --check "$patch_file"
git -C deps/submodules/minirtc apply "$patch_file"
+549
View File
@@ -0,0 +1,549 @@
#include "video_server.h"
#include <atomic>
#include <deque>
#include <future>
#include <map>
#include <mutex>
#include <thread>
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
namespace crossdesk::cli {
namespace {
using Clock = std::chrono::steady_clock;
using namespace std::chrono_literals;
using json = nlohmann::json;
using Handle = websocketpp::connection_hdl;
using Server = websocketpp::server<websocketpp::config::asio>;
constexpr size_t kMaxEncoded = 4 * 1024 * 1024;
constexpr size_t kMaxQueued = 8 * 1024 * 1024;
bool Token(const std::string &s) {
return s.size() == 32 &&
s.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvw"
"xyz0123456789-_") == std::string::npos;
}
bool AppOrigin(const std::string &s) {
return s == "null" ||
(s.size() > 8 && s.size() <= 256 && s.compare(0, 8, "https://") == 0 &&
s.find_first_of("/?#@\\ \t\r\n", 8) == std::string::npos);
}
bool BrowserOrigin(const std::string &s) {
const std::string prefix = "http://127.0.0.1:";
if (s.compare(0, prefix.size(), prefix) || s.size() > prefix.size() + 5 ||
s.size() == prefix.size() ||
s.find_first_not_of("0123456789", prefix.size()) != std::string::npos)
return false;
const auto port = std::stoul(s.substr(prefix.size()));
return port > 0 && port <= 65535;
}
void Number(std::string &out, uint64_t value, int bytes) {
for (int n = bytes - 1; n >= 0; --n)
out.push_back(char(value >> (n * 8)));
}
} // namespace
struct VideoServer::State {
struct Ticket {
VideoGrant grant;
Clock::time_point expires;
};
struct Receipt {
uint32_t generation, sequence;
size_t bytes;
Clock::time_point sent;
};
struct Client {
std::string origin, view, session;
uint64_t view_generation = 0;
uint64_t cursor_revision = 0;
bool authorized = false, waiting_key = true;
size_t bytes = 0;
Clock::time_point opened = Clock::now(), pinged = Clock::now();
std::deque<Receipt> receipts;
};
struct Packet {
std::shared_ptr<const DecodedFrame> raw;
EncodedVideoFrame encoded;
size_t Size() const {
return raw ? 32 + raw->nv12.size() : 24 + encoded.data.size();
}
uint64_t Generation() const {
return raw ? raw->generation : encoded.generation;
}
uint64_t Sequence() const { return raw ? raw->frame_id : encoded.sequence; }
bool Key() const { return raw || encoded.key; }
};
Server server;
Server::timer_ptr timer;
std::thread thread;
std::string authority;
std::map<Handle, Client, std::owner_less<Handle>> clients;
std::map<std::string, Ticket> tickets;
std::atomic<size_t> viewers{0}, outstanding{0}, dropped{0};
std::atomic<bool> key_requested{false};
uint64_t generation = 0;
bool connected = false, decoded = false;
Clock::time_point last_key_request{};
// One posted drain, not one posted handler per picture. Raw frames replace
// each other; the compressed handoff is bounded and recovers at an IDR.
mutable std::mutex mutex;
std::deque<Packet> queue;
std::deque<AudioFrame> audio_queue;
size_t audio_bytes = 0;
size_t queued_bytes = 0;
bool scheduled = false, wait_key = true;
json cursor;
uint64_t cursor_revision = 0;
State() {
server.clear_access_channels(websocketpp::log::alevel::all);
server.clear_error_channels(websocketpp::log::elevel::all);
server.init_asio();
server.set_open_handshake_timeout(3000);
server.set_close_handshake_timeout(1000);
server.set_pong_timeout(10000);
server.set_max_message_size(1024);
server.set_max_http_body_size(0);
server.set_socket_init_handler([](Handle, asio::ip::tcp::socket &socket) {
asio::error_code ec;
socket.set_option(asio::ip::tcp::no_delay(true), ec);
socket.set_option(asio::socket_base::send_buffer_size(256 * 1024), ec);
});
server.set_validate_handler([this](Handle h) {
auto c = server.get_con_from_hdl(h);
if (clients.size() >= 16 || c->get_resource() != "/api/video" ||
c->get_request_header("Host") != authority ||
c->get_request_header("Sec-WebSocket-Version") != "13" ||
c->get_origin().empty() || c->get_origin().size() > 256) {
c->set_status(websocketpp::http::status_code::forbidden);
return false;
}
Client client;
client.origin = c->get_origin();
clients.emplace(h, std::move(client));
return true;
});
server.set_message_handler([this](Handle h, Server::message_ptr message) {
try {
Message(h, message);
} catch (...) {
Drop(h);
}
});
server.set_close_handler([this](Handle h) { Remove(h); });
server.set_fail_handler([this](Handle h) { Remove(h); });
server.set_pong_timeout_handler(
[this](Handle h, const std::string &) { Drop(h); });
server.listen(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0));
asio::error_code ec;
authority =
"127.0.0.1:" + std::to_string(server.get_local_endpoint(ec).port());
if (ec)
throw std::runtime_error("VIDEO_CHANNEL_UNAVAILABLE");
server.start_accept();
Maintain();
thread = std::thread([this] { server.run(); });
}
~State() {
Call([this] {
websocketpp::lib::error_code ec;
server.stop_listening(ec);
if (timer)
timer->cancel();
Revoke("");
});
server.stop();
thread.join();
}
template <class F> void Call(F f) {
auto task = std::make_shared<std::packaged_task<void()>>(std::move(f));
auto result = task->get_future();
server.get_io_service().post([task] { (*task)(); });
result.get();
}
void Remove(Handle h) {
auto it = clients.find(h);
if (it == clients.end())
return;
if (it->second.authorized)
--viewers;
outstanding -= it->second.bytes;
clients.erase(it);
}
void Drop(Handle h) {
// Cancel even a partially sent raw picture on revocation. Waiting for a
// WebSocket close frame behind megabytes of video would keep it alive.
Remove(h);
websocketpp::lib::error_code ec;
auto c = server.get_con_from_hdl(h, ec);
if (!ec) {
asio::error_code ignored;
c->get_raw_socket().cancel(ignored);
c->get_raw_socket().close(ignored);
}
}
void Revoke(const std::string &view) {
for (auto it = tickets.begin(); it != tickets.end();) {
if (view.empty() || it->second.grant.view == view)
it = tickets.erase(it);
else
++it;
}
std::vector<Handle> close;
for (const auto &[h, c] : clients)
if (view.empty() || c.view == view)
close.push_back(h);
for (auto h : close)
Drop(h);
}
void RequestKey() {
const auto now = Clock::now();
if (now - last_key_request < 1s)
return;
last_key_request = now;
key_requested = true;
}
void Message(Handle h, const Server::message_ptr &message) {
auto it = clients.find(h);
if (it == clients.end())
return;
auto &c = it->second;
auto j = json::parse(message->get_payload(), nullptr, false);
if (message->get_opcode() != websocketpp::frame::opcode::TEXT ||
!j.is_object())
return Drop(h);
if (!c.authorized) {
if (j.size() != 1 || !j.contains("ticket") || !j["ticket"].is_string())
return Drop(h);
auto ticket = tickets.find(j["ticket"].get<std::string>());
if (ticket == tickets.end())
return Drop(h);
const auto grant = std::move(ticket->second);
tickets.erase(ticket);
if (!connected || grant.expires <= Clock::now() || viewers >= 8 ||
(c.origin != grant.grant.origin &&
!(grant.grant.apps && AppOrigin(c.origin))))
return Drop(h);
c.authorized = true;
c.view = grant.grant.view;
c.view_generation = grant.grant.view_generation;
c.session = grant.grant.session;
++viewers;
// Also prompts a raw viewer to receive the current fresh picture.
key_requested = true;
return;
}
if (j.value("type", "") == "keyframe" && j.size() == 1 && !decoded) {
c.waiting_key = true;
RequestKey();
return;
}
if (j.value("type", "") != "ack" || j.size() != 3 ||
!j.contains("generation") || !j["generation"].is_number_unsigned() ||
!j.contains("sequence") || !j["sequence"].is_number_unsigned() ||
c.receipts.empty() ||
j["generation"] != c.receipts.front().generation ||
j["sequence"] != c.receipts.front().sequence)
return Drop(h);
c.bytes -= c.receipts.front().bytes;
outstanding -= c.receipts.front().bytes;
c.receipts.pop_front();
}
void Maintain() {
timer = server.set_timer(
33, [this](const websocketpp::lib::error_code &error) {
if (error)
return;
const auto now = Clock::now();
std::deque<AudioFrame> audio;
{
std::lock_guard lock(mutex);
audio.swap(audio_queue);
audio_bytes = 0;
}
for (const auto &frame : audio) {
if (!connected || frame.generation != generation) continue;
std::string bytes;
Number(bytes, 0x43444131, 4); // CDA1, network-order header, s16le payload.
Number(bytes, frame.generation, 4);
Number(bytes, frame.sequence, 4);
Number(bytes, 48000, 4);
Number(bytes, 1, 2);
Number(bytes, 16, 2);
bytes.append(reinterpret_cast<const char *>(frame.pcm.data()), frame.pcm.size());
std::vector<Handle> failed;
for (auto &[handle, client] : clients) {
if (!client.authorized) continue;
websocketpp::lib::error_code error;
const auto connection = server.get_con_from_hdl(handle, error);
if (!error && connection->get_buffered_amount() < 256 * 1024)
server.send(handle, bytes, websocketpp::frame::opcode::BINARY, error);
if (error) failed.push_back(handle);
}
for (const auto handle : failed) Drop(handle);
}
std::string pointer;
uint64_t revision;
{
std::lock_guard lock(mutex);
revision = cursor_revision;
if (!cursor.is_null()) pointer = cursor.dump();
}
for (auto it = tickets.begin(); it != tickets.end();)
if (it->second.expires <= now)
it = tickets.erase(it);
else
++it;
std::vector<Handle> close;
for (auto &[h, c] : clients) {
if (c.authorized && c.cursor_revision != revision && !pointer.empty()) {
websocketpp::lib::error_code ec;
auto connection = server.get_con_from_hdl(h, ec);
// Coalesce pointer changes while a viewer is backpressured.
if (!ec && connection->get_buffered_amount() < 256 * 1024) {
server.send(h, pointer, websocketpp::frame::opcode::TEXT, ec);
if (!ec) c.cursor_revision = revision;
}
if (ec) close.push_back(h);
}
if ((!c.authorized && now - c.opened >= 3s) ||
(!c.receipts.empty() && now - c.receipts.front().sent >= 10s)) {
close.push_back(h);
} else if (c.authorized && now - c.pinged >= 10s) {
c.pinged = now;
websocketpp::lib::error_code ec;
server.ping(h, "", ec);
if (ec)
close.push_back(h);
}
}
for (auto h : close)
Drop(h);
Maintain();
});
}
void Enqueue(Packet p) {
std::lock_guard lock(mutex);
if (p.raw) {
dropped += queue.size();
queue.clear();
queued_bytes = 0;
} else {
if (queue.size() >= 60 || queued_bytes + p.Size() > kMaxQueued) {
dropped += queue.size();
queue.clear();
queued_bytes = 0;
wait_key = true;
key_requested = true;
}
if (wait_key && !p.Key()) {
++dropped;
return;
}
wait_key = false;
}
queued_bytes += p.Size();
queue.push_back(std::move(p));
if (!scheduled) {
scheduled = true;
server.get_io_service().post([this] { Drain(); });
}
}
void Drain() {
Packet p;
{
std::lock_guard lock(mutex);
if (queue.empty()) {
scheduled = false;
return;
}
p = std::move(queue.front());
queued_bytes -= p.Size();
queue.pop_front();
}
if (connected && p.Generation() == generation && bool(p.raw) == decoded)
Publish(p);
// Let authorization, reads, and timers run between large pictures.
server.get_io_service().post([this] { Drain(); });
}
void Publish(const Packet &p) {
std::vector<Handle> ready;
for (auto &[h, c] : clients) {
if (!c.authorized)
continue;
// ACKs are flow-control hints, not proof that a client read the socket.
// Also bound the actual send queue against forged early receipts.
const auto buffered = server.get_con_from_hdl(h)->get_buffered_amount();
if ((decoded && (!c.receipts.empty() || buffered > 0)) ||
(!decoded && (c.receipts.size() >= 8 || c.bytes >= 1024 * 1024 ||
buffered >= 1024 * 1024))) {
++dropped;
c.waiting_key = true;
if (!decoded)
RequestKey();
continue;
}
if (!decoded && c.waiting_key && !p.Key()) {
RequestKey();
continue;
}
ready.push_back(h);
}
if (ready.empty())
return;
// Prepare once and share the immutable WebSocket message across viewers.
// No length-prefixed pipe, Node Buffer, or per-viewer payload copy.
auto message =
server.get_con_from_hdl(ready.front())
->get_message(websocketpp::frame::opcode::BINARY, p.Size());
auto &bytes = message->get_raw_payload();
Number(bytes, decoded ? 0x43444e31 : 0x43445631, 4);
Number(bytes, p.Generation(), 4);
Number(bytes, p.Sequence(), 4);
const auto time =
p.raw ? std::chrono::duration_cast<std::chrono::microseconds>(
p.raw->queued.time_since_epoch())
.count()
: p.encoded.timestamp_us;
Number(bytes, time, 8);
Number(bytes, p.Key() ? 1 : 0, 4);
if (p.raw) {
Number(bytes, p.raw->width, 4);
Number(bytes, p.raw->height, 4);
message->append_payload(p.raw->nv12.data(), p.raw->nv12.size());
} else
message->append_payload(p.encoded.data.data(), p.encoded.data.size());
message->set_header(websocketpp::frame::prepare_header(
websocketpp::frame::basic_header(websocketpp::frame::opcode::BINARY,
bytes.size(), true, false),
websocketpp::frame::extended_header(bytes.size())));
message->set_prepared(true);
for (auto h : ready) {
auto &c = clients.at(h);
websocketpp::lib::error_code ec;
server.send(h, message, ec);
if (ec) {
Drop(h);
continue;
}
c.waiting_key = false;
c.bytes += bytes.size();
outstanding += bytes.size();
c.receipts.push_back({uint32_t(p.Generation()), uint32_t(p.Sequence()),
bytes.size(), Clock::now()});
}
}
};
VideoServer::VideoServer() : state_(std::make_unique<State>()) {}
VideoServer::~VideoServer() = default;
std::string VideoServer::Url() const {
return "ws://" + state_->authority + "/api/video";
}
void VideoServer::Bind(uint64_t generation, bool decoded, bool connected) {
auto &s = *state_;
// Only the automation owner calls Bind; the I/O thread reads these values.
s.Call([&s, generation, decoded, connected] {
if (s.generation == generation && s.decoded == decoded &&
s.connected == connected)
return;
s.Revoke("");
s.generation = generation;
s.decoded = decoded;
s.connected = connected;
s.key_requested = false;
std::lock_guard lock(s.mutex);
s.queue.clear();
s.audio_queue.clear();
s.audio_bytes = 0;
s.queued_bytes = 0;
s.wait_key = true;
});
}
void VideoServer::Grant(VideoGrant grant) {
if (!Token(grant.ticket) || !Token(grant.view) || !Token(grant.session) ||
!grant.view_generation || !BrowserOrigin(grant.origin))
throw std::invalid_argument("INVALID_VIDEO_GRANT");
auto &s = *state_;
s.Call([&s, grant = std::move(grant)] {
if (!s.connected)
throw std::invalid_argument("NOT_READY");
for (auto it = s.tickets.begin(); it != s.tickets.end();)
if (it->second.expires <= Clock::now())
it = s.tickets.erase(it);
else
++it;
if (s.tickets.size() >= 32)
throw std::invalid_argument("VIDEO_LIMIT");
// A replaced view cannot retain a socket or consume an old grant.
bool changed = false;
for (const auto &[h, c] : s.clients)
changed |=
c.view == grant.view && (c.view_generation != grant.view_generation ||
c.session != grant.session);
for (const auto &[ticket, t] : s.tickets)
changed |= t.grant.view == grant.view &&
(t.grant.view_generation != grant.view_generation ||
t.grant.session != grant.session);
if (changed)
s.Revoke(grant.view);
if (!s.tickets
.emplace(grant.ticket, State::Ticket{grant, Clock::now() + 15s})
.second)
throw std::invalid_argument("INVALID_VIDEO_GRANT");
});
}
void VideoServer::Revoke(const std::string &view) {
state_->Call([&] { state_->Revoke(view); });
}
bool VideoServer::Enabled() const { return state_->viewers.load() > 0; }
bool VideoServer::TakeKeyframeRequest() {
return state_->key_requested.exchange(false);
}
void VideoServer::Submit(std::shared_ptr<const DecodedFrame> frame) {
if (!frame || !frame->width || !frame->height || (frame->width & 1) ||
(frame->height & 1) || frame->width > 8192 || frame->height > 8192 ||
uint64_t(frame->width) * frame->height > 16777216 ||
frame->nv12.size() != size_t(frame->width) * frame->height * 3 / 2)
throw std::invalid_argument("INVALID_VIDEO_FRAME");
if (Enabled())
state_->Enqueue({std::move(frame), {}});
}
void VideoServer::Submit(EncodedVideoFrame frame) {
if (frame.data.empty() || frame.data.size() > kMaxEncoded)
throw std::invalid_argument("INVALID_VIDEO_FRAME");
if (Enabled())
state_->Enqueue({{}, std::move(frame)});
}
void VideoServer::Submit(AudioFrame frame) {
if (!Enabled() || frame.pcm.empty() || frame.pcm.size() > 11520 || (frame.pcm.size() & 1)) return;
auto &s = *state_;
std::lock_guard lock(s.mutex);
while (!s.audio_queue.empty() && s.audio_bytes + frame.pcm.size() > 48000) {
s.audio_bytes -= s.audio_queue.front().pcm.size();
s.audio_queue.pop_front();
}
s.audio_bytes += frame.pcm.size();
s.audio_queue.push_back(std::move(frame));
}
void VideoServer::Cursor(json cursor) {
auto &s = *state_;
std::lock_guard lock(s.mutex);
s.cursor = std::move(cursor);
++s.cursor_revision;
}
json VideoServer::Status() const {
const auto &s = *state_;
std::lock_guard lock(s.mutex);
return {{"transport", "loopback-websocket/1"},
{"viewers", s.viewers.load()},
{"queued", s.queue.size()},
{"queued_bytes", s.queued_bytes},
{"unacknowledged_bytes", s.outstanding.load()},
{"dropped", s.dropped.load()}};
}
} // namespace crossdesk::cli
+50
View File
@@ -0,0 +1,50 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-03
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _VIDEO_SERVER_H_
#define _VIDEO_SERVER_H_
#include <controller_session.h>
#include <memory>
#include <string>
namespace crossdesk::cli {
struct VideoGrant {
std::string ticket, view, session, origin;
uint64_t view_generation = 0;
bool apps = false;
};
// Owns the loopback listener and its I/O thread. JSON-RPC carries only grants
// and diagnostics; encoded and decoded pictures stay in the native process.
class VideoServer {
public:
VideoServer();
~VideoServer();
VideoServer(const VideoServer &) = delete;
VideoServer &operator=(const VideoServer &) = delete;
std::string Url() const;
void Bind(uint64_t generation, bool decoded, bool connected);
void Grant(VideoGrant grant);
void Revoke(const std::string &view = "");
bool Enabled() const;
bool TakeKeyframeRequest();
void Submit(std::shared_ptr<const DecodedFrame> frame);
void Submit(EncodedVideoFrame frame);
void Submit(AudioFrame frame);
void Cursor(nlohmann::json cursor);
nlohmann::json Status() const;
private:
struct State;
std::unique_ptr<State> state_;
};
} // namespace crossdesk::cli
#endif
+9 -10
View File
@@ -2,25 +2,22 @@ local source_dir = path.absolute("../..", os.scriptdir())
local controller_dir = path.join(source_dir, "libs/controller") local controller_dir = path.join(source_dir, "libs/controller")
local terminal_dir = path.join(source_dir, "libs/terminal") local terminal_dir = path.join(source_dir, "libs/terminal")
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc") local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
local credential_patch = path.join(source_dir, "deps/patches/minirtc-credential-logs.patch")
add_requires("spdlog 1.14.1", {system = false}) add_requires("spdlog 1.14.1", {system = false})
add_requires("asio 1.32.0", "websocketpp 0.8.2", {system = false})
add_requires("libpng v1.6.59", {system = false, configs = {shared = false}})
if is_plat("windows") then if is_plat("windows") then
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}}) add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
end end
target("crossdesk_controller") target("crossdesk_controller")
set_kind("static") set_kind("static")
add_deps("minirtc", "crossdesk_wire") add_deps("minirtc", "crossdesk_wire", "crossdesk_file_browser")
add_packages("spdlog") add_packages("spdlog", "libpng", "libyuv")
add_includedirs(path.join(controller_dir, "include"), add_includedirs(path.join(controller_dir, "include"),
path.join(minirtc_dir, "src/api"), {public = true}) path.join(minirtc_dir, "src/api"), {public = true})
add_includedirs(path.join(minirtc_dir, "src/log"))
add_files(path.join(controller_dir, "src/*.cpp")) add_files(path.join(controller_dir, "src/*.cpp"))
before_build(function () remove_files(path.join(controller_dir, "src/file_browser.cpp"))
-- Require the same credential redaction in checkouts and source bundles.
os.vrunv("git", {"-C", minirtc_dir, "apply", "--reverse", "--check", credential_patch})
end)
target_end() target_end()
target("crossdesk_terminal") target("crossdesk_terminal")
@@ -37,12 +34,14 @@ target_end()
target("crossdesk-cli") target("crossdesk-cli")
set_kind("binary") set_kind("binary")
set_version("0.1.2") set_version("0.2.0")
add_deps("crossdesk_terminal") add_deps("crossdesk_terminal")
add_files("main.cpp") add_files("main.cpp")
add_files("agent.cpp", "automation_protocol.cpp", "video_server.cpp")
add_packages("asio", "websocketpp")
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp") add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
if is_plat("windows") then if is_plat("windows") then
add_syslinks("advapi32", "user32") add_syslinks("advapi32", "user32", "ws2_32")
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN") add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
elseif is_plat("linux") then elseif is_plat("linux") then
add_syslinks("rt") add_syslinks("rt")
+9 -5
View File
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
VOL_NAME="Install ${APP_NAME_UPPER}" VOL_NAME="Install ${APP_NAME_UPPER}"
echo "delete old files" echo "delete old files"
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp CrossDesk_dmg_temp rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp build_pkg_scripts CrossDesk_dmg_temp
mkdir -p build_pkg_temp mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully." echo ".app created successfully."
PKG_SCRIPT_ARGS=()
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
mkdir -p build_pkg_scripts mkdir -p build_pkg_scripts
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall cp "${SCRIPT_DIR}/tcc_migration_preinstall.sh" build_pkg_scripts/preinstall
chmod +x build_pkg_scripts/postinstall chmod +x build_pkg_scripts/preinstall
PKG_SCRIPT_ARGS=(--scripts build_pkg_scripts)
fi
mkdir -p build_pkg_resources mkdir -p build_pkg_resources
cat > build_pkg_resources/welcome.html <<EOF cat > build_pkg_resources/welcome.html <<EOF
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
<h1>欢迎安装 ${INSTALLER_TITLE}</h1> <h1>欢迎安装 ${INSTALLER_TITLE}</h1>
<p>CrossDesk 将安装到“应用程序”文件夹。</p> <p>CrossDesk 将安装到“应用程序”文件夹。</p>
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p> <p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p> <p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p> <p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
</body> </body>
</html> </html>
@@ -177,7 +181,7 @@ pkgbuild \
--version "${APP_VERSION}" \ --version "${APP_VERSION}" \
--install-location "/Applications" \ --install-location "/Applications" \
--component "${APP_BUNDLE}" \ --component "${APP_BUNDLE}" \
--scripts build_pkg_scripts \ ${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
build_pkg_temp/${APP_NAME}-component.pkg build_pkg_temp/${APP_NAME}-component.pkg
cat > build_pkg_temp/Distribution <<EOF cat > build_pkg_temp/Distribution <<EOF
+9 -5
View File
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
VOL_NAME="Install ${APP_NAME_UPPER}" VOL_NAME="Install ${APP_NAME_UPPER}"
echo "delete old files" echo "delete old files"
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp CrossDesk_dmg_temp rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp build_pkg_scripts CrossDesk_dmg_temp
mkdir -p build_pkg_temp mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully." echo ".app created successfully."
PKG_SCRIPT_ARGS=()
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
mkdir -p build_pkg_scripts mkdir -p build_pkg_scripts
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall cp "${SCRIPT_DIR}/tcc_migration_preinstall.sh" build_pkg_scripts/preinstall
chmod +x build_pkg_scripts/postinstall chmod +x build_pkg_scripts/preinstall
PKG_SCRIPT_ARGS=(--scripts build_pkg_scripts)
fi
mkdir -p build_pkg_resources mkdir -p build_pkg_resources
cat > build_pkg_resources/welcome.html <<EOF cat > build_pkg_resources/welcome.html <<EOF
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
<h1>欢迎安装 ${INSTALLER_TITLE}</h1> <h1>欢迎安装 ${INSTALLER_TITLE}</h1>
<p>CrossDesk 将安装到“应用程序”文件夹。</p> <p>CrossDesk 将安装到“应用程序”文件夹。</p>
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p> <p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p> <p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p> <p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
</body> </body>
</html> </html>
@@ -177,7 +181,7 @@ pkgbuild \
--version "${APP_VERSION}" \ --version "${APP_VERSION}" \
--install-location "/Applications" \ --install-location "/Applications" \
--component "${APP_BUNDLE}" \ --component "${APP_BUNDLE}" \
--scripts build_pkg_scripts \ ${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
build_pkg_temp/${APP_NAME}-component.pkg build_pkg_temp/${APP_NAME}-component.pkg
cat > build_pkg_temp/Distribution <<EOF cat > build_pkg_temp/Distribution <<EOF
@@ -0,0 +1,63 @@
#!/bin/bash
set -u
export LC_ALL=C
# Migration is best effort. Installing the application must remain possible
# even when its old privacy records or the migration directory are unavailable.
if [[ $# -lt 3 ]]; then
echo "CrossDesk permission migration skipped because installer arguments are missing." >&2
exit 0
fi
APP_BUNDLE="${3%/}${2%/}/CrossDesk.app"
if [[ ! -d "$APP_BUNDLE" ]]; then
exit 0
fi
BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \
"$APP_BUNDLE/Contents/Info.plist" 2>/dev/null || true)"
case "$BUNDLE_ID" in
cn.crossdesk.app|cn.crossdesk.CrossDesk|com.crossdesk.app|com.crossdesk.CrossDesk|com.kunkundi.crossdesk|com.kunkundi.CrossDesk)
;;
*) exit 0 ;;
esac
if SIGNATURE="$(/usr/bin/codesign --display --verbose=4 "$APP_BUNDLE" 2>&1)"; then
if ! /usr/bin/grep -Fxq 'Signature=adhoc' <<< "$SIGNATURE"; then
# Keep any unfinished migration for users who have not launched yet.
exit 0
fi
elif [[ "$SIGNATURE" != *": code object is not signed at all" ]]; then
echo "Could not identify the old CrossDesk signature. Preserving migration state." >&2
exit 0
fi
STATE_DIR="${3%/}/Library/Application Support/CrossDesk"
if [[ "$EUID" -ne 0 || -L "$STATE_DIR" ]]; then
echo "Could not safely record the CrossDesk permission migration." >&2
exit 0
fi
if ! /bin/mkdir -p "$STATE_DIR" ||
[[ "$(/usr/bin/stat -f '%u' "$STATE_DIR")" != 0 ]] ||
! /bin/chmod 755 "$STATE_DIR"; then
echo "CrossDesk migration directory is unavailable. Use permission repair after installation." >&2
exit 0
fi
TEMP_FILE="$(/usr/bin/mktemp "$STATE_DIR/.permission-migration.XXXXXX")" || exit 0
trap '/bin/rm -f "$TEMP_FILE"' EXIT
MIGRATION_ID="$(/usr/bin/uuidgen)" || exit 0
if /usr/bin/plutil -create xml1 "$TEMP_FILE" &&
/usr/bin/plutil -insert schema -integer 1 "$TEMP_FILE" &&
/usr/bin/plutil -insert migration_id -string "$MIGRATION_ID" "$TEMP_FILE" &&
/usr/bin/plutil -insert legacy_bundle_id -string "$BUNDLE_ID" "$TEMP_FILE" &&
/usr/bin/plutil -insert app_path -string "${2%/}/CrossDesk.app" "$TEMP_FILE" &&
/usr/bin/plutil -convert json "$TEMP_FILE" &&
/bin/chmod 644 "$TEMP_FILE" &&
/bin/mv -f "$TEMP_FILE" "$STATE_DIR/permission-migration.json"; then
echo "Recorded CrossDesk permission migration. Quit the old app and launch the new version to continue."
else
echo "Could not record CrossDesk permission migration. Use permission repair after installation." >&2
fi
exit 0
@@ -1,112 +0,0 @@
#!/bin/bash
set -e
APP_IDENTIFIER="cn.crossdesk.app"
# Keep known historical identifiers here. tccutil only resets identifiers that
# Launch Services can currently resolve, so path/db cleanup below remains a
# best-effort fallback for stale entries from unsigned or removed builds.
BUNDLE_IDENTIFIERS=(
"cn.crossdesk.app"
"cn.crossdesk.CrossDesk"
"com.crossdesk.app"
"com.crossdesk.CrossDesk"
"com.kunkundi.crossdesk"
"com.kunkundi.CrossDesk"
)
TCC_SERVICES=(
"ScreenCapture"
"Accessibility"
"Microphone"
"AudioCapture"
)
run_tccutil() {
local user_name="$1"
local user_id="$2"
local service="$3"
local bundle_id="$4"
if [ -n "$user_name" ] && [ -n "$user_id" ]; then
/bin/launchctl asuser "$user_id" \
/usr/bin/sudo -u "$user_name" \
/usr/bin/tccutil reset "$service" "$bundle_id" >/dev/null 2>&1
else
/usr/bin/tccutil reset "$service" "$bundle_id" >/dev/null 2>&1
fi
}
reset_bundle_tcc() {
local user_name="$1"
local user_id="$2"
local bundle_id
local service
for bundle_id in "${BUNDLE_IDENTIFIERS[@]}"; do
if run_tccutil "$user_name" "$user_id" "All" "$bundle_id"; then
continue
fi
for service in "${TCC_SERVICES[@]}"; do
run_tccutil "$user_name" "$user_id" "$service" "$bundle_id" || true
done
done
}
cleanup_tcc_db() {
local db_path="$1"
if [ ! -f "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
return
fi
/usr/bin/sqlite3 "$db_path" <<'SQL' >/dev/null 2>&1 || true
DELETE FROM access
WHERE service IN (
'kTCCServiceScreenCapture',
'kTCCServiceAccessibility',
'kTCCServiceMicrophone',
'kTCCServiceAudioCapture'
)
AND (
client IN (
'cn.crossdesk.app',
'cn.crossdesk.CrossDesk',
'com.crossdesk.app',
'com.crossdesk.CrossDesk',
'com.kunkundi.crossdesk',
'com.kunkundi.CrossDesk'
)
OR lower(client) LIKE '%crossdesk%'
);
SQL
}
cleanup_user_tcc_db() {
local user_name="$1"
local home_dir
home_dir=$(/usr/bin/dscl . -read "/Users/${user_name}" NFSHomeDirectory 2>/dev/null |
/usr/bin/awk '{print $2}')
if [ -z "$home_dir" ]; then
return
fi
cleanup_tcc_db "${home_dir}/Library/Application Support/com.apple.TCC/TCC.db"
}
CONSOLE_USER=$(/usr/bin/stat -f "%Su" /dev/console 2>/dev/null || true)
if [ -n "$CONSOLE_USER" ] &&
[ "$CONSOLE_USER" != "root" ] &&
[ "$CONSOLE_USER" != "loginwindow" ]; then
CONSOLE_UID=$(/usr/bin/id -u "$CONSOLE_USER" 2>/dev/null || true)
reset_bundle_tcc "$CONSOLE_USER" "$CONSOLE_UID"
cleanup_user_tcc_db "$CONSOLE_USER"
fi
# Also clear any system/root-scoped decisions as a harmless fallback.
reset_bundle_tcc "" ""
cleanup_tcc_db "/Library/Application Support/com.apple.TCC/TCC.db"
exit 0
@@ -0,0 +1,87 @@
#include "desktop_automation.h"
#include <algorithm>
#include <stdexcept>
namespace crossdesk {
using nlohmann::json;
std::string DesktopText(std::string text, size_t limit) {
if (text.size() > limit) {
while (limit && (static_cast<unsigned char>(text[limit]) & 0xc0) == 0x80) --limit;
text.resize(limit);
}
for (auto& character : text) if (static_cast<unsigned char>(character) < 32 && character != '\n') character = ' ';
return json::parse(json(text).dump(-1, ' ', false, json::error_handler_t::replace)).get<std::string>();
}
json DesktopAutomation::Execute(const std::string& owner, const json& request) {
const auto now = std::chrono::steady_clock::now();
while (!snapshots_.empty() && snapshots_.front().expires <= now) snapshots_.pop_front();
const auto operation = request.at("operation").get<std::string>();
if (operation == "capabilities") return DesktopCapabilities();
if (operation == "launch" || operation == "open_url") {
const auto value = request.at(operation == "launch" ? "application" : "url").get<std::string>();
if (value.empty() || value.size() > 4096 || value.find_first_of("\r\n") != std::string::npos ||
value.find('\0') != std::string::npos) throw std::invalid_argument("INVALID_ARGUMENT");
if (operation == "launch") return DesktopLaunch(value);
if (value.rfind("https://", 0) != 0 && value.rfind("http://", 0) != 0)
throw std::invalid_argument("INVALID_URL");
return DesktopOpenUrl(value);
}
DesktopItem item;
auto found = snapshots_.end();
if (request.contains("snapshot_id")) {
const auto id = request.at("snapshot_id").get<std::string>();
found = std::find_if(snapshots_.begin(), snapshots_.end(), [&](const auto& snapshot) {
return snapshot.id == id && snapshot.owner == owner;
});
if (found == snapshots_.end()) throw std::invalid_argument("STALE_UI_SNAPSHOT");
const int index = request.at("element_id").get<int>();
if (index < 0 || size_t(index) >= found->content.items.size()) throw std::invalid_argument("INVALID_ELEMENT");
item = found->content.items[size_t(index)];
}
if (operation == "windows" || operation == "tree") {
const int limit = request.value("max_nodes", 128), depth = request.value("max_depth", 8);
const int pid = request.value("pid", 0);
if (limit < 1 || limit > 128 || depth < 0 || depth > 16 || pid < 0)
throw std::invalid_argument("INVALID_ARGUMENT");
DesktopSnapshot content;
if (operation == "windows") content = DesktopWindows();
else if (found != snapshots_.end()) {
if (!item.inspect) throw std::invalid_argument("UNSUPPORTED_OPERATION");
content = item.inspect(limit, depth);
} else content = DesktopTree(pid, limit, depth);
json elements = json::array();
size_t bytes = 0;
for (size_t index = 0; index < content.items.size(); ++index) {
auto data = content.items[index].data;
data["element_id"] = index;
bytes += data.dump(-1, ' ', false, json::error_handler_t::replace).size();
if (bytes > 48000 || index >= 128) { content.truncated = true; content.items.resize(index); break; }
elements.push_back(std::move(data));
}
const auto id = "ui_" + std::to_string(++sequence_);
if (snapshots_.size() >= 8) snapshots_.pop_front();
const bool truncated = content.truncated;
snapshots_.push_back({id, owner, now + std::chrono::seconds(30), std::move(content)});
return {{"snapshot_id", id}, {"expires_in_ms", 30000}, {"elements", elements},
{"truncated", truncated}, {"coordinate_space", "desktop_native"}};
}
if (operation != "activate" && operation != "press" && operation != "focus" && operation != "set_value")
throw std::invalid_argument("UNSUPPORTED_OPERATION");
if (found == snapshots_.end() || !item.perform) throw std::invalid_argument("INVALID_ELEMENT");
const auto value = request.value("value", std::string{});
if (value.size() > 16384 || value.find('\0') != std::string::npos) throw std::invalid_argument("INVALID_ARGUMENT");
if (item.data.value("secure", false)) throw std::invalid_argument("SECURE_ELEMENT");
// The next operation must inspect the resulting UI again, even if an OS
// provider returns an uncertain result after accepting a mutation.
snapshots_.erase(found);
return item.perform(operation, value);
}
#if defined(__OHOS__)
json DesktopCapabilities() { return {{"available", false}}; }
DesktopSnapshot DesktopWindows() { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
DesktopSnapshot DesktopTree(int, int, int) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
json DesktopLaunch(const std::string&) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
json DesktopOpenUrl(const std::string&) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
#endif
} // namespace crossdesk
@@ -0,0 +1,50 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-06
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DESKTOP_AUTOMATION_H_
#define _DESKTOP_AUTOMATION_H_
#include <chrono>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace crossdesk {
struct DesktopSnapshot;
struct DesktopItem {
nlohmann::json data;
std::function<nlohmann::json(const std::string&, const std::string&)> perform;
std::function<DesktopSnapshot(int, int)> inspect;
};
struct DesktopSnapshot {
std::vector<DesktopItem> items;
bool truncated = false;
};
// Platform functions execute on the desktop UI thread. References remain owned
// by a bounded, connection-scoped snapshot until expiry or a mutating action.
nlohmann::json DesktopCapabilities();
DesktopSnapshot DesktopWindows();
DesktopSnapshot DesktopTree(int pid, int limit, int depth);
nlohmann::json DesktopLaunch(const std::string& application);
nlohmann::json DesktopOpenUrl(const std::string& url);
std::string DesktopText(std::string text, size_t limit = 256);
class DesktopAutomation {
public:
nlohmann::json Execute(const std::string& owner, const nlohmann::json& request);
private:
struct Snapshot {
std::string id, owner;
std::chrono::steady_clock::time_point expires;
DesktopSnapshot content;
};
std::deque<Snapshot> snapshots_;
uint64_t sequence_ = 0;
};
} // namespace crossdesk
#endif
+247
View File
@@ -0,0 +1,247 @@
#include "private_file.h"
#include <openssl/rand.h>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#include <Aclapi.h>
#include <sddl.h>
#else
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace crossdesk::private_file {
namespace {
void Require(bool ok, const char* error) {
if (!ok) throw std::runtime_error(error);
}
std::string RandomSuffix() {
unsigned char bytes[16];
Require(RAND_bytes(bytes, sizeof(bytes)) == 1, "Secure random source unavailable");
constexpr char hex[] = "0123456789abcdef";
std::string value;
for (auto byte : bytes) { value += hex[byte >> 4]; value += hex[byte & 15]; }
return value;
}
#ifdef _WIN32
std::vector<unsigned char> CurrentUser() {
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
return data;
}
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
// An elevated token can default to Administrators as the owner even when
// its DACL grants access only to TokenUser. Always set both explicitly.
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect private file");
return descriptor;
}
#endif
} // namespace
void Write(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomSuffix());
#ifdef _WIN32
auto descriptor = PrivateSecurityDescriptor(false);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
LocalFree(descriptor);
Require(file != INVALID_HANDLE_VALUE, "Cannot create private file");
DWORD written = 0;
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
&written, nullptr) &&
written == text.size() && FlushFileBuffers(file);
CloseHandle(file);
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DeleteFileW(temporary.c_str());
throw std::runtime_error("Cannot persist private file");
}
#else
int fd =
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
Require(fd >= 0, "Cannot create private file");
size_t offset = 0;
while (offset < text.size()) {
const auto count = write(fd, text.data() + offset, text.size() - offset);
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
bool ok = offset == text.size() && fsync(fd) == 0;
close(fd);
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
unlink(temporary.c_str());
throw std::runtime_error("Cannot persist private file");
}
const int directory = open(path.parent_path().c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
const bool durable = directory >= 0 && fsync(directory) == 0;
if (directory >= 0) close(directory);
Require(durable, "Cannot persist private directory entry");
#endif
}
namespace {
#ifdef _WIN32
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
FileHandle OpenPrivatePath(const std::filesystem::path& path, DWORD access) {
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
// owner repair cannot operate on a replacement at the same path.
HANDLE file = CreateFileW(
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
Require(file != INVALID_HANDLE_VALUE, "Cannot open private path");
return {file, CloseHandle};
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
auto file = OpenPrivatePath(path, READ_CONTROL);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"private path cannot be a reparse point");
auto user = CurrentUser();
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(GetSecurityInfo(
file.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read private file permissions");
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
bool ok = acl && acl->AceCount > 0;
// Only the exact protected, full-access DACL written by older versions is
// eligible for owner repair. Never adopt a shared or foreign-user profile.
SECURITY_DESCRIPTOR_CONTROL control{};
DWORD revision = 0;
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) {
ok = false;
break;
}
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
: 0;
legacy =
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
}
Require(ok && owner && (EqualSid(owner, sid) || legacy),
"private files must be accessible only by the current user");
if (legacy) {
auto writable = OpenPrivatePath(path, WRITE_OWNER);
BY_HANDLE_FILE_INFORMATION current{};
Require(GetFileInformationByHandle(writable.get(), &current) &&
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
current.nFileIndexHigh == info.nFileIndexHigh &&
current.nFileIndexLow == info.nFileIndexLow &&
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION, sid, nullptr,
nullptr, nullptr) == ERROR_SUCCESS,
"Cannot repair private file owner");
}
}
void CreatePrivateDirectory(const std::filesystem::path& path, bool protect_existing) {
if (!std::filesystem::exists(path)) {
auto descriptor = PrivateSecurityDescriptor(true);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
bool ok = CreateDirectoryW(path.c_str(), &security);
LocalFree(descriptor);
Require(ok, "Cannot create private directory");
}
if (protect_existing) {
// The cache may predate identity storage and inherit a normal profile ACL.
// Elevated older builds may have used Administrators as the owner. The
// private-path check below repairs that owner after applying the user ACL.
auto file = OpenPrivatePath(path, READ_CONTROL | WRITE_DAC);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"Invalid cache directory");
PSID owner = nullptr;
PSECURITY_DESCRIPTOR original = nullptr;
Require(GetSecurityInfo(file.get(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION,
&owner, nullptr, nullptr, nullptr, &original) == ERROR_SUCCESS,
"Cannot read cache directory owner");
std::unique_ptr<void, decltype(&LocalFree)> original_guard(original, LocalFree);
auto user = CurrentUser();
Require(owner && (EqualSid(owner, reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid) ||
IsWellKnownSid(owner, WinBuiltinAdministratorsSid)),
"Cache directory belongs to another user");
auto descriptor = PrivateSecurityDescriptor(true);
std::unique_ptr<void, decltype(&LocalFree)> descriptor_guard(descriptor, LocalFree);
BOOL present = FALSE, defaulted = FALSE;
PACL acl = nullptr;
Require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) && present &&
SetSecurityInfo(file.get(), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, acl, nullptr) == ERROR_SUCCESS,
"Cannot protect cache directory");
}
CheckPrivateWindowsPath(path);
}
#endif
} // namespace
std::string Read(const std::filesystem::path& path) {
#ifdef _WIN32
CheckPrivateWindowsPath(path);
#else
struct stat status{};
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
"private file must be owned by this user with mode 0600");
#endif
Require(std::filesystem::file_size(path) <= 64 * 1024, "private file too large");
std::ifstream input(path, std::ios::binary);
Require(input.good(), "Cannot read private file");
return {std::istreambuf_iterator<char>(input), {}};
}
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
const auto parent = directory.parent_path();
if (!parent.empty()) {
std::filesystem::create_directories(parent);
}
#ifdef _WIN32
CreatePrivateDirectory(directory, protect_existing);
#else
std::filesystem::create_directories(directory);
struct stat status{};
Require(lstat(directory.c_str(), &status) == 0 && S_ISDIR(status.st_mode) &&
status.st_uid == getuid(), "Invalid private directory owner or type");
Require(chmod(directory.c_str(), 0700) == 0, "Cannot protect private directory");
#endif
}
} // namespace crossdesk::private_file
+21
View File
@@ -0,0 +1,21 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVATE_FILE_H_
#define _PRIVATE_FILE_H_
#include <filesystem>
#include <string>
namespace crossdesk::private_file {
// User-only files, atomic replacement, and rejection of shared/linked stores.
void EnsureDirectory(const std::filesystem::path& directory,
bool protect_existing = false);
std::string Read(const std::filesystem::path& path);
void Write(const std::filesystem::path& path, const std::string& text);
}
#endif
+2
View File
@@ -12,6 +12,8 @@ namespace crossdesk {
// Called on the application thread. True means the OS accepted the request, // Called on the application thread. True means the OS accepted the request,
// not that the asynchronous transition to the lock screen has finished. // not that the asynchronous transition to the lock screen has finished.
bool LockScreen(); bool LockScreen();
enum class ScreenLockState { unknown, unlocked, locked };
ScreenLockState QueryScreenLock();
} // namespace crossdesk } // namespace crossdesk
@@ -10,7 +10,7 @@
#include "SimpleIni.h" #include "SimpleIni.h"
#include "autostart.h" #include "autostart.h"
#include "rd_log.h" #include "rd_log.h"
#include "signal_server_address.h" #include <minirtc.h>
namespace crossdesk { namespace crossdesk {
@@ -36,6 +36,14 @@ ConfigCenter::~ConfigCenter() {}
int ConfigCenter::Load() { int ConfigCenter::Load() {
SI_Error rc = ini_->LoadFile(config_path_.c_str()); SI_Error rc = ini_->LoadFile(config_path_.c_str());
if (rc < 0) { if (rc < 0) {
std::error_code error;
if (std::filesystem::exists(config_path_, error) || error) {
// An unreadable existing configuration must not silently disable access
// restrictions or overwrite the user's settings with default values.
trusted_devices_only_.store(true);
LOG_ERROR("Cannot read existing configuration; restricting incoming connections");
return -1;
}
Save(); Save();
return -1; return -1;
} }
@@ -141,8 +149,13 @@ int ConfigCenter::Load() {
ini_->GetBoolValue(section_, "privacy_local_unlock_allowed", true)); ini_->GetBoolValue(section_, "privacy_local_unlock_allowed", true));
lock_on_disconnect_.store( lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true)); ini_->GetBoolValue(section_, "lock_on_disconnect", true));
trusted_devices_only_.store(
ini_->GetValue(section_, "trusted_devices_only", nullptr) != nullptr &&
ini_->GetBoolValue(section_, "trusted_devices_only", true));
save_remote_previews_.store( save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true)); ini_->GetBoolValue(section_, "save_remote_previews", true));
pause_video_when_minimized_.store(
ini_->GetBoolValue(section_, "pause_video_when_minimized", true));
portable_service_prompt_suppressed_ = portable_service_prompt_suppressed_ =
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed", ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_); portable_service_prompt_suppressed_);
@@ -239,8 +252,11 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "privacy_local_unlock_allowed", ini_->SetBoolValue(section_, "privacy_local_unlock_allowed",
privacy_local_unlock_allowed_.load()); privacy_local_unlock_allowed_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load()); ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
ini_->SetBoolValue(section_, "save_remote_previews", ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load()); save_remote_previews_.load());
ini_->SetBoolValue(section_, "pause_video_when_minimized",
pause_video_when_minimized_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_); portable_service_prompt_suppressed_);
ini_->SetValue(section_, "sponsorship_prompt_dismissed_version", ini_->SetValue(section_, "sponsorship_prompt_dismissed_version",
@@ -333,22 +349,24 @@ int ConfigCenter::SetForceRelay(bool force_relay) {
} }
int ConfigCenter::SetServerHost(const std::string& host) { int ConfigCenter::SetServerHost(const std::string& host) {
const auto address = minirtc::ParseSignalServerAddress(host); MiniRtcSignalServerAddress address{};
if (!address) return -1; if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
if (address->port) return SetServerEndpoint(host, *address->port); if (address.port) return SetServerEndpoint(host, address.port);
if (StoreValues({{"signal_server_host", address->Address()}}) != 0) return -1; const auto normalized = std::string(address.host) + address.target;
signal_server_host_ = address->Address(); if (StoreValues({{"signal_server_host", normalized}}) != 0) return -1;
signal_server_host_ = normalized;
return 0; return 0;
} }
int ConfigCenter::SetServerEndpoint(const std::string& host, int port) { int ConfigCenter::SetServerEndpoint(const std::string& host, int port) {
const auto address = minirtc::ParseSignalServerAddress(host); MiniRtcSignalServerAddress address{};
if (!address) return -1; if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
port = address->port.value_or(port); if (address.port) port = address.port;
if (port < 1 || port > 65535) return -1; if (port < 1 || port > 65535) return -1;
if (StoreValues({{"signal_server_host", address->Address()}, const auto normalized = std::string(address.host) + address.target;
if (StoreValues({{"signal_server_host", normalized},
{"signal_server_port", std::to_string(port)}}) != 0) return -1; {"signal_server_port", std::to_string(port)}}) != 0) return -1;
signal_server_host_ = address->Address(); signal_server_host_ = normalized;
signal_server_port_ = port; signal_server_port_ = port;
return 0; return 0;
} }
@@ -425,6 +443,13 @@ bool ConfigCenter::IsPrivacyLocalUnlockAllowed() const {
return privacy_local_unlock_allowed_.load(); return privacy_local_unlock_allowed_.load();
} }
int ConfigCenter::SetTrustedDevicesOnly(bool enabled) {
if (StoreValues({{"trusted_devices_only", enabled ? "true" : "false"}}) != 0) return -1;
trusted_devices_only_.store(enabled);
return 0;
}
bool ConfigCenter::IsTrustedDevicesOnly() const { return trusted_devices_only_.load(); }
int ConfigCenter::SetLockOnDisconnect(bool enabled) { int ConfigCenter::SetLockOnDisconnect(bool enabled) {
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0) if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
return -1; return -1;
@@ -448,6 +473,18 @@ bool ConfigCenter::IsSaveRemotePreviews() const {
return save_remote_previews_.load(); return save_remote_previews_.load();
} }
int ConfigCenter::SetPauseVideoWhenMinimized(bool enabled) {
if (StoreValues({{"pause_video_when_minimized", enabled ? "true" : "false"}}) != 0) {
return -1;
}
pause_video_when_minimized_.store(enabled);
return 0;
}
bool ConfigCenter::IsPauseVideoWhenMinimized() const {
return pause_video_when_minimized_.load();
}
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) { int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
portable_service_prompt_suppressed_ = suppressed; portable_service_prompt_suppressed_ = suppressed;
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -55,7 +55,9 @@ class ConfigCenter {
int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyScreen(bool enable_privacy_screen);
int SetPrivacyLocalUnlock(bool allowed); int SetPrivacyLocalUnlock(bool allowed);
int SetLockOnDisconnect(bool enabled); int SetLockOnDisconnect(bool enabled);
int SetTrustedDevicesOnly(bool enabled);
int SetSaveRemotePreviews(bool enabled); int SetSaveRemotePreviews(bool enabled);
int SetPauseVideoWhenMinimized(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed); int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version); int SetSponsorshipPromptDismissedVersion(const std::string& version);
int SetFileTransferSavePath(const std::string& path); int SetFileTransferSavePath(const std::string& path);
@@ -80,7 +82,9 @@ class ConfigCenter {
bool IsEnablePrivacyScreen() const; bool IsEnablePrivacyScreen() const;
bool IsPrivacyLocalUnlockAllowed() const; bool IsPrivacyLocalUnlockAllowed() const;
bool IsLockOnDisconnect() const; bool IsLockOnDisconnect() const;
bool IsTrustedDevicesOnly() const;
bool IsSaveRemotePreviews() const; bool IsSaveRemotePreviews() const;
bool IsPauseVideoWhenMinimized() const;
bool IsPortableServicePromptSuppressed() const; bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const; std::string GetSponsorshipPromptDismissedVersion() const;
std::string GetFileTransferSavePath() const; std::string GetFileTransferSavePath() const;
@@ -113,7 +117,9 @@ class ConfigCenter {
std::atomic<bool> enable_privacy_screen_{true}; std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> privacy_local_unlock_allowed_{true}; std::atomic<bool> privacy_local_unlock_allowed_{true};
std::atomic<bool> lock_on_disconnect_{true}; std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> trusted_devices_only_{false};
std::atomic<bool> save_remote_previews_{true}; std::atomic<bool> save_remote_previews_{true};
std::atomic<bool> pause_video_when_minimized_{true};
bool portable_service_prompt_suppressed_ = false; bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_; std::string sponsorship_prompt_dismissed_version_;
std::string file_transfer_save_path_ = ""; std::string file_transfer_save_path_ = "";
File diff suppressed because it is too large Load Diff
@@ -54,6 +54,7 @@ private:
void ShowMainWindow(const std::string& activation_token = {}); void ShowMainWindow(const std::string& activation_token = {});
bool MinimizeMainWindowToTray(); bool MinimizeMainWindowToTray();
void BindMainCallbacks(bool gallery_window = false); void BindMainCallbacks(bool gallery_window = false);
void BindTrustedDeviceCallbacks(bool gallery_window = false);
void BindStreamCallbacks(); void BindStreamCallbacks();
void BindServerCallbacks(); void BindServerCallbacks();
void Tick(); void Tick();
@@ -92,7 +93,10 @@ private:
void SyncStreamKeyboardFocus(); void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused); void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow(); void SyncServerWindow();
void SyncTrustedDevices();
void SyncFileTransferNotifications(); void SyncFileTransferNotifications();
void OpenFileManager(const std::string& remote);
std::string FileManagerPeerName(const std::string& remote);
void CancelFileTransfer(uint32_t file_id); void CancelFileTransfer(uint32_t file_id);
void SetServerPanelExpanded(bool expanded); void SetServerPanelExpanded(bool expanded);
void PositionServerWindows(); void PositionServerWindows();
@@ -0,0 +1,21 @@
#pragma once
#include <slint.h>
namespace crossdesk {
// Winit creates hidden native windows on a later event-loop turn. Wait for
// that window before applying geometry that depends on its frame or DPI.
inline bool NativeWindowReady([[maybe_unused]] const slint::Window& window) {
#if defined(_WIN32)
return window.win32_hwnd() != nullptr;
#elif defined(__APPLE__)
return window.appkit_view() != nullptr;
#else
// Other backends accept initial geometry before show(); Wayland placement
// is controlled by the compositor and must not wait for a mapped surface.
return true;
#endif
}
} // namespace crossdesk
@@ -386,7 +386,7 @@ inline constexpr TranslationRow kTranslationRows[] = {
{"设置(修改后立即保存)", {"设置(修改后立即保存)", "Settings (each change is saved immediately)", "Настройки (изменения сохраняются сразу)", "設定(修改後立即儲存)", "設定 (各変更はすぐに保存されます)", "설정(각 변경 사항은 즉시 저장됩니다)", "Einstellungen (jede Änderung wird sofort gespeichert)", "Paramètres (chaque modification est enregistrée immédiatement)", "Configuración (cada cambio se guarda inmediatamente)", "Configurações (cada alteração é salva imediatamente)"}}, {"设置(修改后立即保存)", {"设置(修改后立即保存)", "Settings (each change is saved immediately)", "Настройки (изменения сохраняются сразу)", "設定(修改後立即儲存)", "設定 (各変更はすぐに保存されます)", "설정(각 변경 사항은 즉시 저장됩니다)", "Einstellungen (jede Änderung wird sofort gespeichert)", "Paramètres (chaque modification est enregistrée immédiatement)", "Configuración (cada cambio se guarda inmediatamente)", "Configurações (cada alteração é salva imediatamente)"}},
{"访问官网: ", {"访问官网: ", "Access Website: ", "Официальный сайт: ", "訪問官網: ", "ウェブサイトにアクセス: ", "웹사이트 접속: ", "Zugriff auf die Website: ", "Accéder au site Web : ", "Acceder al sitio web: ", "Acesse o site: "}}, {"访问官网: ", {"访问官网: ", "Access Website: ", "Официальный сайт: ", "訪問官網: ", "ウェブサイトにアクセス: ", "웹사이트 접속: ", "Zugriff auf die Website: ", "Accéder au site Web : ", "Acceder al sitio web: ", "Acesse o site: "}},
{"访问密码必须为 6 位英文字母或数字,区分大小写。", {"访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается.", "訪問密碼必須為 6 位英文字母或數字,區分大小寫。", "正確に 6 つの文字 (A ~ Z、a ~ z) または数字 (0 ~ 9) を使用してください。大文字と小文字を区別。", "정확히 6개의 문자(A–Z, a–z) 또는 숫자(0–9)를 사용하세요. 대소문자를 구분합니다.", "Verwenden Sie genau 6 Buchstaben (A–Z, a–z) oder Ziffern (0–9). Groß- und Kleinschreibung beachten.", "Utilisez exactement 6 lettres (A–Z, a–z) ou chiffres (0–9). Sensible aux majuscules et minuscules.", "Utilice exactamente 6 letras (A–Z, a–z) o dígitos (0–9). Distingue mayúsculas y minúsculas.", "Use exatamente 6 letras (A–Z, a–z) ou dígitos (0–9). Maiúsculas e minúsculas."}}, {"访问密码必须为 6 位英文字母或数字,区分大小写。", {"访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается.", "訪問密碼必須為 6 位英文字母或數字,區分大小寫。", "正確に 6 つの文字 (A ~ Z、a ~ z) または数字 (0 ~ 9) を使用してください。大文字と小文字を区別。", "정확히 6개의 문자(A–Z, a–z) 또는 숫자(0–9)를 사용하세요. 대소문자를 구분합니다.", "Verwenden Sie genau 6 Buchstaben (A–Z, a–z) oder Ziffern (0–9). Groß- und Kleinschreibung beachten.", "Utilisez exactement 6 lettres (A–Z, a–z) ou chiffres (0–9). Sensible aux majuscules et minuscules.", "Utilice exactamente 6 letras (A–Z, a–z) o dígitos (0–9). Distingue mayúsculas y minúsculas.", "Use exatamente 6 letras (A–Z, a–z) ou dígitos (0–9). Maiúsculas e minúsculas."}},
{"证书验证失败,请重新安装自托管根证书", {"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", "Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.", "證書驗證失敗,請重新安裝自託管根證書", "証明書の検証に失敗しました。セルフホスト型ルート証明書を再インストールします。", "인증서 확인에 실패했습니다. 자체 호스팅 루트 인증서를 다시 설치합니다.", "Die Zertifikatsüberprüfung ist fehlgeschlagen. Installieren Sie das selbstgehostete Stammzertifikat neu.", "La vérification du certificat a échoué. Réinstallez le certificat racine auto-hébergé.", "Falló la verificación del certificado. Reinstale el certificado raíz autohospedado.", "Falha na verificação do certificado. Reinstale o certificado raiz auto-hospedado."}}, {"服务器证书无效或已过期,正在自动重试", {"服务器证书无效或已过期,正在自动重试", "Server certificate invalid or expired. Retrying automatically.", "Сертификат сервера недействителен или просрочен. Повторяем подключение автоматически.", "伺服器憑證無效或已過期,正在自動重試", "サーバー証明書が無効か期限切れです。自動的に再試行します。", "서버 인증서가 유효하지 않거나 만료되었습니다. 자동으로 재시도합니다.", "Serverzertifikat ungültig oder abgelaufen. Automatischer Neuversuch.", "Certificat du serveur invalide ou expiré. Nouvelle tentative automatique.", "Certificado del servidor no válido o caducado. Reintentando automáticamente.", "Certificado do servidor inválido ou expirado. Tentando novamente automaticamente."}},
{"该分组暂无近期连接", {"该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений", "該分組暫無近期連線", "このグループには最近の接続がありません", "이 그룹에는 최근 연결이 없습니다.", "Keine aktuellen Verbindungen in dieser Gruppe", "Aucune connexion récente dans ce groupe", "No hay conexiones recientes en este grupo.", "Nenhuma conexão recente neste grupo"}}, {"该分组暂无近期连接", {"该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений", "該分組暫無近期連線", "このグループには最近の接続がありません", "이 그룹에는 최근 연결이 없습니다.", "Keine aktuellen Verbindungen in dieser Gruppe", "Aucune connexion récente dans ce groupe", "No hay conexiones recientes en este grupo.", "Nenhuma conexão recente neste grupo"}},
{"该应用需要授权以下权限:", {"该应用需要授权以下权限:", "The application requires the following permissions:", "Для работы приложения требуются следующие разрешения:", "該應用需要授權以下許可權:", "アプリケーションには次の権限が必要です。", "애플리케이션에는 다음 권한이 필요합니다.", "Die Anwendung erfordert die folgenden Berechtigungen:", "L'application nécessite les autorisations suivantes :", "La aplicación requiere los siguientes permisos:", "O aplicativo requer as seguintes permissões:"}}, {"该应用需要授权以下权限:", {"该应用需要授权以下权限:", "The application requires the following permissions:", "Для работы приложения требуются следующие разрешения:", "該應用需要授權以下許可權:", "アプリケーションには次の権限が必要です。", "애플리케이션에는 다음 권한이 필요합니다.", "Die Anwendung erfordert die folgenden Berechtigungen:", "L'application nécessite les autorisations suivantes :", "La aplicación requiere los siguientes permisos:", "O aplicativo requer as seguintes permissões:"}},
{"该设备正在连接,请稍候", {"该设备正在连接,请稍候", "This device is already connecting", "Устройство уже подключается", "該裝置正在連線,請稍候", "このデバイスはすでに接続されています", "이 장치는 이미 연결되어 있습니다", "Dieses Gerät stellt bereits eine Verbindung her", "Cet appareil est déjà connecté", "Este dispositivo ya se está conectando", "Este dispositivo já está conectado"}}, {"该设备正在连接,请稍候", {"该设备正在连接,请稍候", "This device is already connecting", "Устройство уже подключается", "該裝置正在連線,請稍候", "このデバイスはすでに接続されています", "이 장치는 이미 연결되어 있습니다", "Dieses Gerät stellt bereits eine Verbindung her", "Cet appareil est déjà connecté", "Este dispositivo ya se está conectando", "Este dispositivo já está conectado"}},
@@ -494,6 +494,44 @@ inline constexpr TranslationRow kTranslationRows[] = {
{":同步键鼠到所有已连接设备", {":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах", ":同步鍵鼠到所有已連線裝置", ": 接続されているすべてのデバイスへのミラー入力", ": 연결된 모든 장치에 대한 미러 입력", ": Eingang auf alle angeschlossenen Geräte spiegeln", ": entrée miroir sur tous les appareils connectés", ": entrada espejo para todos los dispositivos conectados", ": entrada espelhada para todos os dispositivos conectados"}}, {":同步键鼠到所有已连接设备", {":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах", ":同步鍵鼠到所有已連線裝置", ": 接続されているすべてのデバイスへのミラー入力", ": 연결된 모든 장치에 대한 미러 입력", ": Eingang auf alle angeschlossenen Geräte spiegeln", ": entrée miroir sur tous les appareils connectés", ": entrada espejo para todos los dispositivos conectados", ": entrada espelhada para todos os dispositivos conectados"}},
{"监听所有 IP 接口", {"监听所有 IP 接口", "Listen on all IP interfaces", "Все IP-интерфейсы", "監聽所有 IP 介面", "すべての IP で待ち受け", "모든 IP 인터페이스 수신", "Alle IP-Schnittstellen", "Toutes les interfaces IP", "Todas las interfaces IP", "Todas as interfaces IP"}}, {"监听所有 IP 接口", {"监听所有 IP 接口", "Listen on all IP interfaces", "Все IP-интерфейсы", "監聽所有 IP 介面", "すべての IP で待ち受け", "모든 IP 인터페이스 수신", "Alle IP-Schnittstellen", "Toutes les interfaces IP", "Todas las interfaces IP", "Todas as interfaces IP"}},
{"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", "Use one port on all interfaces, including VPNs. Select an actual IP to share. Turn off to restore separate ports per IP.", "Один порт для всех интерфейсов, включая VPN. Для подключения выберите реальный IP. Отключение вернёт отдельные порты для каждого IP.", "所有介面共用一個連接埠,包括 VPN。分享時請選擇實際 IP;關閉後恢復各 IP 的獨立連接埠。", "VPN を含む全インターフェースで同じポートを使います。共有には実際の IP を選択してください。オフにすると IP ごとのポートに戻ります。", "VPN을 포함한 모든 인터페이스가 하나의 포트를 사용합니다. 공유할 실제 IP를 선택하세요. 끄면 IP별 포트가 복원됩니다.", "Ein Port für alle Schnittstellen, einschließlich VPN. Zum Teilen eine tatsächliche IP wählen. Ausschalten stellt die Ports pro IP wieder her.", "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.", "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.", "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP."}}, {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", "Use one port on all interfaces, including VPNs. Select an actual IP to share. Turn off to restore separate ports per IP.", "Один порт для всех интерфейсов, включая VPN. Для подключения выберите реальный IP. Отключение вернёт отдельные порты для каждого IP.", "所有介面共用一個連接埠,包括 VPN。分享時請選擇實際 IP;關閉後恢復各 IP 的獨立連接埠。", "VPN を含む全インターフェースで同じポートを使います。共有には実際の IP を選択してください。オフにすると IP ごとのポートに戻ります。", "VPN을 포함한 모든 인터페이스가 하나의 포트를 사용합니다. 공유할 실제 IP를 선택하세요. 끄면 IP별 포트가 복원됩니다.", "Ein Port für alle Schnittstellen, einschließlich VPN. Zum Teilen eine tatsächliche IP wählen. Ausschalten stellt die Ports pro IP wieder her.", "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.", "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.", "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP."}},
{"可信设备", {"可信设备", "Trusted devices", "Доверенные устройства", "可信裝置", "信頼済みデバイス", "신뢰하는 기기", "Vertrauenswürdige Geräte", "Appareils de confiance", "Dispositivos de confianza", "Dispositivos confiáveis"}},
{"仅允许可信设备控制", {"仅允许可信设备控制", "Only allow trusted devices", "Разрешать только доверенные устройства", "僅允許可信裝置控制", "信頼済みデバイスのみ許可", "신뢰하는 기기만 제어 허용", "Nur vertrauenswürdige Geräte zulassen", "Autoriser uniquement les appareils de confiance", "Permitir solo dispositivos de confianza", "Permitir apenas dispositivos confiáveis"}},
{"管理可信设备", {"管理可信设备", "Manage trusted devices", "Управление доверенными устройствами", "管理可信裝置", "信頼済みデバイスを管理", "신뢰하는 기기 관리", "Vertrauenswürdige Geräte verwalten", "Gérer les appareils de confiance", "Administrar dispositivos de confianza", "Gerenciar dispositivos confiáveis"}},
{"可添加的已连接设备", {"可添加的已连接设备", "Connected devices available to trust", "Подключённые устройства для добавления", "可新增的已連線裝置", "追加可能な接続中のデバイス", "추가 가능한 연결된 기기", "Verbundene Geräte zum Hinzufügen", "Appareils connectés à ajouter", "Dispositivos conectados que puedes añadir", "Dispositivos conectados disponíveis"}},
{"信任此设备", {"信任此设备", "Trust this device", "Доверять устройству", "信任此裝置", "このデバイスを信頼", "이 기기 신뢰", "Diesem Gerät vertrauen", "Faire confiance à cet appareil", "Confiar en este dispositivo", "Confiar neste dispositivo"}},
{"移除信任", {"移除信任", "Remove trust", "Отозвать доверие", "移除信任", "信頼を解除", "신뢰 해제", "Vertrauen entfernen", "Retirer la confiance", "Retirar confianza", "Remover confiança"}},
{"无法读取设备身份或可信列表,请修复后再启用。", {"无法读取设备身份或可信列表,请修复后再启用。", "Could not load the device identity or trusted list. Repair the local data before enabling this restriction.", "Не удалось загрузить идентификатор устройства или список доверия. Восстановите локальные данные перед включением ограничения.", "無法讀取裝置身分或可信清單,請修復後再啟用。", "デバイス情報または信頼リストを読み込めません。ローカルデータを修復してから制限を有効にしてください。", "기기 ID 또는 신뢰 목록을 불러올 수 없습니다. 로컬 데이터를 복구한 후 제한을 켜세요.", "Geräteidentität oder Vertrauensliste konnte nicht geladen werden. Lokale Daten vor dem Aktivieren reparieren.", "Impossible de charger l’identité ou la liste de confiance. Réparez les données locales avant d’activer la restriction.", "No se pudo cargar la identidad o la lista de confianza. Repara los datos locales antes de activar la restricción.", "Não foi possível carregar a identidade ou a lista confiável. Repare os dados locais antes de ativar a restrição."}},
{"无法保存可信设备设置,请重试。", {"无法保存可信设备设置,请重试。", "Could not save trusted device settings. Try again.", "Не удалось сохранить настройки доверенных устройств. Повторите попытку.", "無法儲存可信裝置設定,請重試。", "信頼済みデバイスの設定を保存できません。再試行してください。", "신뢰하는 기기 설정을 저장할 수 없습니다. 다시 시도하세요.", "Einstellungen für vertrauenswürdige Geräte konnten nicht gespeichert werden. Erneut versuchen.", "Impossible d’enregistrer les paramètres de confiance. Réessayez.", "No se pudo guardar la configuración de confianza. Inténtalo de nuevo.", "Não foi possível salvar as configurações de confiança. Tente novamente."}},
{"粘贴", {"粘贴", "Paste", "Вставить", "貼上", "貼り付け", "붙여넣기", "Einfügen", "Coller", "Pegar", "Colar"}},
{"粘贴控制端的配对信息", {"粘贴控制端的配对信息", "Paste the controller’s pairing information", "Вставьте данные сопряжения управляющего устройства", "貼上控制端的配對資訊", "操作側のペアリング情報を貼り付け", "제어 장치의 페어링 정보 붙여넣기", "Kopplungsdaten des steuernden Geräts einfügen", "Collez les informations d’association de l’appareil de contrôle", "Pegue la información de emparejamiento del dispositivo de control", "Cole as informações de pareamento do dispositivo controlador"}},
{"已在可信列表中", {"已在可信列表中", "Already trusted", "Уже доверенное", "已在可信清單中", "信頼済み", "이미 신뢰하는 장치", "Bereits vertrauenswürdig", "Déjà approuvé", "Ya es de confianza", "Já é confiável"}},
{"无法访问剪贴板,请重试。", {"无法访问剪贴板,请重试。", "Cannot access the clipboard. Please try again.", "Не удалось получить доступ к буферу обмена. Повторите попытку.", "無法存取剪貼簿,請重試。", "クリップボードにアクセスできません。もう一度お試しください。", "클립보드에 접근할 수 없습니다. 다시 시도하세요.", "Kein Zugriff auf die Zwischenablage. Bitte erneut versuchen.", "Impossible d’accéder au presse-papiers. Réessayez.", "No se puede acceder al portapapeles. Inténtelo de nuevo.", "Não foi possível acessar a área de transferência. Tente novamente."}},
{"配对信息无效,请从控制端重新复制。", {"配对信息无效,请从控制端重新复制。", "Invalid pairing information. Copy it again from the controller.", "Недопустимые данные сопряжения. Скопируйте их заново с управляющего устройства.", "配對資訊無效,請從控制端重新複製。", "ペアリング情報が無効です。操作側からコピーし直してください。", "유효하지 않은 페어링 정보입니다. 제어 장치에서 다시 복사하세요.", "Ungültige Kopplungsdaten. Kopieren Sie sie erneut vom steuernden Gerät.", "Informations d’association invalides. Copiez-les à nouveau depuis l’appareil de contrôle.", "Información de emparejamiento no válida. Cópiela de nuevo del dispositivo de control.", "Informações de pareamento inválidas. Copie-as novamente do dispositivo controlador."}},
{"已加入可信设备。", {"已加入可信设备。", "Device added to the trusted list.", "Устройство добавлено в список доверенных.", "已加入可信裝置。", "信頼済みデバイスに追加しました。", "신뢰 장치 목록에 추가했습니다.", "Gerät zur Vertrauensliste hinzugefügt.", "Appareil ajouté à la liste de confiance.", "Dispositivo añadido a la lista de confianza.", "Dispositivo adicionado à lista de confiança."}},
{"无法更新可信设备,请检查列表容量和存储权限后重试。", {"无法更新可信设备,请检查列表容量和存储权限后重试。", "Cannot update trusted devices. Check the list capacity and storage permissions, then try again.", "Не удалось обновить список доверенных устройств. Проверьте его размер и права доступа к хранилищу.", "無法更新可信裝置,請檢查清單容量和儲存權限後重試。", "信頼済みデバイスを更新できません。リストの上限と保存先の権限を確認してください。", "신뢰 장치를 업데이트할 수 없습니다. 목록 용량과 저장 권한을 확인한 후 다시 시도하세요.", "Vertrauensliste konnte nicht aktualisiert werden. Prüfen Sie die Listenbegrenzung und Speicherberechtigungen.", "Impossible de mettre à jour les appareils de confiance. Vérifiez la capacité de la liste et les autorisations de stockage.", "No se pueden actualizar los dispositivos de confianza. Compruebe la capacidad de la lista y los permisos de almacenamiento.", "Não foi possível atualizar os dispositivos confiáveis. Verifique a capacidade da lista e as permissões de armazenamento."}},
{"复制配对信息", {"复制配对信息", "Copy pairing info", "Копировать данные", "複製配對資訊", "ペアリング情報をコピー", "페어링 정보 복사", "Kopplungsdaten kopieren", "Copier les informations", "Copiar información", "Copiar informações"}},
{"添加可信设备", {"添加可信设备", "Add a trusted device", "Добавить доверенное устройство", "新增可信裝置", "信頼するデバイスを追加", "신뢰 장치 추가", "Vertrauenswürdiges Gerät hinzufügen", "Ajouter un appareil de confiance", "Añadir dispositivo de confianza", "Adicionar dispositivo confiável"}},
{"已信任的设备", {"已信任的设备", "Trusted devices", "Доверенные устройства", "已信任的裝置", "信頼済みデバイス", "신뢰하는 장치", "Vertrauenswürdige Geräte", "Appareils de confiance", "Dispositivos de confianza", "Dispositivos confiáveis"}},
{"本机配对信息", {"本机配对信息", "This device’s pairing info", "Данные этого устройства", "本機配對資訊", "このデバイスのペアリング情報", "이 장치의 페어링 정보", "Kopplungsdaten dieses Geräts", "Informations de cet appareil", "Información de este dispositivo", "Informações deste dispositivo"}},
{"查看指纹", {"查看指纹", "View fingerprint", "Показать отпечаток", "查看指紋", "フィンガープリントを表示", "지문 보기", "Fingerabdruck anzeigen", "Afficher l’empreinte", "Ver huella", "Ver impressão digital"}},
{"收起指纹", {"收起指纹", "Hide fingerprint", "Скрыть отпечаток", "收起指紋", "フィンガープリントを隠す", "지문 숨기기", "Fingerabdruck ausblenden", "Masquer l’empreinte", "Ocultar huella", "Ocultar impressão digital"}},
{"暂无可信设备", {"暂无可信设备", "No trusted devices", "Нет доверенных устройств", "暫無可信裝置", "信頼済みデバイスはありません", "신뢰하는 장치 없음", "Keine vertrauenswürdigen Geräte", "Aucun appareil de confiance", "No hay dispositivos de confianza", "Nenhum dispositivo confiável"}},
{"请核对控制端指纹。", {"请核对控制端指纹。", "Check the fingerprint on the controller.", "Сверьте отпечаток с управляющим устройством.", "請核對控制端指紋。", "操作側のフィンガープリントと照合してください。", "제어 장치의 지문과 대조하세요.", "Mit dem Fingerabdruck des steuernden Geräts vergleichen.", "Vérifiez l’empreinte sur l’appareil de contrôle.", "Verifique la huella en el dispositivo de control.", "Confira a impressão digital no dispositivo controlador."}},
{"配对信息已复制。", {"配对信息已复制。", "Pairing information copied.", "Данные сопряжения скопированы.", "配對資訊已複製。", "ペアリング情報をコピーしました。", "페어링 정보를 복사했습니다.", "Kopplungsdaten kopiert.", "Informations d’association copiées.", "Información de emparejamiento copiada.", "Informações de pareamento copiadas."}},
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "Unable to copy pairing information. Please try again.", "Не удалось скопировать данные сопряжения. Повторите попытку.", "無法複製配對資訊,請重試。", "ペアリング情報をコピーできません。もう一度お試しください。", "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.", "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.", "Impossible de copier les informations d’association. Réessayez.", "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.", "Não foi possível copiar as informações de pareamento. Tente novamente."}},
{"重试更新", {"重试更新", "Retry update", "Повторить", "重試更新", "再試行", "다시 시도", "Erneut versuchen", "Réessayer", "Reintentar", "Tentar novamente"}},
{"更新未完成,请手动下载安装。", {"更新未完成,请手动下载安装。", "Update did not complete. Download and install manually.", "Обновление не завершено. Скачайте и установите вручную.", "更新未完成,請手動下載安裝。", "アップデートが完了しませんでした。手動でダウンロードしてインストールしてください。", "업데이트가 완료되지 않았습니다. 수동으로 다운로드하여 설치하세요.", "Das Update wurde nicht abgeschlossen. Laden Sie es herunter und installieren Sie es manuell.", "La mise à jour n'a pas été effectuée. Téléchargez-la et installez-la manuellement.", "La actualización no se completó. Descárguela e instálela manualmente.", "A atualização não foi concluída. Baixe e instale manualmente."}},
{"发送到远端", {"发送到远端", "Send to remote", "Отправить на удалённый ПК", "傳送到遠端", "リモートへ送信", "원격으로 보내기", "An Remote-PC senden", "Envoyer au PC distant", "Enviar al equipo remoto", "Enviar ao computador remoto"}},
{"发送到本机", {"发送到本机", "Send to local", "Отправить на этот ПК", "傳送到本機", "この端末へ送信", "이 기기로 보내기", "An diesen PC senden", "Envoyer à ce PC", "Enviar a este equipo", "Enviar a este computador"}},
{"清除已完成", {"清除已完成", "Clear completed", "Убрать завершённые", "清除已完成", "完了済みをクリア", "완료 항목 지우기", "Abgeschlossene entfernen", "Effacer les tâches terminées", "Limpiar completadas", "Limpar concluídas"}},
{"打开目录", {"打开目录", "Open folder", "Открыть папку", "開啟目錄", "フォルダーを開く", "폴더 열기", "Ordner öffnen", "Ouvrir le dossier", "Abrir carpeta", "Abrir pasta"}},
{"最小化时暂停视频", {"最小化时暂停视频", "Pause video when minimized", "Приостанавливать видео при сворачивании", "最小化時暫停視訊", "最小化時に映像を一時停止", "최소화 시 영상 일시 중지", "Video beim Minimieren pausieren", "Mettre la vidéo en pause à la réduction", "Pausar el vídeo al minimizar", "Pausar vídeo ao minimizar"}},
{"控制窗口最小化时暂停接收视频,降低本机解码资源占用。", {"控制窗口最小化时暂停接收视频,降低本机解码资源占用。", "Pause video reception when the control window is minimized to reduce local decoding resource usage.", "При сворачивании окна управления приём видео приостанавливается, снижая нагрузку от декодирования на этом устройстве.", "控制視窗最小化時暫停接收視訊,降低本機解碼資源占用。", "操作ウィンドウの最小化時に映像の受信を一時停止し、この端末のデコード処理による負荷を軽減します。", "제어 창을 최소화하면 영상 수신을 일시 중지하여 이 기기의 디코딩 리소스 사용량을 줄입니다.", "Pausiert den Videoempfang, wenn das Steuerungsfenster minimiert ist, und reduziert so den lokalen Ressourcenverbrauch beim Dekodieren.", "Met en pause la réception vidéo lorsque la fenêtre de contrôle est réduite afin de diminuer les ressources de décodage utilisées sur cet appareil.", "Pausa la recepción de vídeo al minimizar la ventana de control para reducir el uso de recursos de decodificación en este dispositivo.", "Pausa a recepção de vídeo ao minimizar a janela de controle para reduzir o uso de recursos de decodificação neste dispositivo."}},
{"系统权限", {"系统权限", "System permissions", "Системные разрешения", "系統權限", "システム権限", "시스템 권한", "Systemberechtigungen", "Autorisations système", "Permisos del sistema", "Permissões do sistema"}},
{"正在迁移旧版权限…", {"正在迁移旧版权限…", "Migrating permissions…", "Перенос разрешений…", "正在遷移舊版權限…", "権限を移行中…", "권한 이전 중…", "Berechtigungen werden migriert…", "Migration des autorisations…", "Migrando permisos…", "Migrando permissões…"}},
{"权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。", {"权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。", "Permission migration needs attention. Retry, or remove and re-add CrossDesk in System Settings.", "Не удалось завершить перенос разрешений. Повторите попытку или удалите и заново добавьте CrossDesk в системных настройках.", "權限遷移未完成。請重試,或在系統設定中移除舊 CrossDesk 後重新加入。", "権限の移行を完了できませんでした。再試行するか、システム設定でCrossDeskを削除して追加し直してください。", "권한 이전이 완료되지 않았습니다. 다시 시도하거나 시스템 설정에서 CrossDesk를 제거한 후 다시 추가하세요.", "Die Migration der Berechtigungen ist unvollständig. Wiederholen Sie den Vorgang oder entfernen Sie CrossDesk in den Systemeinstellungen und fügen Sie es erneut hinzu.", "La migration des autorisations est incomplète. Réessayez, ou supprimez puis ajoutez à nouveau CrossDesk dans les réglages système.", "La migración de permisos está incompleta. Reinténtelo o elimine y vuelva a añadir CrossDesk en Ajustes del Sistema.", "A migração de permissões está incompleta. Tente novamente ou remova e adicione o CrossDesk novamente nos Ajustes do Sistema."}},
{"重试权限迁移", {"重试权限迁移", "Retry migration", "Повторить перенос", "重試權限遷移", "移行を再試行", "권한 이전 재시도", "Migration wiederholen", "Réessayer la migration", "Reintentar migración", "Repetir migração"}},
{"稍后", {"稍后", "Later", "Позже", "稍後", "後で", "나중에", "Später", "Plus tard", "Más tarde", "Mais tarde"}},
}; };
struct NamedTranslation { const char* name; const char* literal; }; struct NamedTranslation { const char* name; const char* literal; };
@@ -676,7 +714,7 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
{"signal_disconnected", "未连接服务器"}, {"signal_disconnected", "未连接服务器"},
{"signal_reconnect", "重新连接"}, {"signal_reconnect", "重新连接"},
{"signal_reconnecting", "正在重新连接..."}, {"signal_reconnecting", "正在重新连接..."},
{"signal_tls_cert_error", "证书验证失败,请重新安装自托管根证书"}, {"signal_tls_cert_error", "服务器证书无效或已过期,正在自动重试"},
{"p2p_connected", "对等连接已建立"}, {"p2p_connected", "对等连接已建立"},
{"p2p_disconnected", "对等连接已断开"}, {"p2p_disconnected", "对等连接已断开"},
{"p2p_connecting", "正在建立对等连接..."}, {"p2p_connecting", "正在建立对等连接..."},
@@ -708,10 +746,12 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
{"access_website", "访问官网: "}, {"access_website", "访问官网: "},
{"update", "更新"}, {"update", "更新"},
{"install_update", "安装更新"}, {"install_update", "安装更新"},
{"retry_update", "重试更新"},
{"manual_download", "手动下载"}, {"manual_download", "手动下载"},
{"install_update_confirm", "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?"}, {"install_update_confirm", "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?"},
{"update_preparing", "正在准备更新…"}, {"update_preparing", "正在准备更新…"},
{"update_install_failed", "更新未完成,可重试或手动安装。"}, {"update_install_failed", "更新未完成,可重试或手动安装。"},
{"update_install_failed_manual", "更新未完成,请手动下载安装。"},
{"connection_alias", "修改名称"}, {"connection_alias", "修改名称"},
{"delete_connection", "删除连接"}, {"delete_connection", "删除连接"},
{"connect_to_this_connection", "发起连接"}, {"connect_to_this_connection", "发起连接"},
@@ -780,6 +820,13 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
{"privacy_screen_unlock_hint", "按下快捷键,解除隐私屏"}, {"privacy_screen_unlock_hint", "按下快捷键,解除隐私屏"},
{"exit_program", "退出"}, {"exit_program", "退出"},
{"shutdown_reason", "远程控制连接中,保留连接以确认或取消关机。"}, {"shutdown_reason", "远程控制连接中,保留连接以确认或取消关机。"},
{"pause_video_when_minimized", "最小化时暂停视频"},
{"pause_video_when_minimized_hint", "控制窗口最小化时暂停接收视频,降低本机解码资源占用。"},
{"system_permissions", "系统权限"},
{"permission_migration_running", "正在迁移旧版权限…"},
{"permission_migration_failed", "权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。"},
{"retry_permission_migration", "重试权限迁移"},
{"permission_later", "稍后"},
}; };
inline constexpr const char* kSlintLiterals[] = { inline constexpr const char* kSlintLiterals[] = {
"此端口已被占用,", "此端口已被占用,",
@@ -990,6 +1037,8 @@ inline constexpr const char* kSlintLiterals[] = {
"确认执行", "确认执行",
"监听所有 IP 接口", "监听所有 IP 接口",
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
"最小化时暂停视频",
"控制窗口最小化时暂停接收视频,降低本机解码资源占用。",
}; };
} // namespace detail } // namespace detail
@@ -1204,10 +1253,12 @@ inline constexpr const char* kSlintLiterals[] = {
X(access_website) \ X(access_website) \
X(update) \ X(update) \
X(install_update) \ X(install_update) \
X(retry_update) \
X(manual_download) \ X(manual_download) \
X(install_update_confirm) \ X(install_update_confirm) \
X(update_preparing) \ X(update_preparing) \
X(update_install_failed) \ X(update_install_failed) \
X(update_install_failed_manual) \
X(connection_alias) \ X(connection_alias) \
X(delete_connection) \ X(delete_connection) \
X(connect_to_this_connection) \ X(connect_to_this_connection) \
@@ -1275,7 +1326,14 @@ inline constexpr const char* kSlintLiterals[] = {
X(privacy_unlock_lock) \ X(privacy_unlock_lock) \
X(privacy_screen_unlock_hint) \ X(privacy_screen_unlock_hint) \
X(exit_program) \ X(exit_program) \
X(shutdown_reason) X(shutdown_reason) \
X(pause_video_when_minimized) \
X(pause_video_when_minimized_hint) \
X(system_permissions) \
X(permission_migration_running) \
X(permission_migration_failed) \
X(retry_permission_migration) \
X(permission_later)
} // namespace localization } // namespace localization
} // namespace crossdesk } // namespace crossdesk
@@ -59,6 +59,7 @@ void ClipboardController::Shutdown() {
pending_remote_text_.reset(); pending_remote_text_.reset();
pending_complete_ = {}; pending_complete_ = {};
pending_allowed_ = {}; pending_allowed_ = {};
pending_reads_.clear();
} }
bool ClipboardController::WebSessionActive( bool ClipboardController::WebSessionActive(
@@ -107,7 +108,7 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size,
if (found == web_sessions_.end() || found->second->events.lock() != events) return; if (found == web_sessions_.end() || found->second->events.lock() != events) return;
session = found->second; session = found->second;
} }
if (!hello) { if (!hello && kind != clip::kRead && kind != clip::kClear) {
if (!session->ready.load(std::memory_order_acquire) || if (!session->ready.load(std::memory_order_acquire) ||
kind != clip::kText || !request) return; kind != clip::kText || !request) return;
message = session->assembler.Receive(std::string_view(data, size)); message = session->assembler.Receive(std::string_view(data, size));
@@ -121,6 +122,23 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size,
session->ready.store(true, std::memory_order_release); session->ready.store(true, std::memory_order_release);
return; return;
} }
if ((kind == clip::kRead || kind == clip::kClear) &&
(!session->ready.load(std::memory_order_acquire) || !request ||
size != clip::kHeaderBytes)) return;
if (kind == clip::kRead) {
const auto send = [this, remote_id, session](const std::string& frame) {
return WebSessionActive(remote_id, session) &&
owner_.SendControllerData(remote_id, frame.data(), frame.size(), clip::kStream) == 0;
};
if (!QueueRead([send, request](std::optional<std::string> text) {
if (!text) send(clip::Frame(clip::kRejected, request));
else if (text->empty()) send(clip::Frame(clip::kEmpty, request));
else if (!clip::SendText(*text, request, 16 * 1024, send))
send(clip::Frame(clip::kRejected, request));
})) send(clip::Frame(clip::kRejected, request));
return;
}
if (kind == clip::kClear) message = clip::Frame(clip::kText, request);
if (message.empty()) return; if (message.empty()) return;
const std::weak_ptr<WebSession> weak = session; const std::weak_ptr<WebSession> weak = session;
auto allowed = [this, remote_id, weak] { auto allowed = [this, remote_id, weak] {
@@ -163,7 +181,7 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size,
if (!events_enabled_.load(std::memory_order_acquire)) { if (!events_enabled_.load(std::memory_order_acquire)) {
return; return;
} }
if (!data || size == 0) { if (!data || (size == 0 && !complete)) {
return; return;
} }
if (size > kMaxClipboardTextBytes) { if (size > kMaxClipboardTextBytes) {
@@ -207,11 +225,37 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size,
} }
} }
bool ClipboardController::QueueRead(std::function<void(std::optional<std::string>)> complete) {
{
std::lock_guard lock(pending_mutex_);
if (!events_enabled_.load() || pending_reads_.size() >= 32) return false;
pending_reads_.push_back(std::move(complete));
}
SDL_Event event{};
event.type = event_type_;
if (event_type_) SDL_PushEvent(&event);
return true;
}
void ClipboardController::ApplyPendingRemoteText() { void ClipboardController::ApplyPendingRemoteText() {
if (!events_enabled_.load(std::memory_order_acquire)) { if (!events_enabled_.load(std::memory_order_acquire)) {
return; return;
} }
ApplyPendingWrite();
std::deque<std::function<void(std::optional<std::string>)>> reads;
{
std::lock_guard lock(pending_mutex_);
reads.swap(pending_reads_);
}
if (!reads.empty()) {
char* value = SDL_GetClipboardText();
const std::optional<std::string> text = value ? std::optional<std::string>{value} : std::nullopt;
SDL_free(value);
for (auto& complete : reads) complete(text);
}
}
void ClipboardController::ApplyPendingWrite() {
std::optional<std::string> pending_text; std::optional<std::string> pending_text;
std::string source_id; std::string source_id;
std::weak_ptr<gui_detail::RemoteSession> source_session; std::weak_ptr<gui_detail::RemoteSession> source_session;
@@ -315,7 +359,7 @@ void ClipboardController::SendDeferredLocalText() {
return; return;
} }
const int ret = SendReliableDataFrame( const int ret = MiniRtcSendReliableDataFrame(
session->peer_, last_text_.data(), last_text_.size(), session->peer_, last_text_.data(), last_text_.size(),
session->clipboard_label_.c_str()); session->clipboard_label_.c_str());
if (ret != 0) { if (ret != 0) {
@@ -344,7 +388,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
} }
const int ret = const int ret =
SendReliableDataFrame(props->peer_, text.data(), text.size(), MiniRtcSendReliableDataFrame(props->peer_, text.data(), text.size(),
props->clipboard_label_.c_str()); props->clipboard_label_.c_str());
if (ret != 0) { if (ret != 0) {
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}", LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
@@ -11,6 +11,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <deque>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
@@ -42,6 +43,7 @@ public:
std::function<void(bool)> complete = {}, std::function<void(bool)> complete = {},
std::function<bool()> allowed = {}); std::function<bool()> allowed = {});
void ApplyPendingRemoteText(); void ApplyPendingRemoteText();
bool QueueRead(std::function<void(std::optional<std::string>)> complete);
void HandleLocalUpdate(); void HandleLocalUpdate();
void ReceiveWeb(const char* data, size_t size, const std::string& remote_id, void ReceiveWeb(const char* data, size_t size, const std::string& remote_id,
PeerEventHandler* source); PeerEventHandler* source);
@@ -49,6 +51,7 @@ public:
private: private:
struct WebSession; struct WebSession;
void ApplyPendingWrite();
bool WebSessionActive(const std::string& remote_id, bool WebSessionActive(const std::string& remote_id,
const std::shared_ptr<WebSession>& session); const std::shared_ptr<WebSession>& session);
int SendToController(const std::string& remote_id, const std::string& text); int SendToController(const std::string& remote_id, const std::string& text);
@@ -59,6 +62,7 @@ private:
uint32_t event_type_ = 0; uint32_t event_type_ = 0;
std::atomic<bool> events_enabled_{false}; std::atomic<bool> events_enabled_{false};
std::mutex pending_mutex_; std::mutex pending_mutex_;
std::deque<std::function<void(std::optional<std::string>)>> pending_reads_;
std::optional<std::string> pending_remote_text_; std::optional<std::string> pending_remote_text_;
std::string pending_remote_id_; std::string pending_remote_id_;
std::weak_ptr<gui_detail::RemoteSession> pending_session_; std::weak_ptr<gui_detail::RemoteSession> pending_session_;
@@ -48,9 +48,9 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
frame.size = size; frame.size = size;
for (const auto& id : owner_.ConnectedControllers()) for (const auto& id : owner_.ConnectedControllers())
owner_.WithController( owner_.WithController(
id, [&](PeerPtr* peer, const std::string& wire) { id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
frame.captured_timestamp = GetSystemTimeMicros(peer); frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
return SendAudioFrameToPeer(peer, &frame, return MiniRtcSendAudioFrameToPeer(peer, &frame,
owner_.audio_label_.c_str(), owner_.audio_label_.c_str(),
wire.data(), wire.size()); wire.data(), wire.size());
}); });
@@ -239,11 +239,11 @@ int SessionDeviceManager::InitializeScreenCapturer() {
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
stream_id)) continue; stream_id)) continue;
if (owner_.WithController( if (owner_.WithController(
remote_id, [&](PeerPtr* peer, const std::string& wire) { remote_id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
if (resumed_after_gap || stream_changed) if (resumed_after_gap || stream_changed)
RequestVideoKeyFrame(peer, stream_id.c_str()); MiniRtcRequestVideoKeyFrame(peer, stream_id.c_str());
frame.captured_timestamp = GetSystemTimeMicros(peer); frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(), return MiniRtcSendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
wire.data(), wire.size()); wire.data(), wire.size());
}) != 0) }) != 0)
++send_errors; ++send_errors;
@@ -285,7 +285,7 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
std::shared_lock lock(owner_.connection_status_mutex_); std::shared_lock lock(owner_.connection_status_mutex_);
return std::any_of(owner_.connection_status_.begin(), return std::any_of(owner_.connection_status_.begin(),
owner_.connection_status_.end(), [](const auto& entry) { owner_.connection_status_.end(), [](const auto& entry) {
return entry.second == ConnectionStatus::Connected && return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
entry.first.find("web") != std::string::npos; entry.first.find("web") != std::string::npos;
}); });
} }
@@ -294,7 +294,7 @@ bool SessionDeviceManager::HasConnectedController() const {
std::shared_lock lock(owner_.connection_status_mutex_); std::shared_lock lock(owner_.connection_status_mutex_);
return std::any_of(owner_.connection_status_.begin(), return std::any_of(owner_.connection_status_.begin(),
owner_.connection_status_.end(), [](const auto& entry) { owner_.connection_status_.end(), [](const auto& entry) {
return entry.second == ConnectionStatus::Connected; return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
}); });
} }
@@ -0,0 +1,637 @@
#include "features/file_transfer/file_browser_window.h"
#include <algorithm>
#include <any>
#include <cctype>
#include <climits>
#include <cstdio>
extern unsigned char fa_solid_900_ttf[];
extern unsigned int fa_solid_900_ttf_len;
#include "ui/model_update.h"
#include "localization.h"
#include "application/window_geometry.h"
#include "application/window_presentation.h"
#if defined(__APPLE__)
#include "platform/window_drag.h"
#elif defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#else
#include <SDL3/SDL.h>
#endif
namespace crossdesk {
namespace {
slint::SharedString S(std::string_view value) { return slint::SharedString(value); }
std::string Size(uint64_t bytes) {
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
double value = bytes; int unit = 0;
while (value >= 1024 && unit < 4) { value /= 1024; ++unit; }
char text[64]; std::snprintf(text, sizeof(text), unit ? "%.1f %s" : "%.0f %s", value, units[unit]);
return text;
}
std::string Join(const std::string& parent, const std::string& name) {
const bool windows = (parent.size() > 1 && parent[1] == ':') || parent.rfind("\\\\", 0) == 0;
return parent + (parent.empty() || parent.back() == '/' || parent.back() == '\\'
? "" : windows ? "\\" : "/") + name;
}
std::string Name(const std::string& path) {
const auto slash = path.find_last_of("/\\");
return slash == std::string::npos || slash == path.size() - 1 ? path : path.substr(slash + 1);
}
bool Matches(const std::string& text, const std::string& filter) {
return std::search(text.begin(), text.end(), filter.begin(), filter.end(),
[](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }) != text.end();
}
}
std::string FileBrowserWindow::Tr(const char* chinese, const char* english) const { return chinese_ ? chinese : english; }
FileBrowserWindow::FileBrowserWindow(FileTransferManager& transfers, std::string remote,
bool controller, int language)
: transfers_(transfers), remote_(std::move(remote)),
connection_generation_(transfers_.browser().Generation(remote_)),
chinese_(language == 0 || language == 3) {
transfers_.browser().TakeEvents(remote_);
window_->window().window_handle().register_font_from_data(
fa_solid_900_ttf, fa_solid_900_ttf_len);
// Resolve the native frame at its final size while it is still hidden.
// Otherwise show() applies the preferred size after we have centered it.
window_->window().set_size(slint::LogicalSize({900, 620}));
window_->set_chinese(chinese_);
UpdateActionLabels(language);
SetPeerName("");
window_->set_local_role(S(controller ? Tr("控制端", "Controller") : Tr("被控端", "Host")));
window_->set_remote_role(S(controller ? Tr("被控端", "Host") : Tr("控制端", "Controller")));
window_->set_local_entries(panes_[0].rows); window_->set_local_tree(panes_[0].tree);
window_->set_remote_entries(panes_[1].rows); window_->set_remote_tree(panes_[1].tree);
window_->set_tasks(tasks_);
window_->on_navigate([this](bool remote, slint::SharedString path) { Navigate(remote, std::string(path)); });
window_->on_refresh([this](bool remote) { Navigate(remote, panes_[remote].path); });
window_->on_up([this](bool remote) { Navigate(remote, panes_[remote].parent); });
window_->on_filter([this](bool remote, slint::SharedString filter) {
auto& pane = panes_[remote]; pane.filter = std::string(filter); pane.selected.clear(); UpdatePane(remote);
});
window_->on_sort([this](bool remote, int column) {
auto& pane = panes_[remote];
if (column < 0 || column > 2 || pane.busy || !pane.loaded ||
!pane.error.empty() || window_->get_drag_active()) return;
const auto sort_column = static_cast<SortColumn>(column);
pane.sort_descending = pane.sort_column == sort_column ? !pane.sort_descending
: sort_column != SortColumn::Name;
pane.sort_column = sort_column;
SortEntries(pane);
UpdatePane(remote);
});
window_->on_toggle_hidden([this](bool remote) {
auto& pane = panes_[remote]; pane.show_hidden = !pane.show_hidden;
pane.selected.clear(); UpdatePane(remote); UpdateTree(remote);
});
window_->on_select_entry([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
auto row = *pane.rows->row_data(index);
if (row.link) return;
SelectSource(remote);
const std::string path(row.path);
if (!pane.selected.erase(path)) pane.selected.insert(path);
UpdatePane(remote);
});
window_->on_select_all([this](bool remote) {
SelectSource(remote);
auto& pane = panes_[remote];
bool all = true;
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
const auto row = *pane.rows->row_data(i);
if (!row.link && !pane.selected.count(std::string(row.path))) all = false;
}
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
const auto row = *pane.rows->row_data(i);
if (all) pane.selected.erase(std::string(row.path));
else if (!row.link) pane.selected.insert(std::string(row.path));
}
UpdatePane(remote);
});
window_->on_clear_selection([this] {
if (window_->get_drag_active()) return;
for (int side = 0; side < 2; ++side) {
panes_[side].selected.clear();
UpdatePane(side);
}
});
window_->on_open_entry([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
const auto row = *pane.rows->row_data(index);
if (row.directory) OpenDirectory(remote, std::string(row.path));
});
window_->on_open_directory([this](bool remote, slint::SharedString path) {
OpenDirectory(remote, std::string(path));
});
window_->on_tree_open([this](bool remote, int index) {
const auto& pane = panes_[remote];
if (index >= 0 && index < static_cast<int>(pane.tree->row_count()))
Navigate(remote, std::string(pane.tree->row_data(index)->path));
});
window_->on_tree_toggle([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.tree->row_count())) return;
const std::string path(pane.tree->row_data(index)->path);
if (!pane.expanded.erase(path)) {
pane.expanded.insert(path);
if (!pane.directories.count(path)) Navigate(remote, path, true);
}
UpdateTree(remote);
});
window_->on_drag_data([this](bool remote, slint::SharedString path, int) {
return MakeDragData(remote, std::string(path));
});
window_->on_drag_state([this](const slint::DataTransfer& data, bool active) {
if (!active) { CancelDrag(); return; }
const auto value = data.user_data();
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
if (!stored || !*stored) return;
const auto payload = DropPayload(!(*stored)->remote, data);
if (!payload) return;
window_->set_drag_active(true);
window_->set_drag_count(static_cast<int>(payload->paths->size()));
SelectSource(payload->remote);
panes_[payload->remote].selected = {payload->paths->begin(), payload->paths->end()};
UpdatePane(payload->remote);
});
window_->on_can_drop([this](bool remote, const slint::DataTransfer& data) {
return static_cast<bool>(DropPayload(remote, data));
});
window_->on_dropped([this](bool remote, const slint::DataTransfer& data) {
const auto payload = DropPayload(remote, data);
if (!payload) return false;
// The drag owns a snapshot of both directories and its selected paths.
// Consume before queueing so repeated release events cannot send twice.
payload->consumed = true;
return QueueTransfer(payload->remote, *payload->paths, payload->target_directory);
});
window_->on_cancel_drag([this] { CancelDrag(); });
window_->on_transfer([this](bool download) { Transfer(download); });
window_->on_cancel_task([this](int id) { transfers_.CancelBrowserTransfer(remote_, static_cast<uint32_t>(id)); });
window_->on_clear_finished([this] {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
for (const auto& [id, task] : history_)
if (task.status == Status::Completed || task.status == Status::Failed || task.status == Status::Cancelled)
dismissed_.insert(id);
next_sync_ = {};
});
window_->window().on_close_requested([this] {
show_pending_ = false;
CancelDrag();
window_->hide(); return slint::CloseRequestResponse::KeepWindowShown;
});
Navigate(false, ""); Navigate(true, "");
}
void FileBrowserWindow::SetPeerName(const std::string& name) {
const auto text = S(name.empty() ? Tr("远端设备", "Remote device") : name);
if (window_->get_peer_name() != text) window_->set_peer_name(text);
}
void FileBrowserWindow::UpdateActionLabels(int language) {
window_->set_send_local_text(S(localization::Literal("发送到本机", language)));
window_->set_send_remote_text(S(localization::Literal("发送到远端", language)));
window_->set_clear_completed_text(S(localization::Literal("清除已完成", language)));
window_->set_open_directory_text(S(localization::Literal("打开目录", language)));
}
bool FileBrowserWindow::PresentsResult(uint32_t id,
FileTransferManager::FileTransferState::FileTransferStatus status) const {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
if (!PresentsTransfers() ||
(status != Status::Completed && status != Status::Failed && status != Status::Cancelled))
return false;
const auto task = history_.find(id);
return task != history_.end() && !task->second.receiving && task->second.status == status &&
(window_->get_tasks_expanded() || dismissed_.count(id));
}
bool FileBrowserWindow::CenterOnScreen() {
auto& window = window_->window();
if (window.is_fullscreen() || window.is_maximized()) return true;
#if defined(__APPLE__)
return CenterNativeWindowOnScreen(window.appkit_view());
#elif defined(_WIN32)
const HWND hwnd = window.win32_hwnd();
if (!hwnd) return false;
MONITORINFO monitor{sizeof(MONITORINFO)};
RECT frame{};
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitor) ||
!GetWindowRect(hwnd, &frame)) return false;
const auto& bounds = monitor.rcWork;
const auto target = window_geometry::CenteredPosition(
{static_cast<int>(bounds.left), static_cast<int>(bounds.top),
static_cast<int>(bounds.right - bounds.left),
static_cast<int>(bounds.bottom - bounds.top)},
{static_cast<int>(frame.right - frame.left),
static_cast<int>(frame.bottom - frame.top)});
return SetWindowPos(hwnd, nullptr, target.x, target.y, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) != 0;
#else
#if defined(__linux__) && !defined(__OHOS__)
// Native Wayland placement belongs to the compositor.
if (window.wayland_surface()) return true;
#endif
const auto position = window.position();
const auto size = window.size();
if (!size.width || !size.height) return false;
const SDL_Point center{position.x + static_cast<int>(size.width / 2),
position.y + static_cast<int>(size.height / 2)};
SDL_DisplayID display = SDL_GetDisplayForPoint(&center);
if (!display) display = SDL_GetPrimaryDisplay();
SDL_Rect bounds{};
if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return false;
const auto target = window_geometry::CenteredPosition(
{bounds.x, bounds.y, bounds.w, bounds.h},
{static_cast<int>(size.width), static_cast<int>(size.height)});
window.set_position(slint::PhysicalPosition({target.x, target.y}));
return true;
#endif
}
void FileBrowserWindow::Show() {
if (!Tick()) return;
if (Visible()) {
window_->window().set_minimized(false);
} else {
show_pending_ = true;
ShowWhenReady();
}
if (!panes_[1].busy && !panes_[1].error.empty()) Navigate(true, panes_[1].path);
}
void FileBrowserWindow::ShowWhenReady() {
if (!show_pending_ || !NativeWindowReady(window_->window())) return;
#if defined(__APPLE__)
if (!ConfigureWhiteWindowChrome(window_->window().appkit_view())) return;
#endif
if (!CenterOnScreen()) return;
show_pending_ = false;
window_->show();
}
void FileBrowserWindow::OpenDirectory(bool remote, const std::string& path) {
const auto& pane = panes_[remote];
if (!SessionConnected() || !pane.loaded || pane.busy || !pane.error.empty() ||
window_->get_drag_active()) return;
// Resolve the menu target by path, never by a row index that can change.
const auto entry = std::find_if(pane.entries.begin(), pane.entries.end(), [&](const auto& item) {
return item.row.directory && std::string_view(item.row.path) == path;
});
if (entry != pane.entries.end()) Navigate(remote, path);
}
void FileBrowserWindow::Navigate(bool remote, const std::string& path, bool tree) {
if (window_->get_drag_active()) CancelDrag();
if (remote && !transfers_.BrowserConnected(remote_)) { Notice(Error("disconnected"), true); return; }
auto& pane = panes_[remote];
for (auto it = requests_.begin(); it != requests_.end();) {
if (it->second.remote == remote && it->second.tree == tree &&
(!tree || it->second.path == path)) {
transfers_.browser().CancelList(remote_, it->first);
it = requests_.erase(it);
} else ++it;
}
const auto id = transfers_.browser().List(remote_, path, !remote);
requests_[id] = {remote, tree, path};
if (!tree) {
pane.request = id; pane.busy = true; pane.error.clear(); pane.loading.clear();
pane.selected.clear(); UpdatePane(remote);
}
}
void FileBrowserWindow::SortEntries(Pane& pane) {
std::sort(pane.entries.begin(), pane.entries.end(), [&](const Entry& a, const Entry& b) {
// Folder grouping and unknown dates stay consistent in either direction.
if (a.row.directory != b.row.directory) return a.row.directory;
int order = 0;
if (pane.sort_column == SortColumn::Modified) {
const bool a_known = a.modified_ms.has_value() || !a.modified.empty();
const bool b_known = b.modified_ms.has_value() || !b.modified.empty();
if (a_known != b_known) return a_known;
if (a.modified_ms && b.modified_ms)
order = *a.modified_ms < *b.modified_ms ? -1 : *a.modified_ms > *b.modified_ms ? 1 : 0;
else order = a.modified.compare(b.modified);
} else if (pane.sort_column == SortColumn::Size && !a.row.directory) {
order = a.bytes < b.bytes ? -1 : a.bytes > b.bytes ? 1 : 0;
}
if (!order) order = a.name_key.compare(b.name_key);
if (!order) order = std::string_view(a.row.name).compare(std::string_view(b.row.name));
if (!order) order = std::string_view(a.row.path).compare(std::string_view(b.row.path));
return pane.sort_descending ? order > 0 : order < 0;
});
}
void FileBrowserWindow::UpdatePane(bool remote) {
auto& pane = panes_[remote];
std::vector<ui::BrowserEntry> rows;
size_t selectable = 0;
rows.reserve(pane.entries.size());
for (const auto& item : pane.entries) {
auto entry = item.row;
if (!pane.show_hidden && std::string(entry.name).starts_with('.')) continue;
if (!Matches(std::string(entry.name), pane.filter)) continue;
entry.selected = pane.selected.count(std::string(entry.path));
if (!entry.link) ++selectable;
rows.push_back(std::move(entry));
}
ui::UpdateModelRows(*pane.rows, std::move(rows));
const auto summary = pane.busy
? pane.loading.empty() ? Tr("正在读取目录…", "Loading folder…") : pane.loading
: std::to_string(pane.rows->row_count()) + Tr(" 项", " items");
const bool up = pane.loaded && !pane.parent.empty() && pane.path != pane.parent;
const bool all = selectable > 0 && pane.selected.size() == selectable;
if (remote) {
window_->set_remote_sort_column(static_cast<int>(pane.sort_column));
window_->set_remote_sort_descending(pane.sort_descending);
window_->set_remote_current_path(S(pane.path));
window_->set_remote_filter(S(pane.filter));
window_->set_remote_hidden(pane.show_hidden);
window_->set_remote_busy(pane.busy); window_->set_remote_status(S(summary));
window_->set_remote_error(S(pane.error)); window_->set_remote_up(up);
window_->set_remote_selected(static_cast<int>(pane.selected.size())); window_->set_remote_all(all);
} else {
window_->set_local_sort_column(static_cast<int>(pane.sort_column));
window_->set_local_sort_descending(pane.sort_descending);
window_->set_local_current_path(S(pane.path));
window_->set_local_filter(S(pane.filter));
window_->set_local_hidden(pane.show_hidden);
window_->set_local_busy(pane.busy); window_->set_local_status(S(summary));
window_->set_local_error(S(pane.error)); window_->set_local_up(up);
window_->set_local_selected(static_cast<int>(pane.selected.size())); window_->set_local_all(all);
}
UpdateAvailability();
}
void FileBrowserWindow::UpdateTree(bool remote) {
auto& pane = panes_[remote];
std::vector<ui::BrowserTreeEntry> rows;
const auto append = [&](auto&& self, const std::string& path, const std::string& name, int depth) -> void {
if (depth > 24) return;
const bool expanded = pane.expanded.count(path);
rows.push_back({S(name), S(path), depth, expanded, pane.path == path});
if (expanded) {
const auto it = pane.directories.find(path);
if (it != pane.directories.end())
for (const auto& entry : it->second)
if (entry.directory && !entry.link && (pane.show_hidden || !std::string(entry.name).starts_with('.')))
self(self, std::string(entry.path), std::string(entry.name), depth + 1);
}
};
if (!pane.home.empty()) append(append, pane.home, Tr("主目录", "Home"), 0);
append(append, "@roots", Tr("磁盘", "Drives"), 0);
if (pane.loaded && pane.path != "@roots" && pane.path != pane.home &&
std::none_of(rows.begin(), rows.end(), [&](const auto& row) { return std::string(row.path) == pane.path; }))
append(append, pane.path, Name(pane.path), 0);
ui::UpdateModelRows(*pane.tree, std::move(rows));
}
bool FileBrowserWindow::TransferReady() const {
return window_->get_connected() && panes_[0].loaded && panes_[1].loaded &&
!panes_[0].busy && !panes_[1].busy && panes_[0].error.empty() && panes_[1].error.empty() &&
panes_[0].path != "@roots" && panes_[1].path != "@roots" &&
std::string(window_->get_local_path()) == panes_[0].path &&
std::string(window_->get_remote_path()) == panes_[1].path;
}
bool FileBrowserWindow::SessionConnected() const {
// A peer can reconnect with the same ID between UI ticks. Its old window
// must still close, even if the new connection is already usable.
return !disconnected_ && transfers_.BrowserConnected(remote_) &&
connection_generation_ == transfers_.browser().Generation(remote_);
}
void FileBrowserWindow::UpdateAvailability() {
const bool ready = TransferReady();
window_->set_transfer_ready(ready);
window_->set_can_upload(ready && !panes_[0].selected.empty());
window_->set_can_download(ready && !panes_[1].selected.empty());
// All selected rows share one immutable list, so selecting a large folder
// doesn't allocate a copy of the entire selection for every draggable row.
for (int side = 0; side < 2; ++side)
drag_selection_[side] = std::make_shared<const std::vector<std::string>>(
panes_[side].selected.begin(), panes_[side].selected.end());
// Callback bindings don't track C++ container mutations. Refresh the drag
// payload only when its selection/directory state changes, never every tick.
const int revision = window_->get_selection_revision();
window_->set_selection_revision(revision == INT_MAX ? 0 : revision + 1);
}
void FileBrowserWindow::SelectSource(bool remote) {
window_->set_source_remote(remote);
if (!panes_[!remote].selected.empty()) {
panes_[!remote].selected.clear(); UpdatePane(!remote);
}
}
slint::DataTransfer FileBrowserWindow::MakeDragData(bool remote, const std::string& path) const {
slint::DataTransfer result;
if (!TransferReady()) return result;
const auto& source = panes_[remote]; const auto& target = panes_[!remote];
const auto found = std::find_if(source.entries.begin(), source.entries.end(),
[&](const auto& entry) { return !entry.row.link && std::string(entry.row.path) == path; });
if (found == source.entries.end()) return result;
auto payload = std::make_shared<DragPayload>();
payload->owner = drag_owner_; payload->epoch = drag_epoch_; payload->remote = remote;
payload->source_request = source.request; payload->target_request = target.request;
payload->source_directory = source.path; payload->target_directory = target.path;
if (source.selected.count(path)) payload->paths = drag_selection_[remote];
else payload->paths = std::make_shared<const std::vector<std::string>>(std::initializer_list<std::string>{path});
// Opaque application data: a remote path must never masquerade as a local
// file URL when the pointer leaves this window or enters another session.
result.set_user_data(std::move(payload));
return result;
}
std::shared_ptr<FileBrowserWindow::DragPayload> FileBrowserWindow::DropPayload(
bool remote, const slint::DataTransfer& data) const {
const auto value = data.user_data();
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
if (!stored || !*stored) return {};
const auto& payload = *stored;
if (payload->owner.lock() != drag_owner_ || payload->epoch != drag_epoch_ ||
payload->consumed || payload->remote == remote || !payload->paths || payload->paths->empty() ||
!TransferReady() || !SessionConnected()) return {};
const auto& source = panes_[payload->remote]; const auto& target = panes_[remote];
if (source.request != payload->source_request || target.request != payload->target_request ||
source.path != payload->source_directory || target.path != payload->target_directory) return {};
return payload;
}
void FileBrowserWindow::CancelDrag() {
++drag_epoch_;
window_->set_drag_active(false); window_->set_drag_count(0);
UpdateAvailability();
}
bool FileBrowserWindow::QueueTransfer(bool download, const std::vector<std::string>& paths,
const std::string& destination) {
if (paths.empty() || !TransferReady() || !SessionConnected()) return false;
transfers_.browser().Copy(remote_, paths, destination, download);
Notice(Tr("正在准备传输 → ", "Preparing transfer → ") + destination, false);
panes_[download].selected.clear(); UpdatePane(download);
return true;
}
void FileBrowserWindow::Transfer(bool download) {
if (!TransferReady()) return;
auto& source = panes_[download];
const std::vector<std::string> paths(source.selected.begin(), source.selected.end());
QueueTransfer(download, paths, panes_[!download].path);
}
void FileBrowserWindow::Notice(const std::string& message, bool error) {
window_->set_notice(S(message)); window_->set_notice_error(error);
}
std::string FileBrowserWindow::Error(const std::string& code) const {
if (code == "timeout") return Tr("远端未响应,请确认两端已更新至支持文件浏览的版本后重试。", "Peer did not respond. Update both ends to a version that supports file browsing, then retry.");
if (code == "disconnected") return Tr("连接已断开,请重新连接后打开文件管理器。", "Disconnected. Reconnect and reopen the file manager.");
if (code == "browser_not_open") return Tr("请先在控制端打开本连接的文件管理器,再浏览控制端目录。", "Open this connection's file manager on the controller to enable browsing its folders.");
if (code == "directory_unavailable") return Tr("无法打开目录,请检查路径和访问权限后刷新。", "Cannot open folder. Check the path and access permissions, then refresh.");
if (code == "pagination_unsupported") return Tr("远端版本无法完整读取此目录,请更新远端后重试。", "Update the remote app to load every item in this folder.");
if (code == "invalid_listing" || code == "listing_expired") return Tr("目录列表未能完整加载,请刷新重试。", "The folder listing could not be fully loaded. Refresh to retry.");
if (code == "directory_not_writable") return Tr("目标目录不可写,请选择其他目录。", "Cannot write to the destination. Choose another folder.");
if (code == "symbolic_link") return Tr("所选内容包含符号链接,请选择实际文件或目录。", "Selection contains a symbolic link. Select the original files or folders.");
if (code == "too_many_files") return Tr("单次最多传输 4096 个文件与目录,请分批选择。", "Select at most 4096 files and folders per transfer.");
if (code == "file_unreadable") return Tr("文件无法读取,可能已移动或无访问权限。", "Cannot read a file. It may have moved or access was denied.");
if (code == "busy") return Tr("请求较多,请稍后重试。", "Too many pending requests. Try again shortly.");
if (code == "already_finished") return Tr("任务已结束,无需取消。", "This transfer has already finished.");
return Tr("操作失败,请检查所选路径与文件权限后重试。", "Operation failed. Check the paths and file permissions, then retry.");
}
bool FileBrowserWindow::Tick() {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
// Connection lifetime is checked every UI tick, outside the throttled
// directory/progress refresh. All native window operations stay on this thread.
if (!SessionConnected()) {
disconnected_ = true;
show_pending_ = false;
window_->set_connected(false);
CancelDrag();
requests_.clear();
window_->hide();
return false;
}
ShowWhenReady();
const auto now = std::chrono::steady_clock::now();
if (now < next_sync_) return true;
next_sync_ = now + std::chrono::milliseconds(150);
for (auto& event : transfers_.browser().TakeEvents(remote_)) {
if (!event.id) {
const bool ok = event.result.value("ok", false);
if (!ok || !window_->get_notice_error())
Notice(ok ? event.result.value("message", "") == "cancelling"
? Tr("正在取消传输…", "Cancelling transfer…")
: Tr("已加入传输队列", "Added to transfer queue")
: Error(event.result.value("error", "unknown")), !ok);
if (ok) refresh_at_ = now + std::chrono::seconds(1);
continue;
}
const auto found = requests_.find(event.id);
if (found == requests_.end()) continue;
const auto request = found->second;
auto& pane = panes_[request.remote];
if (!request.tree && pane.request != event.id) continue;
if (event.result.value("loading", false)) {
if (!request.tree) {
pane.loading = Tr("正在加载 ", "Loading ") + std::to_string(event.result.value("loaded", size_t{})) +
" / " + std::to_string(event.result.value("total", size_t{})) + Tr(" 项…", " items…");
if (request.remote) window_->set_remote_status(S(pane.loading));
else window_->set_local_status(S(pane.loading));
}
continue;
}
requests_.erase(found);
if (!request.tree) pane.busy = false;
if (!event.result.value("ok", false)) {
pane.error = Error(event.result.value("error", "unknown"));
if (request.tree) pane.expanded.erase(request.path);
} else {
try {
const auto path = event.result.at("path").get<std::string>();
const auto& entries = event.result.at("entries");
if (!entries.is_array()) throw std::runtime_error("invalid");
std::vector<ui::BrowserEntry> rows;
rows.reserve(entries.size());
std::vector<Entry> sortable;
if (!request.tree) sortable.reserve(entries.size());
for (const auto& entry : entries) {
ui::BrowserEntry row{};
const auto name = entry.at("name").get<std::string>();
if (name.empty() || name == "." || name == "..") continue;
// Paths from a different operating system are opaque to this UI.
row.name = S(name); row.path = S(path == "@roots" ? entry.at("path").get<std::string>() : Join(path, name));
row.directory = entry.at("directory").get<bool>(); row.link = entry.value("link", false);
const auto bytes = entry.at("size").get<uint64_t>();
const auto timestamp = entry.at("modified").get<std::string>();
row.size = S(Size(bytes));
auto modified = timestamp.substr(0, 10);
std::replace(modified.begin(), modified.end(), '-', '/');
row.modified = S(modified);
if (!request.tree) {
Entry item{row, name, timestamp, bytes, std::nullopt};
std::transform(item.name_key.begin(), item.name_key.end(), item.name_key.begin(),
[](unsigned char c) { return std::tolower(c); });
if (entry.contains("modified_ms") && entry["modified_ms"].is_number_integer())
item.modified_ms = entry["modified_ms"].get<int64_t>();
sortable.push_back(std::move(item));
}
rows.push_back(std::move(row));
}
pane.directories[path] = rows;
if (!request.tree) {
// A name filter belongs to the open folder. Preserve it on refresh,
// but don't silently hide entries after navigating somewhere else.
if (pane.path != path) pane.filter.clear();
pane.path = path; pane.parent = event.result.at("parent").get<std::string>();
if (request.path.empty()) pane.home = path;
pane.entries = std::move(sortable); pane.error.clear(); pane.loaded = true;
SortEntries(pane);
pane.expanded.insert(path);
}
} catch (...) { pane.error = Error("unknown"); }
}
if (!request.tree) {
if (request.remote) window_->set_remote_path(S(pane.path)); else window_->set_local_path(S(pane.path));
}
UpdatePane(request.remote); UpdateTree(request.remote);
}
bool new_tasks = false;
for (const auto& task : transfers_.BrowserTransfers(remote_)) {
new_tasks = new_tasks || (!task.receiving && !history_.count(task.file_id));
history_[task.file_id] = task;
}
if (new_tasks) window_->set_tasks_expanded(true);
std::vector<ui::BrowserTask> rows;
for (auto it = history_.rbegin(); it != history_.rend(); ++it) {
const auto& [id, task] = *it;
if (task.status == Status::Completed && completed_.insert(id).second)
refresh_at_ = now + std::chrono::milliseconds(500);
// Received files still trigger directory refresh, but only this computer's
// outgoing transfers appear in the task list and progress notifications.
if (task.receiving || dismissed_.count(id)) continue;
ui::BrowserTask row{};
row.id = static_cast<int>(id); row.name = S(task.file_name); row.receiving = task.receiving;
row.size = S(Size(task.file_size));
row.active = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving || task.status == Status::Cancelling;
row.failed = task.status == Status::Failed;
row.can_cancel = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving;
row.progress = task.status == Status::Completed ? 1.f : task.file_size ? std::clamp(float(task.sent_bytes) / task.file_size, 0.f, 1.f) : 0.f;
const auto detail = Tr("已传输 ", "Transferred ") + Size(task.sent_bytes) +
(task.rate_bps ? " · " + Size(task.rate_bps / 8) + "/s" : "");
row.detail = S(detail);
switch (task.status) {
case Status::Queued: row.status = S(Tr("排队中", "Queued")); break;
case Status::Sending: row.status = S(Tr("发送中 ", "Sending ") + std::to_string(int(row.progress * 100)) + "%"); break;
case Status::Receiving: row.status = S(Tr("接收中 ", "Receiving ") + std::to_string(int(row.progress * 100)) + "%"); break;
case Status::Completed: row.status = S(Tr("已完成", "Completed")); break;
case Status::Failed: row.status = S(Tr("失败 · 请重试", "Failed · Retry")); break;
case Status::Cancelling: row.status = S(Tr("正在取消", "Cancelling")); break;
case Status::Cancelled: row.status = S(Tr("已取消", "Cancelled")); break;
}
rows.push_back(row);
}
const auto active = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.active; });
const auto failed = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.failed; });
const auto summary = std::to_string(rows.size()) + Tr(" 项", " items") +
(active ? " · " + std::to_string(active) + Tr(" 项进行中", " active") : "") +
(failed ? " · " + std::to_string(failed) + Tr(" 项失败", " failed") : "");
window_->set_task_summary(S(summary));
ui::UpdateModelRows(*tasks_, std::move(rows));
if (!window_->get_drag_active() && refresh_at_ != std::chrono::steady_clock::time_point{} && now >= refresh_at_) {
refresh_at_ = {};
for (int side = 0; side < 2; ++side)
if (panes_[side].loaded && !panes_[side].busy && panes_[side].selected.empty() &&
std::string(side ? window_->get_remote_path() : window_->get_local_path()) == panes_[side].path)
Navigate(side, panes_[side].path);
}
const bool ready = TransferReady();
if (window_->get_transfer_ready() != ready) UpdateAvailability();
return true;
}
} // namespace crossdesk
@@ -0,0 +1,102 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_BROWSER_WINDOW_H_
#define _FILE_BROWSER_WINDOW_H_
#include "crossdesk_ui.h"
#include "features/file_transfer/file_transfer_manager.h"
#include <optional>
#include <set>
namespace crossdesk {
class FileBrowserWindow {
public:
FileBrowserWindow(FileTransferManager& transfers, std::string remote,
bool controller, int language);
void Show();
void SetPeerName(const std::string& name);
void UpdateActionLabels(int language);
// False means this connection ended; the UI owner should discard the window.
bool Tick();
bool Visible() const { return window_->window().is_visible(); }
bool PresentsTransfers() const { return Visible() && !window_->window().is_minimized(); }
bool PresentsResult(uint32_t id, FileTransferManager::FileTransferState::FileTransferStatus status) const;
private:
enum class SortColumn { Name, Modified, Size };
struct Entry {
ui::BrowserEntry row;
std::string name_key, modified;
uint64_t bytes = 0;
std::optional<int64_t> modified_ms;
};
struct Pane {
std::string path, parent, home, filter, error, loading;
bool loaded = false, busy = false, show_hidden = false;
uint32_t request = 0;
SortColumn sort_column = SortColumn::Name;
bool sort_descending = false;
std::vector<Entry> entries;
std::set<std::string> selected, expanded;
std::map<std::string, std::vector<ui::BrowserEntry>> directories;
std::shared_ptr<slint::VectorModel<ui::BrowserEntry>> rows =
std::make_shared<slint::VectorModel<ui::BrowserEntry>>();
std::shared_ptr<slint::VectorModel<ui::BrowserTreeEntry>> tree =
std::make_shared<slint::VectorModel<ui::BrowserTreeEntry>>();
};
struct Request { bool remote, tree; std::string path; };
struct DragPayload {
std::weak_ptr<int> owner;
uint64_t epoch = 0;
bool remote = false, consumed = false;
uint32_t source_request = 0, target_request = 0;
std::string source_directory, target_directory;
std::shared_ptr<const std::vector<std::string>> paths;
};
void Navigate(bool remote, const std::string& path, bool tree = false);
void OpenDirectory(bool remote, const std::string& path);
bool CenterOnScreen();
void ShowWhenReady();
void UpdatePane(bool remote);
void SortEntries(Pane& pane);
void UpdateTree(bool remote);
bool TransferReady() const;
bool SessionConnected() const;
void UpdateAvailability();
void SelectSource(bool remote);
slint::DataTransfer MakeDragData(bool remote, const std::string& path) const;
std::shared_ptr<DragPayload> DropPayload(bool remote, const slint::DataTransfer& data) const;
void CancelDrag();
bool QueueTransfer(bool download, const std::vector<std::string>& paths,
const std::string& destination);
void Transfer(bool download);
void Notice(const std::string& message, bool error);
std::string Error(const std::string& code) const;
std::string Tr(const char* chinese, const char* english) const;
FileTransferManager& transfers_;
std::string remote_;
uint64_t connection_generation_;
bool chinese_;
bool disconnected_ = false;
bool show_pending_ = false;
slint::ComponentHandle<ui::FileBrowserWindow> window_ = ui::FileBrowserWindow::create();
Pane panes_[2];
std::shared_ptr<int> drag_owner_ = std::make_shared<int>(0);
std::shared_ptr<const std::vector<std::string>> drag_selection_[2];
uint64_t drag_epoch_ = 0;
std::map<uint32_t, Request> requests_;
std::set<uint32_t> dismissed_, completed_;
std::map<uint32_t, FileTransferManager::FileTransferState::FileTransferInfo> history_;
std::shared_ptr<slint::VectorModel<ui::BrowserTask>> tasks_ =
std::make_shared<slint::VectorModel<ui::BrowserTask>>();
std::chrono::steady_clock::time_point next_sync_{}, refresh_at_{};
};
} // namespace crossdesk
#endif
@@ -10,10 +10,31 @@
namespace crossdesk { namespace crossdesk {
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {} FileTransferManager::FileTransferManager(GuiRuntime& owner)
: owner_(owner), browser_(
[this](const auto& remote, const auto& message) { return SendBrowser(remote, message); },
[this](const auto& remote, const auto& path, uint32_t id) {
if (!BrowserConnected(remote)) return false;
auto props = owner_.FindRemoteSession(remote);
return ProcessSelectedFile(path, props, owner_.file_label_, remote, id);
},
[this](const auto& remote, uint32_t id, const auto& directory) {
if (!BrowserConnected(remote)) return false;
std::shared_ptr<PeerEventHandler> events;
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
const auto receiver = ReceiverFor(events.get(), remote);
return receiver && receiver->ReserveDirectory(id, directory);
},
[this](const auto& remote, uint32_t id) {
if (!BrowserConnected(remote)) return false;
for (const auto& info : BrowserTransfers(remote))
if (!info.receiving && info.file_id == id) return CancelSend(id);
return false;
}) {}
FileTransferManager::~FileTransferManager() { Shutdown(); } FileTransferManager::~FileTransferManager() { Shutdown(); }
void FileTransferManager::Shutdown() { void FileTransferManager::Shutdown() {
browser_.Stop();
std::vector<std::future<void>> workers; std::vector<std::future<void>> workers;
{ {
std::lock_guard lock(workers_mutex_); std::lock_guard lock(workers_mutex_);
@@ -87,7 +108,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
workers_.push_back(std::async(std::launch::async, [this, props, file] { workers_.push_back(std::async(std::launch::async, [this, props, file] {
auto& state = state_for(props); auto& state = state_for(props);
const auto id = file.file_id; const auto id = file.file_id;
PeerPtr* peer; MiniRtcPeerPtr* peer;
std::shared_ptr<PeerEventHandler> events; std::shared_ptr<PeerEventHandler> events;
{ {
std::shared_lock lock(owner_.remote_sessions_mutex_); std::shared_lock lock(owner_.remote_sessions_mutex_);
@@ -99,12 +120,13 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = events ? events->DirectPeer() : nullptr; peer = events ? events->DirectPeer() : nullptr;
} }
auto session = std::make_shared<file_transfer::Session>( auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) { [this, peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1; if (!peer || !events || (props && props->closing_) ||
(!props && !owner_.IsAuthorizedController(file.remote_id))) return -1;
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return -1; if (!callback || !events->DataAllowed()) return -1;
const auto wire_id = events->WireId(file.remote_id); const auto wire_id = events->WireId(file.remote_id);
return SendReliableDataFrameToPeer( return MiniRtcSendReliableDataFrameToPeer(
peer, data, size, file.file_label.c_str(), peer, data, size, file.file_label.c_str(),
wire_id.data(), wire_id.size()); wire_id.data(), wire_id.size());
}, file_transfer::Session::Progress{}); }, file_transfer::Session::Progress{});
@@ -146,6 +168,18 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
uint64_t previous = UINT64_MAX; uint64_t previous = UINT64_MAX;
auto progress = file_transfer::SendState{id, 0, 0, auto progress = file_transfer::SendState{id, 0, 0,
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed}; cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
if (cancelled && peer && events && (!props || !props->closing_) &&
(props || owner_.IsAuthorizedController(file.remote_id))) {
auto callback = events->EnterCallback();
if (callback && events->DataAllowed()) {
// A queued browser file already reserved a destination on the peer.
// Release that reservation even though no data chunk was ever sent.
const auto packet = EncodeFileCancel(id, 0);
const auto wire_id = events->WireId(file.remote_id);
MiniRtcSendReliableDataFrameToPeer(peer, packet.data(), packet.size(),
file.file_label.c_str(), wire_id.data(), wire_id.size());
}
}
if (started) { if (started) {
do { do {
{ {
@@ -287,6 +321,7 @@ void FileTransferManager::UpdateReceiveProgress(
info.file_id = file_transfer::Session::NextFileId(); info.file_id = file_transfer::Session::NextFileId();
info.remote_id = remote_id; info.remote_id = remote_id;
info.receiving = true; info.receiving = true;
info.wire_file_id = progress.id;
receive_rate_samples_.emplace(info.file_id, receive_rate_samples_.emplace(info.file_id,
ReceiveRateSample{std::chrono::steady_clock::now(), 0}); ReceiveRateSample{std::chrono::steady_clock::now(), 0});
found = notifications.emplace(progress.id, files.size()).first; found = notifications.emplace(progress.id, files.size()).first;
@@ -309,10 +344,10 @@ void FileTransferManager::UpdateReceiveProgress(
} }
} }
void FileTransferManager::Receive(const char* data, size_t size, std::shared_ptr<FileReceiver> FileTransferManager::ReceiverFor(
PeerEventHandler* handler, const std::string& remote) { PeerEventHandler* handler, const std::string& remote) {
const auto props = owner_.FindRemoteSession(remote); const auto props = owner_.FindRemoteSession(remote);
PeerPtr* peer; MiniRtcPeerPtr* peer;
std::shared_ptr<PeerEventHandler> events; std::shared_ptr<PeerEventHandler> events;
{ {
std::shared_lock lock(owner_.remote_sessions_mutex_); std::shared_lock lock(owner_.remote_sessions_mutex_);
@@ -323,14 +358,14 @@ void FileTransferManager::Receive(const char* data, size_t size,
events = owner_.ControllerEvents(remote); events = owner_.ControllerEvents(remote);
peer = events ? events->DirectPeer() : nullptr; peer = events ? events->DirectPeer() : nullptr;
} }
if (!peer || !events || events.get() != handler || (props && props->closing_)) return; if (!peer || !events || events.get() != handler || (props && props->closing_)) return {};
std::shared_ptr<FileReceiver> receiver; std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory; std::filesystem::path default_directory;
{ {
std::lock_guard lock(sessions_mutex_); std::lock_guard lock(sessions_mutex_);
const auto closed = disconnected_.find(handler); const auto closed = disconnected_.find(handler);
if (stopping_ || (closed != disconnected_.end() && if (stopping_ || (closed != disconnected_.end() &&
(closed->second.count("") || closed->second.count(remote)))) return; (closed->second.count("") || closed->second.count(remote)))) return {};
auto& entry = receivers_[handler][remote]; auto& entry = receivers_[handler][remote];
if (!entry.session) { if (!entry.session) {
entry.session = std::make_shared<FileReceiver>(); entry.handler = events; entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
@@ -341,12 +376,13 @@ void FileTransferManager::Receive(const char* data, size_t size,
(const file_transfer::ReceiveState& progress) mutable { (const file_transfer::ReceiveState& progress) mutable {
UpdateReceiveProgress(progress, notifications, remote); UpdateReceiveProgress(progress, notifications, remote);
}); });
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) { entry.session->SetOnSendAck([this, peer, events, remote, props](const FileTransferAck& ack) {
if (!props && !owner_.IsAuthorizedController(remote)) return -1;
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return -1; if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack); const auto bytes = EncodeFileTransferAck(ack);
const auto wire_id = events->WireId(remote); const auto wire_id = events->WireId(remote);
return SendReliableDataFrameToPeer( return MiniRtcSendReliableDataFrameToPeer(
peer, bytes.data(), bytes.size(), kFileFeedbackStream, peer, bytes.data(), bytes.size(), kFileFeedbackStream,
wire_id.data(), wire_id.size()); wire_id.data(), wire_id.size());
}); });
@@ -356,7 +392,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
} }
const auto configured = owner_.config_center_->GetFileTransferSavePath(); const auto configured = owner_.config_center_->GetFileTransferSavePath();
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured)); receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
receiver->OnData(data, size); return receiver;
}
void FileTransferManager::Receive(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
if (auto receiver = ReceiverFor(handler, remote)) receiver->OnData(data, size);
} }
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) { void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
@@ -369,6 +410,17 @@ void FileTransferManager::Connected(PeerEventHandler* handler, const std::string
} }
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) { void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
{
std::lock_guard lock(browser_mutex_);
for (auto it = browser_peers_.begin(); it != browser_peers_.end();) {
if (it->second.get() == handler && (remote.empty() || it->first == remote)) {
browser_.Disconnect(it->first);
browser_opened_.erase(it->first);
browser_supported_.erase(it->first);
it = browser_peers_.erase(it);
} else ++it;
}
}
std::lock_guard lock(sessions_mutex_); std::lock_guard lock(sessions_mutex_);
disconnected_[handler].insert(remote); disconnected_[handler].insert(remote);
for (auto& [id, sender] : senders_) for (auto& [id, sender] : senders_)
@@ -409,4 +461,129 @@ void FileTransferManager::Poll() {
} }
} }
void FileTransferManager::SetBrowserSupport(PeerEventHandler* handler,
const std::string& remote, bool supported) {
const auto props = owner_.FindRemoteSession(remote);
const auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || events.get() != handler || !events->DataAllowed()) return;
std::lock_guard lock(browser_mutex_);
if (const auto previous = browser_peers_.find(remote);
previous != browser_peers_.end() && previous->second != events) {
browser_.Disconnect(remote);
browser_opened_.erase(remote);
}
browser_peers_[remote] = events;
if (supported) browser_supported_.insert(remote);
else browser_supported_.erase(remote);
}
bool FileTransferManager::BrowserSupported(const std::string& remote) {
std::lock_guard lock(browser_mutex_);
return browser_supported_.count(remote) != 0;
}
bool FileTransferManager::OpenBrowser(const std::string& remote) {
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return false;
std::lock_guard lock(browser_mutex_);
if (const auto previous = browser_peers_.find(remote);
previous != browser_peers_.end() && previous->second != events) {
browser_.Disconnect(remote);
browser_opened_.erase(remote);
browser_supported_.erase(remote);
}
browser_peers_[remote] = events;
// Opening the legacy file picker authorizes a send, not disk browsing.
if (browser_supported_.count(remote)) browser_opened_.insert(remote);
return true;
}
void FileTransferManager::CancelBrowserTransfer(const std::string& remote, uint32_t file_id) {
for (const auto& info : BrowserTransfers(remote)) {
if (info.file_id != file_id) continue;
if (info.receiving) browser_.CancelTransfer(remote, info.wire_file_id);
else CancelSend(file_id);
return;
}
}
bool FileTransferManager::BrowserConnected(const std::string& remote) {
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return false;
std::lock_guard lock(browser_mutex_);
const auto it = browser_peers_.find(remote);
return it != browser_peers_.end() && it->second == events;
}
bool FileTransferManager::SendBrowser(const std::string& remote, const FileBrowser::Json& message) {
if (!BrowserConnected(remote)) return false;
auto props = owner_.FindRemoteSession(remote);
std::shared_ptr<PeerEventHandler> events;
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
if (!events) return false;
auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return false;
MiniRtcPeerPtr* peer = events->IsDirect() ? events->DirectPeer() : props ? props->peer_ : owner_.peer_;
const auto data = message.dump(-1, ' ', false, FileBrowser::Json::error_handler_t::replace);
if (!peer || data.size() > 60000) return false;
const auto wire_id = events->WireId(remote);
return MiniRtcSendReliableDataFrameToPeer(peer, data.data(), data.size(),
owner_.control_data_label_.c_str(), wire_id.data(), wire_id.size()) == 0;
}
bool FileTransferManager::HandleBrowser(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
if (!data || size > 60000 || std::string_view(data, size).find("file_browser") == std::string_view::npos)
return false;
auto message = FileBrowser::Json::parse(data, data + size, nullptr, false);
if (!message.is_object() || message.value("type", FileBrowser::Json{}) != "file_browser") return false;
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (owner_.unattended_host_ || !events || events.get() != handler || !events->DataAllowed() ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return true;
bool opened;
{
std::lock_guard lock(browser_mutex_);
browser_peers_[remote] = events;
if (message.value("op", FileBrowser::Json{}) == "presentation") {
// Older browser builds synchronized window visibility. Ignore that
// optional event: progress now belongs exclusively to the local sender.
return true;
}
opened = browser_opened_.count(remote);
}
// A controlled computer cannot browse the controller's disk until that
// controller explicitly opens this peer's file manager in this connection.
const auto op = message.value("op", FileBrowser::Json{});
if (props && !opened && op != "reply") {
if (message.contains("id")) SendBrowser(remote,
{{"op", "reply"}, {"id", message["id"]}, {"type", "file_browser"},
{"version", 1}, {"ok", false}, {"error", "browser_not_open"}});
return true;
}
browser_.Handle(remote, std::move(message));
return true;
}
std::vector<FileTransferManager::FileTransferState::FileTransferInfo>
FileTransferManager::BrowserTransfers(const std::string& remote) {
std::vector<FileTransferState::FileTransferInfo> result;
const auto collect = [&](auto& state) {
std::lock_guard lock(state.file_transfer_list_mutex_);
for (const auto& info : state.file_transfer_list_)
if (info.remote_id == remote) result.push_back(info);
};
collect(global_state_); collect(received_state_);
if (auto props = owner_.FindRemoteSession(remote)) collect(props->file_transfer_);
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { return a.file_id > b.file_id; });
return result;
}
} // namespace crossdesk } // namespace crossdesk
@@ -13,6 +13,7 @@
#include <vector> #include <vector>
#include "file_transfer.h" #include "file_transfer.h"
#include <file_browser.h>
#include "runtime/remote_session.h" #include "runtime/remote_session.h"
namespace crossdesk { namespace crossdesk {
@@ -40,6 +41,15 @@ class FileTransferManager {
bool CancelSend(uint32_t file_id); bool CancelSend(uint32_t file_id);
void Poll(); void Poll();
void Shutdown(); void Shutdown();
FileBrowser& browser() { return browser_; }
bool OpenBrowser(const std::string& remote);
void SetBrowserSupport(PeerEventHandler* handler, const std::string& remote, bool supported);
bool BrowserSupported(const std::string& remote);
bool BrowserConnected(const std::string& remote);
bool HandleBrowser(const char* data, size_t size, PeerEventHandler* handler,
const std::string& remote);
std::vector<FileTransferState::FileTransferInfo> BrowserTransfers(const std::string& remote);
void CancelBrowserTransfer(const std::string& remote, uint32_t file_id);
private: private:
struct Sender { struct Sender {
@@ -53,6 +63,8 @@ class FileTransferManager {
std::filesystem::path default_directory; std::filesystem::path default_directory;
}; };
void ProcessQueue(std::shared_ptr<RemoteSession> props); void ProcessQueue(std::shared_ptr<RemoteSession> props);
std::shared_ptr<FileReceiver> ReceiverFor(PeerEventHandler* handler, const std::string& remote);
bool SendBrowser(const std::string& remote, const FileBrowser::Json& message);
void UpdateProgress(FileTransferState& state, void UpdateProgress(FileTransferState& state,
const file_transfer::SendState& progress, const file_transfer::SendState& progress,
std::chrono::steady_clock::time_point now); std::chrono::steady_clock::time_point now);
@@ -76,6 +88,11 @@ class FileTransferManager {
FileTransferState received_state_; FileTransferState received_state_;
// Guarded by received_state_.file_transfer_list_mutex_. // Guarded by received_state_.file_transfer_list_mutex_.
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_; std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
std::mutex browser_mutex_;
std::map<std::string, std::shared_ptr<PeerEventHandler>> browser_peers_;
std::set<std::string> browser_opened_;
std::set<std::string> browser_supported_;
FileBrowser browser_;
}; };
} // namespace crossdesk } // namespace crossdesk
@@ -0,0 +1,26 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-05
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TRANSFER_NOTIFICATION_STATE_H_
#define _TRANSFER_NOTIFICATION_STATE_H_
namespace crossdesk::transfer_notification {
struct Presentation {
bool dismissed = false;
bool result_seen = false;
bool ShowNotification(bool browser_visible, bool result_presented) {
// Hiding an active transfer is temporary. A result already presented in
// the browser must not pop up again when that window closes.
result_seen = result_seen || result_presented;
return !dismissed && !result_seen && !browser_visible;
}
};
} // namespace crossdesk::transfer_notification
#endif
@@ -39,14 +39,14 @@ void RemoteInputDispatcher::Connect(const std::string& id) {
} }
RemoteInputDispatcher::Result RemoteInputDispatcher::Push( RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
const std::string& id, const RemoteAction& action) { const std::string& id, const RemoteAction& action, Completion completion) {
// HostInfo owns raw arrays; it must never enter this value-copy queue. // HostInfo owns raw arrays; it must never enter this value-copy queue.
if (!Accepts(action)) return Result::rejected; if (!Accepts(action)) return Result::rejected;
std::lock_guard lock(mutex_); std::lock_guard lock(mutex_);
if (stopping_ || !connected_.count(id)) return Result::rejected; if (stopping_ || !connected_.count(id)) return Result::rejected;
if (IsMove(action) && !pending_.empty()) { if (!completion && IsMove(action) && !pending_.empty()) {
auto& last = pending_.back(); auto& last = pending_.back();
if (last.id == id && last.IsInput() && IsMove(*last.action)) { if (!last.completion && last.id == id && last.IsInput() && IsMove(*last.action)) {
last.action = action; last.action = action;
last.queued_at = Clock::now(); last.queued_at = Clock::now();
return Result::coalesced; return Result::coalesced;
@@ -56,7 +56,7 @@ RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
QueueReleaseLocked(id); QueueReleaseLocked(id);
return Result::overflow; return Result::overflow;
} }
pending_.push_back({id, action, Clock::now(), ItemKind::input}); pending_.push_back({id, action, Clock::now(), ItemKind::input, 0, std::move(completion)});
++pending_counts_[id]; ++pending_counts_[id];
ready_.notify_one(); ready_.notify_one();
return Result::queued; return Result::queued;
@@ -328,6 +328,9 @@ void RemoteInputDispatcher::Run() {
!pending_counts_.count(item.id)) !pending_counts_.count(item.id))
logs_.erase(item.id); logs_.erase(item.id);
lock.unlock(); lock.unlock();
if (item.completion) {
try { item.completion(outcome); } catch (...) {}
}
if (report) { if (report) {
try { try {
report_(item.id, queued_ms, execution_ms, outcome); report_(item.id, queued_ms, execution_ms, outcome);
@@ -38,6 +38,7 @@ class RemoteInputDispatcher {
// Optional targeted adapter; legacy callers fall back to full Release. // Optional targeted adapter; legacy callers fall back to full Release.
using ReleaseMouse = using ReleaseMouse =
std::function<InputOutcome(const std::string&, unsigned)>; std::function<InputOutcome(const std::string&, unsigned)>;
using Completion = std::function<void(InputOutcome)>;
enum class Result { queued, coalesced, rejected, overflow }; enum class Result { queued, coalesced, rejected, overflow };
static constexpr size_t kMaxPendingPerController = 128; static constexpr size_t kMaxPendingPerController = 128;
@@ -47,7 +48,8 @@ class RemoteInputDispatcher {
~RemoteInputDispatcher(); ~RemoteInputDispatcher();
static bool Accepts(const RemoteAction& action); static bool Accepts(const RemoteAction& action);
void Connect(const std::string& id); void Connect(const std::string& id);
Result Push(const std::string& id, const RemoteAction& action); Result Push(const std::string& id, const RemoteAction& action,
Completion completion = {});
void Disconnect(const std::string& id); void Disconnect(const std::string& id);
// Scheduled recovery is not idle work. Check HasPendingReleases() as well, // Scheduled recovery is not idle work. Check HasPendingReleases() as well,
@@ -68,6 +70,7 @@ class RemoteInputDispatcher {
// Zero requests full controller cleanup; otherwise only these mouse // Zero requests full controller cleanup; otherwise only these mouse
// buttons. // buttons.
unsigned mouse_mask = 0; unsigned mouse_mask = 0;
Completion completion;
bool IsReset() const { return kind == ItemKind::reset_display; } bool IsReset() const { return kind == ItemKind::reset_display; }
bool IsInput() const { return kind == ItemKind::input; } bool IsInput() const { return kind == ItemKind::input; }
bool IsCleanup() const { return kind == ItemKind::cleanup; } bool IsCleanup() const { return kind == ItemKind::cleanup; }
@@ -593,14 +593,17 @@ bool SettingsManager::StagePendingPasswordChange(
} }
std::string SettingsManager::PendingPasswordChangeIdentity( std::string SettingsManager::PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host, int server_port) const { bool self_hosted, const std::string &server_host, int server_port,
std::string* request_id) const {
std::lock_guard<std::mutex> lock(cache_mutex_); std::lock_guard<std::mutex> lock(cache_mutex_);
if (request_id) request_id->clear();
if (cache_v3_.pending_identity[0] == '\0' || if (cache_v3_.pending_identity[0] == '\0' ||
cache_v3_.pending_self_hosted != self_hosted || cache_v3_.pending_self_hosted != self_hosted ||
cache_v3_.pending_server_port != server_port || cache_v3_.pending_server_port != server_port ||
server_host != cache_v3_.pending_server_host) { server_host != cache_v3_.pending_server_host) {
return {}; return {};
} }
if (request_id) *request_id = cache_v3_.pending_request_id;
return cache_v3_.pending_identity; return cache_v3_.pending_identity;
} }
@@ -51,7 +51,7 @@ public:
const std::string &request_id); const std::string &request_id);
std::string PendingPasswordChangeIdentity( std::string PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host, bool self_hosted, const std::string &server_host,
int server_port) const; int server_port, std::string* request_id = nullptr) const;
bool PromotePendingPasswordChange(); bool PromotePendingPasswordChange();
bool ClearPendingPasswordChange(); bool ClearPendingPasswordChange();
@@ -88,6 +88,11 @@ struct SlintVideoPresenter::Impl {
VideoRenderSize gl_pending_size; VideoRenderSize gl_pending_size;
bool gl_pending_dirty = false; bool gl_pending_dirty = false;
VideoLatencyFrame gl_pending_timing; VideoLatencyFrame gl_pending_timing;
bool gl_use_pixel_buffers = false;
VideoRenderer::RenderOutcome gl_last_outcome;
std::string gl_rendered_stream;
std::string gl_requested_stream;
uint64_t gl_requested_sequence = 0;
#endif #endif
SurfaceState CurrentState() const { SurfaceState CurrentState() const {
@@ -125,6 +130,13 @@ struct SlintVideoPresenter::Impl {
auto* opengl_renderer = auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&renderer); dynamic_cast<OpenGlVideoRenderer*>(&renderer);
if (state == slint::RenderingState::RenderingSetup) { if (state == slint::RenderingState::RenderingSetup) {
gl_use_pixel_buffers = false;
gl_last_outcome = {};
gl_rendered_stream.clear();
gl_requested_stream.clear();
gl_requested_sequence = 0;
displayed_frame_sequence.clear();
seeded_frame_sequence.clear();
GLuint texture = 0; GLuint texture = 0;
glGenTextures(1, &texture); glGenTextures(1, &texture);
if (texture == 0) { if (texture == 0) {
@@ -165,9 +177,28 @@ struct SlintVideoPresenter::Impl {
rounded_window ? static_cast<int>(std::lround( rounded_window ? static_cast<int>(std::lround(
kStreamWindowCornerRadius * scale_factor)) kStreamWindowCornerRadius * scale_factor))
: 0; : 0;
opengl_renderer->RenderLatest( const auto outcome = opengl_renderer->RenderLatest(
surface.selected_stream, static_cast<int>(window_size.width), surface.selected_stream, static_cast<int>(window_size.width),
static_cast<int>(window_size.height), top_inset, corner_radius); static_cast<int>(window_size.height), top_inset, corner_radius);
if (outcome.result == VideoRenderer::RenderResult::idle) {
return;
}
if (outcome.result == VideoRenderer::RenderResult::failed) {
// RenderLatest stops native submissions. Apply the UI fallback
// on the next UI tick, outside Slint's rendering callback.
gl_use_pixel_buffers = true;
LOG_WARN("Switching Slint video presentation to pixel buffers");
}
const bool changed = gl_rendered_stream != surface.selected_stream ||
gl_last_outcome.result != outcome.result ||
gl_last_outcome.sequence != outcome.sequence ||
gl_last_outcome.width != outcome.width ||
gl_last_outcome.height != outcome.height;
gl_last_outcome = outcome;
gl_rendered_stream = surface.selected_stream;
if (changed) {
MarkDirty();
}
return; return;
} }
@@ -211,6 +242,8 @@ struct SlintVideoPresenter::Impl {
gl_pending_size = {}; gl_pending_size = {};
gl_pending_dirty = false; gl_pending_dirty = false;
gl_pending_frame.clear(); gl_pending_frame.clear();
gl_last_outcome = {};
gl_rendered_stream.clear();
} }
}); });
@@ -239,6 +272,7 @@ void SlintVideoPresenter::PrepareWindow(
impl_->attachment_attempts = kMetalAttachmentAttemptLimit; impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
impl_->skip_attachment_once = true; impl_->skip_attachment_once = true;
#elif !defined(__OHOS__) #elif !defined(__OHOS__)
stream->set_native_video_enabled(false);
impl_->ConfigureOpenGlNotifier(); impl_->ConfigureOpenGlNotifier();
#endif #endif
} }
@@ -313,7 +347,12 @@ bool SlintVideoPresenter::SelectStream(std::string remote_id) {
} }
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present( SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
const Frame& frame) { const Frame& incoming_frame) {
#if !defined(__APPLE__) && !defined(__OHOS__)
Frame frame = incoming_frame;
#else
const Frame& frame = incoming_frame;
#endif
PresentResult result; PresentResult result;
if (!impl_->stream || frame.remote_id.empty()) { if (!impl_->stream || frame.remote_id.empty()) {
return result; return result;
@@ -355,25 +394,54 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
auto* opengl_renderer = auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer); dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
if (opengl_renderer && opengl_renderer->IsReady()) { if (opengl_renderer && opengl_renderer->IsReady()) {
opengl_renderer->SetSelectedStream(frame.remote_id); bool needs_redraw = opengl_renderer->SetSelectedStream(frame.remote_id);
if (!(*impl_->stream)->get_native_video_enabled()) { if (!(*impl_->stream)->get_native_video_enabled()) {
(*impl_->stream)->set_native_video_enabled(true); (*impl_->stream)->set_native_video_enabled(true);
needs_redraw = true;
} }
VideoRenderer::RenderOutcome outcome;
{
std::lock_guard lock(impl_->gl_mutex);
if (impl_->gl_rendered_stream == frame.remote_id) {
outcome = impl_->gl_last_outcome;
}
}
const bool has_frame = const bool has_frame =
frame.width > 0 && frame.height > 0 && frame.sequence > 0; outcome.result == VideoRenderer::RenderResult::rendered;
(*impl_->stream)->set_has_frame(has_frame); (*impl_->stream)->set_has_frame(has_frame);
if (has_frame) { if (has_frame) {
(*impl_->stream)->set_receiving_text(""); (*impl_->stream)->set_receiving_text("");
result.width = frame.width; result.width = outcome.width;
result.height = frame.height; result.height = outcome.height;
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence); result.new_frame = impl_->MarkPresented(frame.remote_id, outcome.sequence);
} }
// A rendering acknowledgement also schedules Present(). Do not draw the
// same video again solely to acknowledge it, especially for low-FPS peers.
if (needs_redraw || impl_->gl_requested_stream != frame.remote_id ||
impl_->gl_requested_sequence != frame.sequence) {
impl_->gl_requested_stream = frame.remote_id;
impl_->gl_requested_sequence = frame.sequence;
(*impl_->stream)->window().request_redraw(); (*impl_->stream)->window().request_redraw();
}
return result; return result;
} }
if ((*impl_->stream)->get_native_video_enabled()) { if ((*impl_->stream)->get_native_video_enabled()) {
(*impl_->stream)->set_native_video_enabled(false); (*impl_->stream)->set_native_video_enabled(false);
(*impl_->stream)->set_has_frame(false);
(*impl_->stream)->set_frame(slint::Image{});
impl_->displayed_frame_sequence.clear();
impl_->seeded_frame_sequence.clear();
}
if (!frame.nv12 && impl_->gl_use_pixel_buffers) {
// Native submissions intentionally release the session's CPU buffers.
// Recover the retained frame so even a static remote desktop can switch
// immediately; subsequent decode callbacks will supply CPU frames.
auto pixels = std::make_shared<std::vector<unsigned char>>();
if (impl_->renderer.CopyLatestNv12(frame.remote_id, pixels.get(),
&frame.width, &frame.height)) {
frame.nv12 = std::move(pixels);
}
} }
#endif #endif
@@ -423,7 +491,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
uint32_t texture_id = 0; uint32_t texture_id = 0;
{ {
std::lock_guard lock(impl_->gl_mutex); std::lock_guard lock(impl_->gl_mutex);
texture_id = impl_->gl_texture; texture_id = impl_->gl_use_pixel_buffers ? 0 : impl_->gl_texture;
} }
if (texture_id != 0) { if (texture_id != 0) {
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) * impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
@@ -22,7 +22,7 @@ namespace crossdesk {
class VideoLatencyStats; class VideoLatencyStats;
// Travels with the exact queued pixels, including native frames. Its clock is // Travels with the exact queued pixels, including native frames. Its clock is
// translated once at the receive callback; rendering needs no live PeerPtr. // translated once at the receive callback; rendering needs no live MiniRtcPeerPtr.
struct VideoLatencyFrame { struct VideoLatencyFrame {
using Clock = std::chrono::steady_clock; using Clock = std::chrono::steady_clock;
std::shared_ptr<VideoLatencyStats> stats; std::shared_ptr<VideoLatencyStats> stats;
@@ -8,7 +8,7 @@
#include <set> #include <set>
#include <system_error> #include <system_error>
#include "signal_server_address.h" #include <minirtc.h>
#ifdef _WIN32 #ifdef _WIN32
#include <windows.h> #include <windows.h>
@@ -53,12 +53,13 @@ void AnnouncementInbox::Configure(std::string host, int port,
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty()) if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
return; return;
auto address = minirtc::ParseSignalServerAddress(host); MiniRtcSignalServerAddress address{};
if (!address) return; if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return;
for (auto& ch : address->host) std::string normalized_host(address.host);
for (auto& ch : normalized_host)
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A'; if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
if (address->host.back() == '.') address->host.pop_back(); if (normalized_host.back() == '.') normalized_host.pop_back();
host = address->Address(); host = normalized_host + address.target;
const auto scope = json::array({host, port, device_id}).dump(); const auto scope = json::array({host, port, device_id}).dump();
if (scope_ == scope) return; if (scope_ == scope) return;
scope_ = scope; scope_ = scope;
@@ -0,0 +1,120 @@
#include "runtime/gui_runtime.h"
#include <stream_names.h>
#include "screen_lock.h"
namespace crossdesk {
void GuiRuntime::HandleAutomation(const std::string& remote_id,
const nlohmann::json& request) {
using nlohmann::json;
if (request.value("version", json{}) != 1 || !request.contains("id") ||
!request["id"].is_number_unsigned() || request["id"].get<uint64_t>() == 0)
return;
const auto source = ControllerEvents(remote_id);
if (!source || !source->DataAllowed()) return;
const auto id = request["id"].get<uint64_t>();
const auto reply = [this, remote_id, id, source](bool ok, json result) {
const auto current = ControllerEvents(remote_id);
if (current != source || !current->DataAllowed()) return;
const auto message = json{{"type", "automation_result"}, {"version", 1},
{"id", id}, {"ok", ok}, {ok ? "result" : "error", std::move(result)}}.dump();
SendControllerData(remote_id, message.data(), message.size(), kControlStream);
};
try {
const auto method = request.value("method", std::string{});
if (method == "capabilities") {
json capabilities{{"input_ack", true}, {"clipboard", true}, {"files", true}};
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
capabilities["system"] = true;
capabilities["desktop"] = true;
#endif
reply(true, std::move(capabilities));
} else if (method == "input") {
RemoteAction action{};
if (!request.contains("arguments") ||
!action.from_json(request["arguments"].dump())) {
reply(false, "INVALID_ARGUMENT");
return;
}
const auto result = remote_input_.Push(remote_id, action,
[reply](InputOutcome outcome) {
reply(true, {{"state", InputOutcomeName(outcome)}});
});
FreeRemoteAction(action);
if (result == RemoteInputDispatcher::Result::rejected ||
result == RemoteInputDispatcher::Result::overflow)
reply(false, "INPUT_REJECTED");
} else if (method == "desktop") {
const auto arguments = request.at("arguments");
if (!arguments.is_object()) { reply(false, "INVALID_ARGUMENT"); return; }
std::lock_guard lock(automation_mutex_);
if (automation_closing_ || automation_tasks_.size() >= 32) { reply(false, "BUSY"); return; }
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
automation_tasks_.push_back([this, source, remote_id, arguments, reply, deadline] {
if (std::chrono::steady_clock::now() > deadline) { reply(false, "REQUEST_EXPIRED"); return; }
if (ControllerEvents(remote_id) != source || !IsAuthorizedController(remote_id)) return;
try { reply(true, desktop_automation_.Execute(remote_id, arguments)); }
catch (const std::exception& error) {
const std::string code = error.what();
reply(false, !code.empty() && code.size() <= 64 && code.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ_") == std::string::npos
? code : "UI_AUTOMATION_FAILED");
}
});
} else if (method == "system") {
const auto operation = request.at("arguments").at("operation").get<std::string>();
if (operation != "status" && operation != "lock" && operation != "send_sas") {
reply(false, "INVALID_ARGUMENT");
return;
}
std::lock_guard lock(automation_mutex_);
if (automation_closing_ || automation_tasks_.size() >= 32) { reply(false, "BUSY"); return; }
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
automation_tasks_.push_back([this, source, remote_id, operation, reply, deadline] {
if (std::chrono::steady_clock::now() > deadline) { reply(false, "REQUEST_EXPIRED"); return; }
if (ControllerEvents(remote_id) != source || !IsAuthorizedController(remote_id)) return;
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (operation == "status") {
const auto state = QueryScreenLock();
json result{{"lock_state", state == ScreenLockState::locked ? "locked"
: state == ScreenLockState::unlocked ? "unlocked" : "unknown"}};
#ifdef _WIN32
result["interactive_stage"] = WindowsInputStage();
result["service_available"] = local_service_available_.load();
result["send_sas_available"] = local_service_available_.load();
#else
result["send_sas_available"] = false;
#endif
reply(true, std::move(result));
} else if (operation == "lock") {
reply(true, {{"accepted", LockScreen()}});
} else {
#ifdef _WIN32
if (!local_service_available_.load()) { reply(false, "SERVICE_UNAVAILABLE"); return; }
pending_windows_service_sas_.store(true);
reply(true, {{"accepted", true}, {"state", "queued"}});
#else
reply(false, "UNSUPPORTED_OPERATION");
#endif
}
#else
reply(false, "UNSUPPORTED_OPERATION");
#endif
});
} else {
reply(false, "UNSUPPORTED_OPERATION");
}
} catch (...) {
reply(false, "INVALID_ARGUMENT");
}
}
void GuiRuntime::ProcessAutomationRequests() {
std::function<void()> task;
{
std::lock_guard lock(automation_mutex_);
if (automation_tasks_.empty() || automation_closing_) return;
task = std::move(automation_tasks_.front());
automation_tasks_.pop_front();
}
try { task(); } catch (...) {}
}
} // namespace crossdesk
@@ -5,7 +5,8 @@
#include <mutex> #include <mutex>
#include <string_view> #include <string_view>
#include "join_failure.h" #include <minirtc.h>
#include <nlohmann/json.hpp>
namespace crossdesk { namespace crossdesk {
@@ -18,7 +19,7 @@ class ConnectionAuthState {
bool password_required = false; bool password_required = false;
bool validating = false; bool validating = false;
bool incorrect_password = false; bool incorrect_password = false;
minirtc::JoinFailureKind error = minirtc::JoinFailureKind::Unknown; MiniRtcJoinFailureKind error = MiniRtcJoinFailureKind::Unknown;
int retry_after = 0; int retry_after = 0;
}; };
@@ -34,15 +35,16 @@ class ConnectionAuthState {
void Rejected(const nlohmann::json& reply, void Rejected(const nlohmann::json& reply,
Clock::time_point now = Clock::now()) { Clock::time_point now = Clock::now()) {
const auto failure = minirtc::ParseJoinFailure(reply); const auto message = reply.dump();
const auto failure = MiniRtcParseJoinFailure(message.data(), message.size());
std::lock_guard lock(mutex_); std::lock_guard lock(mutex_);
state_ = {}; state_ = {};
state_.error = failure.kind; state_.error = failure.kind;
state_.incorrect_password = state_.incorrect_password =
failure.kind == minirtc::JoinFailureKind::AuthenticationFailed; failure.kind == MiniRtcJoinFailureKind::AuthenticationFailed;
state_.password_required = state_.password_required =
state_.incorrect_password || state_.incorrect_password ||
failure.kind == minirtc::JoinFailureKind::PasswordRequired; failure.kind == MiniRtcJoinFailureKind::PasswordRequired;
retry_at_ = now + std::chrono::seconds(failure.retry_after); retry_at_ = now + std::chrono::seconds(failure.retry_after);
} }
@@ -32,8 +32,8 @@ bool GuiRuntime::HasActiveSession() {
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
return std::any_of(connection_status_.begin(), connection_status_.end(), return std::any_of(connection_status_.begin(), connection_status_.end(),
[](const auto& entry) { [](const auto& entry) {
return entry.second == ConnectionStatus::Connecting || return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnecting ||
entry.second == ConnectionStatus::Connected; entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
}); });
} }
@@ -57,7 +57,7 @@ void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props)
action.type = ControlType::audio_capture; action.type = ControlType::audio_capture;
action.a = true; action.a = true;
const auto message = action.to_json(); const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(), MiniRtcSendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str()); props->control_data_label_.c_str());
} }
@@ -79,7 +79,7 @@ void GuiRuntime::HandleConnectionStatusChange() {
const auto message = const auto message =
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {}, BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
gallery_presence_ids_).dump(); gallery_presence_ids_).dump();
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) { if (MiniRtcSendSignalMessage(peer_, message.data(), message.size()) == 0) {
next_presence_refresh_at_ = now + kPresenceRefreshInterval; next_presence_refresh_at_ = now + kPresenceRefreshInterval;
} else { } else {
need_to_send_recent_connections_ = true; need_to_send_recent_connections_ = true;
@@ -207,7 +207,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
codec_status_controllers_.erase(remote_id); codec_status_controllers_.erase(remote_id);
privacy_sessions_.Disconnected(remote_id); privacy_sessions_.Disconnected(remote_id);
for (const auto& [id, status] : connection_status_) { for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) { if (status != MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
continue; continue;
} }
has_connected_controller = true; has_connected_controller = true;
@@ -296,7 +296,7 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
gallery_presence_ids_) gallery_presence_ids_)
.dump(); .dump();
int ret = SendSignalMessage(peer_, s.data(), s.size()); int ret = MiniRtcSendSignalMessage(peer_, s.data(), s.size());
if (ret != 0) { if (ret != 0) {
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_); std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
pending_presence_probe_ = false; pending_presence_probe_ = false;
@@ -315,7 +315,7 @@ int GuiRuntime::ForceSignalReconnect() {
LOG_WARN("Signal reconnect requested with no connection peer"); LOG_WARN("Signal reconnect requested with no connection peer");
return -1; return -1;
} }
return ReconnectSignal(peer_); return MiniRtcReconnectSignal(peer_);
} }
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) { void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
@@ -332,7 +332,7 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
} }
props->connection_established_ = false; props->connection_established_ = false;
props->streaming_ = false; props->streaming_ = false;
props->connection_status_.store(ConnectionStatus::Closed); props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionClosed);
std::shared_ptr<std::vector<unsigned char>> frame_snapshot; std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
int video_width = 0; int video_width = 0;
int video_height = 0; int video_height = 0;
@@ -365,7 +365,7 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
video_frame_dirty_.store(true, std::memory_order_release); video_frame_dirty_.store(true, std::memory_order_release);
} }
PeerPtr* peer = nullptr; MiniRtcPeerPtr* peer = nullptr;
{ {
// Audio/clipboard senders hold a shared map lock while using this pointer. // Audio/clipboard senders hold a shared map lock while using this pointer.
std::unique_lock lock(remote_sessions_mutex_); std::unique_lock lock(remote_sessions_mutex_);
@@ -384,8 +384,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (peer && !props->lan_session_) { if (peer && !props->lan_session_) {
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_, LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
props->remote_id_); props->remote_id_);
LeaveConnection(peer, props->remote_id_.c_str()); MiniRtcLeaveConnection(peer, props->remote_id_.c_str());
DestroyPeer(&peer); MiniRtcDestroyPeer(&peer);
} }
if (thumbnail && props->was_connected_) { if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty() const char* nv12 = frame_snapshot && !frame_snapshot->empty()
@@ -430,7 +430,7 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) {
return; return;
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask( controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
[peer = peer_, transmission_id = std::string(client_id_), remote_id] { [peer = peer_, transmission_id = std::string(client_id_), remote_id] {
if (DisconnectPeerConnection(peer, transmission_id.c_str(), if (MiniRtcDisconnectPeerConnection(peer, transmission_id.c_str(),
remote_id.c_str()) != 0) { remote_id.c_str()) != 0) {
LOG_WARN("[{}] Could not queue controller disconnect notification", LOG_WARN("[{}] Could not queue controller disconnect notification",
remote_id); remote_id);
@@ -443,6 +443,12 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset(); announcements_.Reset();
return; return;
} }
{
std::lock_guard lock(controller_trust_mutex_);
std::erase_if(controller_trust_, [this](const auto& item) {
return item.second.events == peer_events_.get();
});
}
peer_events_->StopAccepting(); peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get()); transfers_.Cancel(peer_events_.get());
announcements_.Reset(); announcements_.Reset();
@@ -456,8 +462,8 @@ void GuiRuntime::CloseConnectionPeer() {
HandleServerControllerDisconnected(id, "peer_closed"); HandleServerControllerDisconnected(id, "peer_closed");
signal_connected_ = false; signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false); device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed; signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
PeerPtr* peer; MiniRtcPeerPtr* peer;
{ {
std::unique_lock lock(remote_sessions_mutex_); std::unique_lock lock(remote_sessions_mutex_);
peer = std::exchange(peer_, nullptr); peer = std::exchange(peer_, nullptr);
@@ -468,8 +474,8 @@ void GuiRuntime::CloseConnectionPeer() {
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable { [peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
handler->Deactivate(); handler->Deactivate();
LOG_INFO("[{}] Background connection peer cleanup", id); LOG_INFO("[{}] Background connection peer cleanup", id);
LeaveConnection(peer, id.c_str()); MiniRtcLeaveConnection(peer, id.c_str());
DestroyPeer(&peer); MiniRtcDestroyPeer(&peer);
}); });
} }
@@ -667,9 +673,9 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
if (exists && !stale->closing_.load() && if (exists && !stale->closing_.load() &&
!stale->connection_established_) { !stale->connection_established_) {
const auto status = stale->connection_status_.load(); const auto status = stale->connection_status_.load();
if (status == ConnectionStatus::Disconnected || if (status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected ||
status == ConnectionStatus::Failed || status == MiniRtcConnectionStatus::MiniRtcConnectionFailed ||
status == ConnectionStatus::Closed) { status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) {
// The recreated session starts with an empty password; callers that // The recreated session starts with an empty password; callers that
// pass none expect the one stored by the previous attempt to be reused. // pass none expect the one stored by the previous attempt to be reused.
const bool keep_stored_password = !password || !*password; const bool keep_stored_password = !password || !*password;
@@ -686,7 +692,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
} }
if (!exists) { if (!exists) {
PeerPtr* peer_to_init = nullptr; MiniRtcPeerPtr* peer_to_init = nullptr;
std::string local_id; std::string local_id;
{ {
@@ -701,13 +707,13 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
props->control_mouse_ = !background; props->control_mouse_ = !background;
props->enable_mouse_control_ = !background; props->enable_mouse_control_ = !background;
props->audio_capture_button_pressed_ = !background; props->audio_capture_button_pressed_ = !background;
memcpy(&props->params_, &params_, sizeof(Params)); memcpy(&props->params_, &params_, sizeof(MiniRtcParams));
props->params_.user_id = props->local_id_.c_str(); props->params_.user_id = props->local_id_.c_str();
props->peer_events_ = std::make_shared<PeerEventHandler>(*this); props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
props->params_.user_data = props->peer_events_.get(); props->params_.user_data = props->peer_events_.get();
props->peer_ = CreatePeer(&props->params_); props->peer_ = MiniRtcCreatePeer(&props->params_);
props->connection_status_.store(ConnectionStatus::Connecting); props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
if (!background) show_connection_status_window_ = true; if (!background) show_connection_status_window_ = true;
if (!props->peer_) { if (!props->peer_) {
@@ -718,18 +724,18 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
const auto& displays = devices_.display_info_list(); const auto& displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) { for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index); const std::string stream_id = MakeDisplayStreamId(index);
AddVideoStream(props->peer_, stream_id.c_str()); MiniRtcAddVideoStream(props->peer_, stream_id.c_str());
} }
AddAudioStream(props->peer_, props->audio_label_.c_str()); MiniRtcAddAudioStream(props->peer_, props->audio_label_.c_str());
AddDataStream(props->peer_, props->data_label_.c_str(), false); MiniRtcAddDataStream(props->peer_, props->data_label_.c_str(), false);
AddDataStream(props->peer_, props->mouse_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->mouse_label_.c_str(), true);
AddDataStream(props->peer_, props->keyboard_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->keyboard_label_.c_str(), true);
AddDataStream(props->peer_, props->control_data_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->control_data_label_.c_str(), true);
AddDataStream(props->peer_, props->file_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->file_label_.c_str(), true);
AddDataStream(props->peer_, props->file_feedback_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
AddDataStream(props->peer_, props->clipboard_label_.c_str(), true); MiniRtcAddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
props->connection_status_.store(ConnectionStatus::Connecting); props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
peer_to_init = props->peer_; peer_to_init = props->peer_;
local_id = props->local_id_; local_id = props->local_id_;
@@ -738,7 +744,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
if (peer_to_init) { if (peer_to_init) {
LOG_INFO("[{}] Create peer instance successful", local_id); LOG_INFO("[{}] Create peer instance successful", local_id);
Init(peer_to_init); MiniRtcInit(peer_to_init);
LOG_INFO("[{}] Peer init finish", local_id); LOG_INFO("[{}] Peer init finish", local_id);
} }
} }
@@ -767,10 +773,10 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
props->rejoin_ = false; props->rejoin_ = false;
return 0; return 0;
} }
props->connection_status_.store(ConnectionStatus::Connecting); props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_; std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_;
if (props->peer_) { if (props->peer_) {
ret = JoinConnection(props->peer_, remote_id_with_pwd.c_str()); ret = MiniRtcJoinConnection(props->peer_, remote_id_with_pwd.c_str());
if (0 == ret) { if (0 == ret) {
props->rejoin_ = false; props->rejoin_ = false;
} else { } else {
@@ -0,0 +1,97 @@
#include "runtime/device_identity_store.h"
#include <openssl/crypto.h>
#include <stdexcept>
#ifdef _WIN32
#include <windows.h>
#else
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
#include "private_file.h"
namespace crossdesk::device_identity_store {
namespace {
void Require(bool ok) {
if (!ok) throw std::runtime_error("Device identity store unavailable or invalid");
}
// Keep the lock file's inode stable; it contains no credentials.
class StoreLock {
public:
explicit StoreLock(const std::filesystem::path& path) {
#ifdef _WIN32
handle_ = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr,
OPEN_ALWAYS, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
BY_HANDLE_FILE_INFORMATION info{};
if (handle_ == INVALID_HANDLE_VALUE || !GetFileInformationByHandle(handle_, &info) ||
(info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) {
if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_);
throw std::runtime_error("Device identity store unavailable");
}
#else
handle_ = open(path.c_str(), O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600);
struct stat info{};
if (handle_ < 0 || fstat(handle_, &info) != 0 || !S_ISREG(info.st_mode) ||
info.st_uid != getuid() || (info.st_mode & 0077) != 0 ||
flock(handle_, LOCK_EX | LOCK_NB) != 0) {
if (handle_ >= 0) close(handle_);
throw std::runtime_error("Device identity store unavailable");
}
#endif
}
~StoreLock() {
#ifdef _WIN32
CloseHandle(handle_);
#else
close(handle_);
#endif
}
StoreLock(const StoreLock&) = delete;
StoreLock& operator=(const StoreLock&) = delete;
private:
#ifdef _WIN32
HANDLE handle_ = INVALID_HANDLE_VALUE;
#else
int handle_ = -1;
#endif
};
struct PrivateText {
std::string value;
~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); }
};
void Validate(const nlohmann::json& document) {
Require(document.is_object() && document.at("version") == 1);
}
} // namespace
void Update(const std::filesystem::path& cache,
const std::function<void(nlohmann::json&)>& update) {
private_file::EnsureDirectory(cache, true);
StoreLock lock(cache / "identity.json.lock");
const auto path = cache / "identity.json";
const bool exists = std::filesystem::symlink_status(path).type() !=
std::filesystem::file_type::not_found;
nlohmann::json saved = {{"version", 1}};
if (exists) {
PrivateText text{private_file::Read(path)};
saved = nlohmann::json::parse(text.value, nullptr, false);
Require(!saved.is_discarded());
}
Validate(saved);
PrivateText before{saved.dump()};
update(saved);
Validate(saved);
PrivateText after{saved.dump()};
Require(after.value.size() <= 64 * 1024);
if (!exists || before.value != after.value) private_file::Write(path, after.value);
}
} // namespace crossdesk::device_identity_store
@@ -0,0 +1,24 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_IDENTITY_STORE_H_
#define _DEVICE_IDENTITY_STORE_H_
#include <filesystem>
#include <functional>
#include <nlohmann/json.hpp>
namespace crossdesk::device_identity_store {
// The shared device signing key lives in cache/identity.json. Read the latest
// document under a process lock, apply an update, then atomically persist it.
// Throw on corruption, unsafe permissions, lock contention or persistence error.
void Update(const std::filesystem::path& cache,
const std::function<void(nlohmann::json&)>& update);
} // namespace crossdesk::device_identity_store
#endif
@@ -0,0 +1,70 @@
#include "runtime/device_login.h"
#include <algorithm>
#include <cstring>
#include "runtime/trusted_devices.h"
namespace crossdesk {
int DeviceLogin::Exchange(MiniRtcSignalExchangeStage stage, const nlohmann::json& message,
const TrustedDevices* identity, char* response, size_t capacity) {
auto output = [&](const nlohmann::json& reply) {
const auto text = reply.dump();
if (text.empty() || text.size() > capacity) return -1;
std::memcpy(response, text.data(), text.size());
return static_cast<int>(text.size());
};
if (stage == MiniRtcSignalExchangeStage::PrepareLogin) {
phase_ = Phase::Idle;
used_password_ = false;
password_.clear();
request_ = message;
const auto login = message.at("user_id").get<std::string>();
if (!identity) return -1; // Never fall back when local key storage fails.
const auto at = login.find('@');
request_["user_id"] = login.substr(0, at);
if (at != std::string::npos) password_ = login.substr(at + 1);
request_["device_key"] = {{"version", 1}, {"public_key", identity->PublicKey()}};
request_.erase("reconnect_token");
request_.erase("reconnect_version");
phase_ = Phase::AwaitingChallenge;
return output(request_);
}
if (!Pending()) return 0;
const auto type = message.value("type", "");
if (type == "login_challenge") {
if (!identity || phase_ != Phase::AwaitingChallenge || message.at("device_key_version") != 1 ||
message.at("user_id") != request_.at("user_id")) return -1;
const auto nonce = message.at("nonce").get<std::string>();
if (nonce.size() != 64 || !std::all_of(nonce.begin(), nonce.end(),
[](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }))
return -1;
const auto id = request_.at("user_id").get<std::string>();
auto reply = request_;
used_password_ = message.at("password_required").get<bool>();
if (used_password_) {
if (id.empty() || id.rfind("C-", 0) == 0 || password_.empty()) return -1;
reply["user_id"] = id + "@" + password_;
}
const auto transcript = nlohmann::json::array(
{"crossdesk.device-login.v1", id, identity->PublicKey(), nonce}).dump();
reply["device_key"]["nonce"] = nonce;
reply["device_key"]["signature"] = identity->Sign(transcript);
phase_ = Phase::AwaitingResult;
password_.clear();
return output(reply);
}
if (type == "login") {
if (message.at("status") == "success") {
if (phase_ != Phase::AwaitingResult || message.value("device_key_version", 0) != 1) return -1;
const auto assigned = message.at("user_id").get<std::string>();
const auto expected = request_.at("user_id").get<std::string>();
if (assigned.empty() || (!expected.empty() && assigned != expected)) return -1;
}
password_.clear();
request_.clear();
phase_ = Phase::Idle;
}
return 0;
}
} // namespace crossdesk
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-02
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_LOGIN_H_
#define _DEVICE_LOGIN_H_
#include <nlohmann/json.hpp>
#include <string>
#include "minirtc.h"
namespace crossdesk {
class TrustedDevices;
// One signaling connection's application authentication, owned by its callbacks.
class DeviceLogin {
public:
int Exchange(MiniRtcSignalExchangeStage stage, const nlohmann::json& message,
const TrustedDevices* identity, char* response, size_t capacity);
bool Pending() const { return phase_ != Phase::Idle; }
bool UsedPassword() const { return used_password_; }
private:
enum class Phase { Idle, AwaitingChallenge, AwaitingResult };
nlohmann::json request_;
std::string password_;
Phase phase_ = Phase::Idle;
bool used_password_ = false;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,192 @@
#include "runtime/gui_runtime.h"
#include "platform.h"
#include "rd_log.h"
namespace crossdesk {
void GuiRuntime::InitializeTrustedDevices(const std::filesystem::path& directory) {
try {
trusted_devices_ = std::make_unique<TrustedDevices>(directory, cache_path_);
} catch (const std::exception& error) {
LOG_ERROR("Cannot load trusted devices: {}", error.what());
// The independent persisted switch stays enabled on identity/store failure.
}
}
bool GuiRuntime::TrustedControllerAllowed(const std::string& id) {
if (!config_center_ || !config_center_->IsTrustedDevicesOnly()) return true;
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
return it != controller_trust_.end() && it->second.IsTrusted(trusted_devices_.get());
}
bool GuiRuntime::BeginControllerTrust(const std::string& id, PeerEventHandler* events) {
const bool required = config_center_->IsTrustedDevicesOnly();
std::lock_guard lock(controller_trust_mutex_);
const auto previous = controller_trust_.find(id);
// Non-connected statuses clear this state. Duplicate Connected notifications
// in the same application session must not restart the challenge or timeout.
if (previous != controller_trust_.end() && previous->second.events == events) {
auto& state = previous->second;
const bool allowed = !required || state.IsTrusted(trusted_devices_.get());
state.pending = !allowed;
return allowed;
}
ControllerTrust state;
state.events = events;
state.pending = required;
state.deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
try {
if (!trusted_devices_) state.invalid = true;
else state.nonce = TrustedDevices::Nonce();
} catch (...) { state.invalid = true; }
controller_trust_[id] = std::move(state);
return !required;
}
void GuiRuntime::ForgetControllerTrust(const std::string& id, PeerEventHandler* events) {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it != controller_trust_.end() && it->second.events == events)
controller_trust_.erase(it);
}
int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) {
const auto text = message.dump();
return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
return MiniRtcSendReliableDataFrameToPeer(peer, text.data(), text.size(),
control_data_label_.c_str(), wire.data(), wire.size());
}, false);
}
bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandler* events,
const char* data, size_t size) {
if (!data || size > 4096) return false;
const auto message = nlohmann::json::parse(data, data + size, nullptr, false);
if (!message.is_object()) return false;
const auto type_it = message.find("type");
if (type_it == message.end()) return false;
if (*type_it == static_cast<int>(ControlType::host_infomation)) {
// Advertise through an existing message so disabled restrictions send no
// unfamiliar protocol messages to legacy clients. Retain only bounded,
// inert metadata until the connection's proof has been accepted.
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it != controller_trust_.end() && it->second.events == events) {
const auto capability = message.find("device_trust_version");
if (capability != message.end() && *capability == 1) it->second.capable = true;
if (it->second.pending) {
it->second.early_host_info.assign(data, size);
return true;
}
}
return false;
}
if (!type_it->is_string()) return false;
const auto type = type_it->get<std::string>();
if (type.rfind("device_trust_", 0) != 0) return false;
if (!trusted_devices_) return true;
try {
if (type == "device_trust_challenge") {
const auto props = FindRemoteSession(id);
if (!props || props->closing_ || props->peer_events_.get() != events ||
message.at("version") != 1) return true;
const auto wire = events->WireId(id);
const auto proof = trusted_devices_->Prove(message.at("nonce"),
props->local_id_, GetHostName()).dump();
MiniRtcSendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(),
control_data_label_.c_str(), wire.data(), wire.size());
} else if (type == "device_trust_proof") {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it == controller_trust_.end() || it->second.events != events) return true;
auto& state = it->second;
if (state.invalid || state.verified || std::chrono::steady_clock::now() >= state.deadline)
return true;
// Accept once per fresh challenge; malformed/replayed proofs grant nothing.
state.verified = TrustedDevices::Verify(message, state.nonce);
if (!state.verified) state.invalid = true;
}
} catch (const std::exception&) {
// Untrusted protocol input never escapes the transport callback.
}
return true;
}
void GuiRuntime::TickControllerTrust() {
struct Action {
std::string id;
PeerEventHandler* events;
std::string nonce;
int kind;
std::string host_info;
};
std::vector<Action> actions;
const auto now = std::chrono::steady_clock::now();
const bool required = config_center_->IsTrustedDevicesOnly();
{
std::lock_guard lock(controller_trust_mutex_);
for (auto& [id, state] : controller_trust_) {
if (state.closing) continue;
const bool trusted = state.IsTrusted(trusted_devices_.get());
if (required && (state.invalid || (state.verified && !trusted) ||
(!state.verified && now >= state.deadline))) {
state.invalid = true;
state.closing = true;
actions.push_back({id, state.events, {}, -1});
} else if (state.pending && (!required || trusted)) {
state.pending = false;
actions.push_back({id, state.events, {}, 1, std::exchange(state.early_host_info, {})});
}
if ((required || state.capable) && !state.invalid && !state.verified &&
now < state.deadline && now >= state.next_challenge) {
state.next_challenge = now + std::chrono::seconds(1);
actions.push_back({id, state.events, state.nonce, 0});
}
}
}
for (const auto& action : actions) {
const auto events = ControllerEvents(action.id);
if (!events || events.get() != action.events) continue;
auto callback = events->EnterCallback();
if (!callback) continue;
if (action.kind < 0) {
LOG_WARN("Reject controller [{}]: trusted device verification required", action.id);
transfers_.Cancel(events.get(), action.id);
remote_input_.Disconnect(action.id);
CloseServerController(action.id);
} else if (action.kind > 0) {
events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionConnected, action.id);
if (!action.host_info.empty()) {
const auto wire = events->WireId(action.id);
PeerEventHandler::OnReceiveDataBuffer(action.host_info.data(), action.host_info.size(),
wire.data(), wire.size(), control_data_label_.data(), control_data_label_.size(), events.get());
}
} else {
SendTrustMessage(action.id, {{"type", "device_trust_challenge"},
{"version", 1}, {"nonce", action.nonce}});
}
}
}
std::optional<TrustedDevices::Device> GuiRuntime::VerifiedController(const std::string& id) {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
return it == controller_trust_.end() || it->second.invalid
? std::nullopt : it->second.verified;
}
bool GuiRuntime::TrustController(const std::string& id, const std::string& expected_key) {
if (!trusted_devices_ || !IsAuthorizedController(id)) return false;
const auto device = VerifiedController(id);
if (!device || device->public_key != expected_key) return false;
try { trusted_devices_->Add(*device); return true; }
catch (const std::exception& error) { LOG_ERROR("Cannot trust device: {}", error.what()); return false; }
}
bool GuiRuntime::RemoveTrustedDevice(const std::string& public_key) {
if (!trusted_devices_) return false;
try { trusted_devices_->Remove(public_key); }
catch (const std::exception& error) { LOG_ERROR("Cannot remove trusted device: {}", error.what()); return false; }
TickControllerTrust();
return true;
}
} // namespace crossdesk
+30 -26
View File
@@ -65,6 +65,11 @@ GuiRuntime::GuiRuntime()
}) {} }) {}
GuiRuntime::~GuiRuntime() { GuiRuntime::~GuiRuntime() {
{
std::lock_guard lock(automation_mutex_);
automation_closing_ = true;
automation_tasks_.clear();
}
remote_input_.Stop(); remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
@@ -94,7 +99,7 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0; ? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
const auto available = [](const auto& props) { const auto available = [](const auto& props) {
return props && props->peer_ && !props->closing_ && return props && props->peer_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected; props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
}; };
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
const auto source = remote_sessions_.find(target_id); const auto source = remote_sessions_.find(target_id);
@@ -116,9 +121,9 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
// Use the negotiated unreliable stream; clicks and wheel events remain // Use the negotiated unreliable stream; clicks and wheel events remain
// on the reliable mouse stream, with their own absolute coordinates. // on the reliable mouse stream, with their own absolute coordinates.
const int result = mouse_move const int result = mouse_move
? SendDataFrame(props->peer_, message.data(), message.size(), ? MiniRtcSendDataFrame(props->peer_, message.data(), message.size(),
props->data_label_.c_str()) props->data_label_.c_str())
: SendReliableDataFrame( : MiniRtcSendReliableDataFrame(
props->peer_, message.data(), message.size(), props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str()); mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
if (result != 0) if (result != 0)
@@ -190,9 +195,7 @@ int GuiRuntime::CreateConnectionPeer() {
if (pending_identity.empty()) { if (pending_identity.empty()) {
credential_recovery_in_progress_ = false; credential_recovery_in_progress_ = false;
credential_recovery_attempt_pending_ = false; credential_recovery_attempt_pending_ = false;
credential_recovery_retry_active_ = false; credential_recovery_action_ = CredentialRecoveryAction::Idle;
credential_recovery_promote_pending_ = false;
credential_recovery_clear_pending_ = false;
} else if (!credential_recovery_in_progress_) { } else if (!credential_recovery_in_progress_) {
credential_recovery_in_progress_ = true; credential_recovery_in_progress_ = true;
credential_recovery_attempt_pending_ = true; credential_recovery_attempt_pending_ = true;
@@ -253,15 +256,15 @@ int GuiRuntime::CreateConnectionPeer() {
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ConfigCenter::VIDEO_ENCODE_FORMAT::AV1
? true ? true
: false; : false;
params_.turn_mode = static_cast<TurnMode>(config_center_->GetTurnMode()); params_.turn_mode = static_cast<MiniRtcTurnMode>(config_center_->GetTurnMode());
params_.enable_srtp = config_center_->IsEnableSrtp(); params_.enable_srtp = config_center_->IsEnableSrtp();
params_.video_content_type = VideoContentType::ScreenContent; params_.video_content_type = MiniRtcVideoContentType::ScreenContent;
// Bootstrap outgoing media with fixed defaults. Live overrides come only // Bootstrap outgoing media with fixed defaults. Live overrides come only
// from the authorized controller and apply to its connection on this host. // from the authorized controller and apply to its connection on this host.
params_.video_quality = VideoQuality::QualityHigh; params_.video_quality = MiniRtcVideoQuality::MiniRtcVideoQualityHigh;
params_.video_frame_rate = 60; params_.video_frame_rate = 60;
params_.video_degradation_preference = params_.video_degradation_preference =
VideoDegradationPreference::MaintainResolution; MiniRtcVideoDegradationPreference::MaintainResolution;
params_.on_receive_video_buffer = nullptr; params_.on_receive_video_buffer = nullptr;
params_.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer; params_.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
params_.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer; params_.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
@@ -270,6 +273,7 @@ int GuiRuntime::CreateConnectionPeer() {
params_.on_signal_status = PeerEventHandler::OnSignalStatus; params_.on_signal_status = PeerEventHandler::OnSignalStatus;
params_.on_signal_message = PeerEventHandler::OnSignalMessage; params_.on_signal_message = PeerEventHandler::OnSignalMessage;
params_.on_signal_exchange = PeerEventHandler::OnSignalExchange;
params_.on_connection_status = PeerEventHandler::OnConnectionStatus; params_.on_connection_status = PeerEventHandler::OnConnectionStatus;
params_.on_net_status_report = PeerEventHandler::OnNetStatusReport; params_.on_net_status_report = PeerEventHandler::OnNetStatusReport;
@@ -280,15 +284,15 @@ int GuiRuntime::CreateConnectionPeer() {
signal_connected_ = false; signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false); device_presence_cache_.SetSignalConnected(false);
need_to_send_recent_connections_ = true; need_to_send_recent_connections_ = true;
signal_status_ = SignalStatus::SignalConnecting; signal_status_ = MiniRtcSignalStatus::MiniRtcSignalConnecting;
{ {
std::unique_lock lock(remote_sessions_mutex_); std::unique_lock lock(remote_sessions_mutex_);
peer_ = CreatePeer(&params_); peer_ = MiniRtcCreatePeer(&params_);
} }
if (peer_) { if (peer_) {
LOG_INFO("Create peer instance [{}] successful", client_id_); LOG_INFO("Create peer instance [{}] successful", client_id_);
Init(peer_); MiniRtcInit(peer_);
LOG_INFO("Peer [{}] init finish", client_id_); LOG_INFO("Peer [{}] init finish", client_id_);
} else { } else {
LOG_INFO("Create peer [{}] instance failed", client_id_); LOG_INFO("Create peer [{}] instance failed", client_id_);
@@ -298,19 +302,19 @@ int GuiRuntime::CreateConnectionPeer() {
const auto &displays = devices_.display_info_list(); const auto &displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) { for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index); const std::string stream_id = MakeDisplayStreamId(index);
AddVideoStream(peer_, stream_id.c_str()); MiniRtcAddVideoStream(peer_, stream_id.c_str());
} }
AddAudioStream(peer_, audio_label_.c_str()); MiniRtcAddAudioStream(peer_, audio_label_.c_str());
AddDataStream(peer_, data_label_.c_str(), false); MiniRtcAddDataStream(peer_, data_label_.c_str(), false);
AddDataStream(peer_, mouse_label_.c_str(), true); MiniRtcAddDataStream(peer_, mouse_label_.c_str(), true);
AddDataStream(peer_, keyboard_label_.c_str(), true); MiniRtcAddDataStream(peer_, keyboard_label_.c_str(), true);
AddDataStream(peer_, control_data_label_.c_str(), true); MiniRtcAddDataStream(peer_, control_data_label_.c_str(), true);
AddDataStream(peer_, file_label_.c_str(), true); MiniRtcAddDataStream(peer_, file_label_.c_str(), true);
AddDataStream(peer_, file_feedback_label_.c_str(), true); MiniRtcAddDataStream(peer_, file_feedback_label_.c_str(), true);
AddDataStream(peer_, clipboard_label_.c_str(), true); MiniRtcAddDataStream(peer_, clipboard_label_.c_str(), true);
if (!unattended_host_) if (!unattended_host_)
AddDataStream(peer_, web_clipboard::kStream, true); MiniRtcAddDataStream(peer_, web_clipboard::kStream, true);
return 0; return 0;
} else { } else {
return -1; return -1;
@@ -386,10 +390,10 @@ void GuiRuntime::SdlCaptureAudioIn(void *userdata, Uint8 *stream, int len) {
uint64_t captured_timestamp = 0; uint64_t captured_timestamp = 0;
for (const auto &it : runtime->remote_sessions_) { for (const auto &it : runtime->remote_sessions_) {
auto props = it.second; auto props = it.second;
if (props->connection_status_.load() == ConnectionStatus::Connected) { if (props->connection_status_.load() == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
if (props->peer_) { if (props->peer_) {
if (captured_timestamp == 0) { if (captured_timestamp == 0) {
const int64_t timestamp_us = GetSystemTimeMicros(props->peer_); const int64_t timestamp_us = MiniRtcGetSystemTimeMicros(props->peer_);
if (timestamp_us > 0) { if (timestamp_us > 0) {
captured_timestamp = static_cast<uint64_t>(timestamp_us); captured_timestamp = static_cast<uint64_t>(timestamp_us);
} }
@@ -398,7 +402,7 @@ void GuiRuntime::SdlCaptureAudioIn(void *userdata, Uint8 *stream, int len) {
frame.data = reinterpret_cast<const char *>(stream); frame.data = reinterpret_cast<const char *>(stream);
frame.size = static_cast<size_t>(len); frame.size = static_cast<size_t>(len);
frame.captured_timestamp = captured_timestamp; frame.captured_timestamp = captured_timestamp;
SendAudioFrame(props->peer_, &frame, MiniRtcSendAudioFrame(props->peer_, &frame,
runtime->audio_label_.c_str()); runtime->audio_label_.c_str());
} }
} }
+46 -2
View File
@@ -10,9 +10,11 @@
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
#include <deque> #include <deque>
#include <future>
#include <memory> #include <memory>
#include <string> #include <string>
#include "desktop_automation.h"
#include "features/clipboard/clipboard_controller.h" #include "features/clipboard/clipboard_controller.h"
#include "features/devices/session_device_manager.h" #include "features/devices/session_device_manager.h"
#include "features/file_transfer/file_transfer_manager.h" #include "features/file_transfer/file_transfer_manager.h"
@@ -26,6 +28,10 @@
#include "runtime/announcement_inbox.h" #include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h" #include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.h" #include "runtime/privacy_session_policy.h"
#include "runtime/trusted_devices.h"
#ifdef __APPLE__
#include "platform/macos/gui/runtime/mac_permission_migration.h"
#endif
namespace crossdesk { namespace crossdesk {
@@ -58,12 +64,13 @@ class GuiRuntime : protected gui_detail::GuiState {
void TickLan(); void TickLan();
void BeginShutdown(); void BeginShutdown();
bool ShutdownDrained(); bool ShutdownDrained();
void RegisterStreams(PeerPtr* peer, size_t display_count); void RegisterStreams(MiniRtcPeerPtr* peer, size_t display_count);
void ConnectLan(const std::string& address, unsigned port, void ConnectLan(const std::string& address, unsigned port,
std::string password, bool remember_password = false); std::string password, bool remember_password = false);
int WithController( int WithController(
const std::string& id, const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation); const std::function<int(MiniRtcPeerPtr*, const std::string&)>& operation,
bool require_trusted = true);
int SendControllerData(const std::string& id, const char* data, size_t size, int SendControllerData(const std::string& id, const char* data, size_t size,
const char* label); const char* label);
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id); std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
@@ -102,9 +109,36 @@ class GuiRuntime : protected gui_detail::GuiState {
void HandleServerControllerDisconnected(const std::string& remote_id, void HandleServerControllerDisconnected(const std::string& remote_id,
const char* reason); const char* reason);
void HandleWindowsServiceIntegration(); void HandleWindowsServiceIntegration();
void ProcessAutomationRequests();
void HandlePrivacy(); void HandlePrivacy();
void HandleScreenLock(); void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id); bool IsAuthorizedController(const std::string& remote_id);
void InitializeTrustedDevices(const std::filesystem::path& directory);
bool TrustedControllerAllowed(const std::string& remote_id);
bool BeginControllerTrust(const std::string& remote_id, PeerEventHandler* events);
void ForgetControllerTrust(const std::string& remote_id, PeerEventHandler* events);
bool HandleDeviceTrustMessage(const std::string& remote_id, PeerEventHandler* events,
const char* data, size_t size);
void TickControllerTrust();
std::optional<TrustedDevices::Device> VerifiedController(const std::string& remote_id);
bool TrustController(const std::string& remote_id, const std::string& expected_key);
bool RemoveTrustedDevice(const std::string& public_key);
int SendTrustMessage(const std::string& remote_id, const nlohmann::json& message);
std::unique_ptr<TrustedDevices> trusted_devices_;
struct ControllerTrust {
PeerEventHandler* events = nullptr;
std::string nonce;
std::optional<TrustedDevices::Device> verified;
std::string early_host_info;
std::chrono::steady_clock::time_point deadline{}, next_challenge{};
bool pending = false, invalid = false, closing = false, capable = false;
bool IsTrusted(const TrustedDevices* devices) const {
return devices && !invalid && !closing && verified &&
devices->Contains(verified->public_key);
}
};
std::mutex controller_trust_mutex_;
std::map<std::string, ControllerTrust> controller_trust_;
void SetControllerPrivacySupport(const std::string& remote_id, bool supported); void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when // Caller holds connection_status_mutex_ exclusively. Turns privacy off when
// any connected controller lacks support, or on for the first automatic one. // any connected controller lacks support, or on for the first automatic one.
@@ -124,6 +158,11 @@ class GuiRuntime : protected gui_detail::GuiState {
// or every connected session while that target leads DeskHub group control. // or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action); void SendRemoteInput(const RemoteAction& action);
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action); InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
void HandleAutomation(const std::string& remote_id, const nlohmann::json& request);
DesktopAutomation desktop_automation_;
std::mutex automation_mutex_;
std::deque<std::function<void()>> automation_tasks_;
bool automation_closing_ = false;
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display); InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
std::string gallery_group_control_id_; std::string gallery_group_control_id_;
@@ -145,6 +184,11 @@ class GuiRuntime : protected gui_detail::GuiState {
#endif #endif
#ifdef __APPLE__ #ifdef __APPLE__
void StartMacPermissionMigration(bool retry);
std::future<MacPermissionMigrationResult> mac_permission_migration_task_;
bool mac_permission_migration_failed_ = false;
bool mac_permission_dialog_dismissed_ = false;
bool mac_permission_dialog_requested_ = false;
bool CheckScreenRecordingPermission(); bool CheckScreenRecordingPermission();
bool CheckAccessibilityPermission(); bool CheckAccessibilityPermission();
void OpenScreenRecordingPreferences(); void OpenScreenRecordingPreferences();
+22 -20
View File
@@ -7,17 +7,17 @@
#endif #endif
namespace crossdesk { namespace crossdesk {
void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) { void GuiRuntime::RegisterStreams(MiniRtcPeerPtr* peer, size_t displays) {
for (size_t i = 0; i < displays; ++i) { for (size_t i = 0; i < displays; ++i) {
const auto id = MakeDisplayStreamId(i); const auto id = MakeDisplayStreamId(i);
AddVideoStream(peer, id.c_str()); MiniRtcAddVideoStream(peer, id.c_str());
} }
AddAudioStream(peer, audio_label_.c_str()); MiniRtcAddAudioStream(peer, audio_label_.c_str());
AddDataStream(peer, data_label_.c_str(), false); MiniRtcAddDataStream(peer, data_label_.c_str(), false);
for (const auto& label : for (const auto& label :
{mouse_label_, keyboard_label_, control_data_label_, file_label_, {mouse_label_, keyboard_label_, control_data_label_, file_label_,
file_feedback_label_, clipboard_label_}) file_feedback_label_, clipboard_label_})
AddDataStream(peer, label.c_str(), true); MiniRtcAddDataStream(peer, label.c_str(), true);
} }
void GuiRuntime::InitializeLan() { void GuiRuntime::InitializeLan() {
if (unattended_host_ || stopping_ || lan_ || if (unattended_host_ || stopping_ || lan_ ||
@@ -37,8 +37,8 @@ void GuiRuntime::InitializeLan() {
auto events = std::make_shared<PeerEventHandler>(*this); auto events = std::make_shared<PeerEventHandler>(*this);
events->BindDirect(session); events->BindDirect(session);
// Direct initialization must not depend on cloud login/configuration having // Direct initialization must not depend on cloud login/configuration having
// successfully populated the managed peer's Params snapshot. // successfully populated the managed peer's MiniRtcParams snapshot.
Params params{}; MiniRtcParams params{};
std::snprintf(params.log_path, sizeof(params.log_path), "%s", std::snprintf(params.log_path, sizeof(params.log_path), "%s",
dll_log_path_.c_str()); dll_log_path_.c_str());
params.hardware_acceleration = true; params.hardware_acceleration = true;
@@ -46,12 +46,12 @@ void GuiRuntime::InitializeLan() {
params.av1_encoding = config_center_->GetVideoEncodeFormat() == params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1; ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
params.enable_srtp = true; params.enable_srtp = true;
params.turn_mode = TurnMode::TurnDisabled; params.turn_mode = MiniRtcTurnMode::MiniRtcTurnDisabled;
params.video_content_type = VideoContentType::ScreenContent; params.video_content_type = MiniRtcVideoContentType::ScreenContent;
params.video_quality = VideoQuality::QualityHigh; params.video_quality = MiniRtcVideoQuality::MiniRtcVideoQualityHigh;
params.video_frame_rate = 60; params.video_frame_rate = 60;
params.video_degradation_preference = params.video_degradation_preference =
VideoDegradationPreference::MaintainResolution; MiniRtcVideoDegradationPreference::MaintainResolution;
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer; params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer; params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer; params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
@@ -69,7 +69,7 @@ void GuiRuntime::InitializeLan() {
lan::SessionManager::Prepared result; lan::SessionManager::Prepared result;
result.callback_owner = events; result.callback_owner = events;
result.drain_callbacks = [events] { events->Deactivate(); }; result.drain_callbacks = [events] { events->Deactivate(); };
result.peer = CreatePeer(&params); result.peer = MiniRtcCreatePeer(&params);
events->SetDirectPeer(result.peer); events->SetDirectPeer(result.peer);
if (result.peer) RegisterStreams(result.peer, displays); if (result.peer) RegisterStreams(result.peer, displays);
return result; return result;
@@ -88,7 +88,7 @@ void GuiRuntime::InitializeLan() {
hooks.connected = [this](const std::shared_ptr<lan::Session>& session) { hooks.connected = [this](const std::shared_ptr<lan::Session>& session) {
auto events = auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner); std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key); events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionConnected, session->key);
events->FlushEarlyData(); events->FlushEarlyData();
}; };
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) { hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
@@ -101,7 +101,7 @@ void GuiRuntime::InitializeLan() {
} }
if (events) { if (events) {
if (attached) if (attached)
events->ApplyConnectionStatus(ConnectionStatus::Closed, session->key); events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionClosed, session->key);
events->StopAccepting(); events->StopAccepting();
transfers_.Cancel(events.get()); transfers_.Cancel(events.get());
} }
@@ -157,7 +157,7 @@ void GuiRuntime::ConnectLan(const std::string& address, unsigned port,
lan_connecting_ = session; lan_connecting_ = session;
props->lan_session_ = session; props->lan_session_ = session;
props->remote_id_ = session->key; props->remote_id_ = session->key;
props->connection_status_ = ConnectionStatus::Connecting; props->connection_status_ = MiniRtcConnectionStatus::MiniRtcConnectionConnecting;
props->connection_auth_.Complete(); props->connection_auth_.Complete();
{ {
std::unique_lock lock(remote_sessions_mutex_); std::unique_lock lock(remote_sessions_mutex_);
@@ -180,12 +180,14 @@ std::shared_ptr<PeerEventHandler> GuiRuntime::ControllerEvents(
} }
int GuiRuntime::WithController( int GuiRuntime::WithController(
const std::string& id, const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation) { const std::function<int(MiniRtcPeerPtr*, const std::string&)>& operation,
bool require_trusted) {
if (require_trusted && !TrustedControllerAllowed(id)) return -1;
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id); auto it = lan_controllers_.find(id);
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events; auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1; if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1;
PeerPtr* peer = MiniRtcPeerPtr* peer =
it == lan_controllers_.end() ? peer_ : it->second.session->peer; it == lan_controllers_.end() ? peer_ : it->second.session->peer;
if (!peer || !events || !events->DataAllowed()) return -1; if (!peer || !events || !events->DataAllowed()) return -1;
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
@@ -196,8 +198,8 @@ int GuiRuntime::WithController(
} }
int GuiRuntime::SendControllerData(const std::string& id, const char* data, int GuiRuntime::SendControllerData(const std::string& id, const char* data,
size_t size, const char* label) { size_t size, const char* label) {
return WithController(id, [&](PeerPtr* peer, const std::string& wire) { return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
return SendReliableDataFrameToPeer(peer, data, size, label, wire.data(), return MiniRtcSendReliableDataFrameToPeer(peer, data, size, label, wire.data(),
wire.size()); wire.size());
}); });
} }
@@ -206,7 +208,7 @@ std::vector<std::string> GuiRuntime::ConnectedControllers() {
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
ids.reserve(connection_status_.size()); ids.reserve(connection_status_.size());
for (const auto& [id, status] : connection_status_) for (const auto& [id, status] : connection_status_)
if (status == ConnectionStatus::Connected) ids.push_back(id); if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) ids.push_back(id);
return ids; return ids;
} }
void GuiRuntime::BeginShutdown() { void GuiRuntime::BeginShutdown() {
@@ -133,6 +133,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
GuiRuntime* runtime = &handler->owner_; GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size); std::string source_id = std::string(src_id, src_id_size);
const auto remote_id = handler->ConnectionKey(user_id, user_id_size);
if (source_id == runtime->control_data_label_ &&
runtime->HandleDeviceTrustMessage(remote_id, handler, data, size)) return;
const auto viewer = runtime->FindRemoteSession(remote_id);
if (viewer && viewer->peer_events_.get() != handler) return;
if (!viewer && !runtime->TrustedControllerAllowed(remote_id)) return;
// The service-owned host runs as SYSTEM. Do not expose filesystem or // The service-owned host runs as SYSTEM. Do not expose filesystem or
// clipboard operations in that security context. // clipboard operations in that security context.
if (runtime->unattended_host_ && if (runtime->unattended_host_ &&
@@ -141,40 +147,46 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_ || source_id == runtime->clipboard_label_ ||
source_id == web_clipboard::kStream)) source_id == web_clipboard::kStream))
return; return;
if (source_id == runtime->control_data_label_ &&
runtime->transfers_.HandleBrowser(data, size, handler, remote_id)) return;
if (source_id == web_clipboard::kStream) { if (source_id == web_clipboard::kStream) {
runtime->clipboard_.ReceiveWeb( runtime->clipboard_.ReceiveWeb(
data, size, handler->ConnectionKey(user_id, user_id_size), handler); data, size, remote_id, handler);
return; return;
} }
if (source_id == runtime->file_label_) { if (source_id == runtime->file_label_) {
runtime->transfers_.Receive(data, size, handler, runtime->transfers_.Receive(data, size, handler, remote_id);
handler->ConnectionKey(user_id, user_id_size));
return; return;
} else if (source_id == runtime->clipboard_label_) { } else if (source_id == runtime->clipboard_label_) {
if (size > 0) { if (size > 0) {
std::string remote_user_id = if (viewer && !viewer->clipboard_allowed_.load()) {
handler->ConnectionKey(user_id, user_id_size);
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props && !props->clipboard_allowed_.load()) {
return; return;
} }
runtime->clipboard_.QueueRemoteText( runtime->clipboard_.QueueRemoteText(
data, size, props ? remote_user_id : std::string{}); data, size, viewer ? remote_id : std::string{}, {},
viewer ? std::function<bool()>{} : [runtime, remote_id] {
return runtime->IsAuthorizedController(remote_id);
});
} }
return; return;
} else if (source_id == runtime->file_feedback_label_) { } else if (source_id == runtime->file_feedback_label_) {
runtime->transfers_.HandleAck( runtime->transfers_.HandleAck(data, size, handler, remote_id);
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
return; return;
} }
const std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
const std::string wire_id = handler->WireId(remote_id); const std::string wire_id = handler->WireId(remote_id);
PeerPtr* bound_peer = MiniRtcPeerPtr* bound_peer =
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_; handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
std::string json_str(data, size); std::string json_str(data, size);
if (json_str.find("\"automation\"") != std::string::npos) {
const auto request = nlohmann::json::parse(json_str, nullptr, false);
if (request.is_object() && request.value("type", nlohmann::json{}) == "automation") {
if (source_id == runtime->control_data_label_ && runtime->IsAuthorizedController(remote_id))
runtime->HandleAutomation(remote_id, request);
return;
}
}
const auto preview_json = const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos json_str.find("\"deskhub_preview") != std::string::npos
? nlohmann::json::parse(json_str, nullptr, false) ? nlohmann::json::parse(json_str, nullptr, false)
@@ -192,31 +204,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
// their own demand. MiniRTC owns per-peer video/audio pause state. // their own demand. MiniRTC owns per-peer video/audio pause state.
if (!previous || subscription->IsNewerThan(*previous)) { if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0) if (subscription->fps > 0)
accepted = UpdateVideoSettings( accepted = MiniRtcUpdateVideoSettings(
bound_peer, wire_id.data(), wire_id.size(), bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(subscription->quality), static_cast<MiniRtcVideoQuality>(subscription->quality),
subscription->frame_rate, subscription->frame_rate,
static_cast<VideoDegradationPreference>( static_cast<MiniRtcVideoDegradationPreference>(
subscription->EncoderPreference())) == 0; subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) != if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0))) subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit( accepted = MiniRtcUpdateVideoBitrateLimit(
bound_peer, wire_id.data(), wire_id.size(), bound_peer, wire_id.data(), wire_id.size(),
subscription->max_bitrate_bps.value_or(0)) == 0; subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a // Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume. // keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width || if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height)) previous->height != subscription->height))
accepted = UpdateVideoResolution(bound_peer, wire_id.data(), accepted = MiniRtcUpdateVideoResolution(bound_peer, wire_id.data(),
wire_id.size(), subscription->width, wire_id.size(), subscription->width,
subscription->height) == 0; subscription->height) == 0;
if (accepted) if (accepted)
accepted = accepted =
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(), MiniRtcSetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
subscription->fps == 0) == 0; subscription->fps == 0) == 0;
if (accepted) if (accepted)
accepted = accepted =
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(), MiniRtcSetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
!subscription->audio) == 0; !subscription->audio) == 0;
if (accepted && subscription->audio) if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
@@ -228,7 +240,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
{"request_id", subscription->request_id}, {"request_id", subscription->request_id},
{"accepted", {"accepted",
accepted}}.dump(); accepted}}.dump();
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(), MiniRtcSendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
runtime->control_data_label_.c_str(), runtime->control_data_label_.c_str(),
wire_id.data(), wire_id.size()); wire_id.data(), wire_id.size());
return; return;
@@ -263,7 +275,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (auto props = runtime->FindRemoteSession(remote_id); if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_ && props && source_id == props->control_data_label_ &&
!props->closing_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected) { props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder); props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
} }
return; return;
@@ -302,17 +314,17 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; return;
const auto& settings = remote_action.vs; const auto& settings = remote_action.vs;
remote_action.vs.accepted = remote_action.vs.accepted =
UpdateVideoSettings( MiniRtcUpdateVideoSettings(
bound_peer, wire_id.data(), wire_id.size(), bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(settings.quality), settings.frame_rate, static_cast<MiniRtcVideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0; static_cast<MiniRtcVideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} " LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
"preference={} accepted={}", "preference={} accepted={}",
remote_id, source_id, settings.request_id, settings.quality, remote_id, source_id, settings.request_id, settings.quality,
settings.frame_rate, settings.preference, remote_action.vs.accepted); settings.frame_rate, settings.preference, remote_action.vs.accepted);
remote_action.type = ControlType::video_settings_status; remote_action.type = ControlType::video_settings_status;
const auto message = remote_action.to_json(); const auto message = remote_action.to_json();
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(), MiniRtcSendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
runtime->control_data_label_.c_str(), runtime->control_data_label_.c_str(),
wire_id.data(), wire_id.size()); wire_id.data(), wire_id.size());
return; return;
@@ -422,6 +434,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
} }
if (remote_action.type == ControlType::host_infomation) { if (remote_action.type == ControlType::host_infomation) {
if (source_id == runtime->control_data_label_) {
runtime->transfers_.SetBrowserSupport(handler, remote_id,
remote_action.i.supports_file_browser);
}
bool is_client_mode = false; bool is_client_mode = false;
std::shared_ptr<GuiRuntime::RemoteSession> props; std::shared_ptr<GuiRuntime::RemoteSession> props;
{ {
@@ -463,7 +479,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->video_settings_request_tick_ = SDL_GetTicks(); props->video_settings_request_tick_ = SDL_GetTicks();
settings.vs = props->video_settings_; settings.vs = props->video_settings_;
const auto message = settings.to_json(); const auto message = settings.to_json();
SendReliableDataFrame( MiniRtcSendReliableDataFrame(
handler->IsDirect() ? handler->DirectPeer() : props->peer_, handler->IsDirect() ? handler->DirectPeer() : props->peer_,
message.data(), message.size(), message.data(), message.size(),
props->control_data_label_.c_str()); props->control_data_label_.c_str());
@@ -514,7 +530,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
// command arrives after it (e.g. when unmuting an inactive window). // command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id); const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a; const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(), const int ret = MiniRtcSetAudioSendPaused(bound_peer, wire_id.data(),
wire_id.size(), paused); wire_id.size(), paused);
if (remote_action.a) if (remote_action.a)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
@@ -65,7 +65,7 @@ bool PeerEventHandler::DataAllowed() const {
return session && session->authorized && !session->closing; return session && session->authorized && !session->closing;
} }
void PeerEventHandler::SendClientInfo(PeerPtr* peer, void PeerEventHandler::SendClientInfo(MiniRtcPeerPtr* peer,
const std::string& client_id) { const std::string& client_id) {
if (!peer) { if (!peer) {
return; return;
@@ -75,7 +75,7 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
{"version", CROSSDESK_VERSION}, {"version", CROSSDESK_VERSION},
{"platform", HostPlatformName(kClientPlatform)}}; {"platform", HostPlatformName(kClientPlatform)}};
const std::string payload = message.dump(); const std::string payload = message.dump();
if (SendSignalMessage(peer, payload.data(), payload.size()) != 0) { if (MiniRtcSendSignalMessage(peer, payload.data(), payload.size()) != 0) {
LOG_WARN("[{}] failed to report client information", client_id); LOG_WARN("[{}] failed to report client information", client_id);
} }
} }
@@ -84,6 +84,24 @@ PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
owner_.transfers_.Connected(this, ""); owner_.transfers_.Connected(this, "");
} }
int PeerEventHandler::OnSignalExchange(MiniRtcSignalExchangeStage stage, const char* message,
size_t size, char* response, size_t capacity, void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler || !message) return -1;
auto callback = handler->EnterCallback();
if (!callback) return -1;
if (stage == MiniRtcSignalExchangeStage::IncomingMessage &&
!handler->device_login_.Pending()) return 0;
try {
return handler->device_login_.Exchange(stage,
nlohmann::json::parse(message, message + size),
handler->owner_.trusted_devices_.get(), response, capacity);
} catch (const std::exception&) {
LOG_ERROR("Device login protocol or identity unavailable");
return -1;
}
}
void PeerEventHandler::OnSignalMessage(const char* message, size_t size, void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
void* user_data) { void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
@@ -184,13 +202,16 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
j.contains("reason") && j["reason"].is_string() j.contains("reason") && j["reason"].is_string()
? j["reason"].get<std::string>() ? j["reason"].get<std::string>()
: "Password change failed"; : "Password change failed";
runtime->password_change_result_uncertain_ = false; // A rejected replay cannot tell us whether the original request committed.
runtime->password_change_result_uncertain_ = runtime->password_change_recovery_ &&
!runtime->password_change_succeeded_;
runtime->password_change_result_ready_ = true; runtime->password_change_result_ready_ = true;
} }
} }
void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id, void PeerEventHandler::OnSignalStatus(MiniRtcSignalStatus status, const char* user_id,
size_t user_id_size, void* user_data) { size_t user_id_size, void* user_data) {
using RecoveryAction = GuiRuntime::CredentialRecoveryAction;
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
@@ -199,39 +220,41 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
std::string client_id(user_id, user_id_size); std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) { if (client_id == runtime->client_id_) {
runtime->announcements_.SetConnected(status == SignalStatus::SignalConnected); runtime->announcements_.SetConnected(status == MiniRtcSignalStatus::MiniRtcSignalConnected);
runtime->signal_status_ = status; runtime->signal_status_ = status;
runtime->device_presence_cache_.SetSignalConnected( runtime->device_presence_cache_.SetSignalConnected(
status == SignalStatus::SignalConnected); status == MiniRtcSignalStatus::MiniRtcSignalConnected);
if (SignalStatus::SignalConnecting == status) { if (MiniRtcSignalStatus::MiniRtcSignalConnecting == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
} else if (SignalStatus::SignalConnected == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalConnected == status) {
runtime->signal_connected_ = true; runtime->signal_connected_ = true;
runtime->need_to_send_recent_connections_ = true; runtime->need_to_send_recent_connections_ = true;
LOG_INFO("[{}] connected to signal server", client_id); LOG_INFO("[{}] connected to signal server", client_id);
SendClientInfo(runtime->peer_, client_id); SendClientInfo(runtime->peer_, client_id);
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_); std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
if (runtime->credential_recovery_in_progress_) { if (runtime->credential_recovery_in_progress_) {
if (runtime->credential_recovery_attempt_pending_) { if (!handler->device_login_.UsedPassword()) {
runtime->credential_recovery_promote_pending_ = true; runtime->credential_recovery_action_ = RecoveryAction::ResumePending;
} else if (runtime->credential_recovery_attempt_pending_) {
runtime->credential_recovery_action_ = RecoveryAction::PromotePending;
} else { } else {
runtime->credential_recovery_clear_pending_ = true; runtime->credential_recovery_action_ = RecoveryAction::ClearPending;
} }
} }
} else if (SignalStatus::SignalFailed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalFailed == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_); std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
if (runtime->credential_recovery_in_progress_ && if (runtime->credential_recovery_in_progress_ &&
runtime->credential_recovery_attempt_pending_) { runtime->credential_recovery_attempt_pending_) {
runtime->credential_recovery_retry_active_ = true; runtime->credential_recovery_action_ = RecoveryAction::RetryActive;
} }
} else if (SignalStatus::SignalClosed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalClosed == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
} else if (SignalStatus::SignalReconnecting == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalReconnecting == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
} else if (SignalStatus::SignalServerClosed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalServerClosed == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
} else if (SignalStatus::SignalTlsCertError == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalTlsCertError == status) {
runtime->signal_connected_ = false; runtime->signal_connected_ = false;
} }
} else { } else {
@@ -243,27 +266,27 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
auto props = runtime->FindRemoteSession(remote_id); auto props = runtime->FindRemoteSession(remote_id);
if (!props) return; if (!props) return;
props->signal_status_ = status; props->signal_status_ = status;
if (SignalStatus::SignalConnecting == status) { if (MiniRtcSignalStatus::MiniRtcSignalConnecting == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} else if (SignalStatus::SignalConnected == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalConnected == status) {
props->signal_connected_ = true; props->signal_connected_ = true;
LOG_INFO("[{}] connected to signal server", remote_id); LOG_INFO("[{}] connected to signal server", remote_id);
SendClientInfo(props->peer_, client_id); SendClientInfo(props->peer_, client_id);
} else if (SignalStatus::SignalFailed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalFailed == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} else if (SignalStatus::SignalClosed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalClosed == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} else if (SignalStatus::SignalReconnecting == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalReconnecting == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} else if (SignalStatus::SignalServerClosed == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalServerClosed == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} else if (SignalStatus::SignalTlsCertError == status) { } else if (MiniRtcSignalStatus::MiniRtcSignalTlsCertError == status) {
props->signal_connected_ = false; props->signal_connected_ = false;
} }
} }
} }
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, void PeerEventHandler::OnConnectionStatus(MiniRtcConnectionStatus status,
const char* user_id, size_t size, const char* user_id, size_t size,
void* context) { void* context) {
auto* handler = static_cast<PeerEventHandler*>(context); auto* handler = static_cast<PeerEventHandler*>(context);
@@ -277,7 +300,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
DispatchConnectionStatus(status, user_id, size, context); DispatchConnectionStatus(status, user_id, size, context);
} }
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status, void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status,
const char* user_id, const char* user_id,
const size_t user_id_size, const size_t user_id_size,
void* user_data) { void* user_data) {
@@ -288,9 +311,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
GuiRuntime* runtime = &handler->owner_; GuiRuntime* runtime = &handler->owner_;
std::string remote_id = handler->ConnectionKey(user_id, user_id_size); std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id); if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed || else if (status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected || status == MiniRtcConnectionStatus::MiniRtcConnectionFailed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id); status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) runtime->transfers_.Cancel(handler, remote_id);
std::shared_ptr<GuiRuntime::RemoteSession> props; std::shared_ptr<GuiRuntime::RemoteSession> props;
{ {
std::shared_lock lock(runtime->remote_sessions_mutex_); std::shared_lock lock(runtime->remote_sessions_mutex_);
@@ -300,15 +323,23 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
} }
} }
if (!props) {
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
if (!runtime->BeginControllerTrust(remote_id, handler)) return;
} else {
runtime->ForgetControllerTrust(remote_id, handler);
}
}
if (props) { if (props) {
runtime->is_client_mode_ = true; runtime->is_client_mode_ = true;
if (!props->wall_managed_) runtime->show_connection_status_window_ = true; if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
props->connection_status_.store(status); props->connection_status_.store(status);
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering) if (status != MiniRtcConnectionStatus::MiniRtcConnectionConnecting && status != MiniRtcConnectionStatus::MiniRtcConnectionGathering)
props->connection_auth_.Complete(); props->connection_auth_.Complete();
switch (status) { switch (status) {
case ConnectionStatus::Connected: { case MiniRtcConnectionStatus::MiniRtcConnectionConnected: {
runtime->ResetRemoteServiceStatus(*props); runtime->ResetRemoteServiceStatus(*props);
{ {
std::lock_guard lock(props->remote_cursor_state_mutex_); std::lock_guard lock(props->remote_cursor_state_mutex_);
@@ -328,13 +359,16 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true; remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true; remote_action.i.supports_deskhub_bandwidth = true;
remote_action.i.supports_file_browser = !runtime->unattended_host_;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) { for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1, LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]); remote_action.i.display_list[i]);
} }
std::string msg = remote_action.to_json(); auto information = nlohmann::json::parse(remote_action.to_json());
information["device_trust_version"] = 1;
std::string msg = information.dump();
const int ret = const int ret =
SendReliableDataFrame(props->peer_, msg.data(), msg.size(), MiniRtcSendReliableDataFrame(props->peer_, msg.data(), msg.size(),
runtime->control_data_label_.c_str()); runtime->control_data_label_.c_str());
if (ret != 0) { if (ret != 0) {
LOG_WARN("[{}] host information send failed, ret={}", remote_id, LOG_WARN("[{}] host information send failed, ret={}", remote_id,
@@ -352,9 +386,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true; if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
break; break;
} }
case ConnectionStatus::Disconnected: case MiniRtcConnectionStatus::MiniRtcConnectionDisconnected:
case ConnectionStatus::Failed: case MiniRtcConnectionStatus::MiniRtcConnectionFailed:
case ConnectionStatus::Closed: { case MiniRtcConnectionStatus::MiniRtcConnectionClosed: {
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id, runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
"connection_closed"); "connection_closed");
props->connection_established_ = false; props->connection_established_ = false;
@@ -372,7 +406,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
props->gallery_video_latency_->Reset(); props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {}; props->gallery_frame_timing_ = {};
} }
props->traversal_mode_ = TraversalMode::UnknownMode; props->traversal_mode_ = MiniRtcTraversalMode::MiniRtcTraversalUnknown;
props->enable_mouse_control_ = false; props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false; props->preview_subscription_supported_ = false;
props->preview_resolution_supported_ = false; props->preview_resolution_supported_ = false;
@@ -443,7 +477,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
runtime->focus_on_stream_window_ = false; runtime->focus_on_stream_window_ = false;
if (status == ConnectionStatus::Closed) { if (status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) {
props->remote_close_pending_.store(true, std::memory_order_release); props->remote_close_pending_.store(true, std::memory_order_release);
} }
@@ -457,7 +491,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
runtime->show_connection_status_window_ = true; runtime->show_connection_status_window_ = true;
{ {
std::unique_lock lock(runtime->connection_status_mutex_); std::unique_lock lock(runtime->connection_status_mutex_);
if (status == ConnectionStatus::Connected) { if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
runtime->disconnect_lock_.Connected(remote_id); runtime->disconnect_lock_.Connected(remote_id);
// Local privacy requires explicit desktop capability. Host info may // Local privacy requires explicit desktop capability. Host info may
// arrive before Connected; consume that saved capability here. // arrive before Connected; consume that saved capability here.
@@ -466,19 +500,19 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable); runtime->ApplyPrivacyAdmission(automatic_enable);
} }
runtime->connection_status_[remote_id] = status; runtime->connection_status_[remote_id] = status;
if (status == ConnectionStatus::Connected) if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected)
runtime->remote_input_.Connect(remote_id); runtime->remote_input_.Connect(remote_id);
#if _WIN32 #if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of( runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(), runtime->connection_status_.begin(), runtime->connection_status_.end(),
[](const auto& entry) { return entry.second == ConnectionStatus::Connected; }), [](const auto& entry) { return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected; }),
std::memory_order_relaxed); std::memory_order_relaxed);
#endif #endif
runtime->devices_.OnControllerConnectionsChanged(); runtime->devices_.OnControllerConnectionsChanged();
} }
switch (status) { switch (status) {
case ConnectionStatus::Connected: { case MiniRtcConnectionStatus::MiniRtcConnectionConnected: {
runtime->preview_subscriptions_.Remove(remote_id); runtime->preview_subscriptions_.Remove(remote_id);
#if _WIN32 #if _WIN32
runtime->last_windows_service_status_tick_ = 0; runtime->last_windows_service_status_tick_ = 0;
@@ -495,6 +529,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true; remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true; remote_action.i.supports_deskhub_bandwidth = true;
remote_action.i.supports_file_browser = !runtime->unattended_host_;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) { for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1, LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]); remote_action.i.display_list[i]);
@@ -522,9 +557,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
break; break;
} }
case ConnectionStatus::Disconnected: case MiniRtcConnectionStatus::MiniRtcConnectionDisconnected:
case ConnectionStatus::Failed: case MiniRtcConnectionStatus::MiniRtcConnectionFailed:
case ConnectionStatus::Closed: { case MiniRtcConnectionStatus::MiniRtcConnectionClosed: {
runtime->HandleServerControllerDisconnected(remote_id, runtime->HandleServerControllerDisconnected(remote_id,
"connection_closed"); "connection_closed");
break; break;
@@ -536,7 +571,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
} }
void PeerEventHandler::OnNetStatusReport( void PeerEventHandler::OnNetStatusReport(
const char* client_id, size_t client_id_size, TraversalMode mode, const char* client_id, size_t client_id_size, MiniRtcTraversalMode mode,
const MiniRtcNetTrafficStats* net_traffic_stats, const char* user_id, const MiniRtcNetTrafficStats* net_traffic_stats, const char* user_id,
const size_t user_id_size, void* user_data) { const size_t user_id_size, void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
@@ -605,7 +640,7 @@ void PeerEventHandler::OnNetStatusReport(
const auto status = runtime->connection_status_.find(remote_id); const auto status = runtime->connection_status_.find(remote_id);
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 && report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
status != runtime->connection_status_.end() && status != runtime->connection_status_.end() &&
status->second == ConnectionStatus::Connected; status->second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
} }
if (net_traffic_stats && report_codecs) { if (net_traffic_stats && report_codecs) {
RemoteAction action{}; RemoteAction action{};
@@ -15,6 +15,7 @@
#include "minirtc.h" #include "minirtc.h"
#include "runtime/session_lifecycle.h" #include "runtime/session_lifecycle.h"
#include "runtime/device_login.h"
namespace crossdesk { namespace crossdesk {
@@ -36,10 +37,10 @@ public:
std::string WireId(const std::string& key) const; std::string WireId(const std::string& key) const;
bool DataAllowed() const; bool DataAllowed() const;
bool IsDirect() const { return direct_bound_; } bool IsDirect() const { return direct_bound_; }
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; } void SetDirectPeer(MiniRtcPeerPtr* peer) { direct_peer_ = peer; }
PeerPtr* DirectPeer() const { return direct_peer_; } MiniRtcPeerPtr* DirectPeer() const { return direct_peer_; }
void FlushEarlyData(); void FlushEarlyData();
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) { void ApplyConnectionStatus(MiniRtcConnectionStatus status, const std::string& id) {
DispatchConnectionStatus(status, id.data(), id.size(), this); DispatchConnectionStatus(status, id.data(), id.size(), this);
} }
void StopAccepting() { callbacks_.StopAccepting(); } void StopAccepting() { callbacks_.StopAccepting(); }
@@ -59,32 +60,36 @@ public:
const char *user_id, size_t user_id_size, const char *user_id, size_t user_id_size,
const char *src_id, size_t src_id_size, const char *src_id, size_t src_id_size,
void *user_data); void *user_data);
static void OnSignalStatus(SignalStatus status, const char *user_id, static void OnSignalStatus(MiniRtcSignalStatus status, const char *user_id,
size_t user_id_size, void *user_data); size_t user_id_size, void *user_data);
static void OnSignalMessage(const char *message, size_t size, static void OnSignalMessage(const char *message, size_t size,
void *user_data); void *user_data);
static void OnConnectionStatus(ConnectionStatus status, const char *user_id, static int OnSignalExchange(MiniRtcSignalExchangeStage stage, const char* message,
size_t size, char* response, size_t capacity,
void* user_data);
static void OnConnectionStatus(MiniRtcConnectionStatus status, const char *user_id,
size_t user_id_size, void *user_data); size_t user_id_size, void *user_data);
static void OnNetStatusReport(const char *client_id, size_t client_id_size, static void OnNetStatusReport(const char *client_id, size_t client_id_size,
TraversalMode mode, MiniRtcTraversalMode mode,
const MiniRtcNetTrafficStats *net_traffic_stats, const MiniRtcNetTrafficStats *net_traffic_stats,
const char *user_id, size_t user_id_size, const char *user_id, size_t user_id_size,
void *user_data); void *user_data);
private: private:
static void DispatchConnectionStatus(ConnectionStatus status, const char* id, static void DispatchConnectionStatus(MiniRtcConnectionStatus status, const char* id,
size_t size, void* context); size_t size, void* context);
static void SendClientInfo(PeerPtr* peer, const std::string& client_id); static void SendClientInfo(MiniRtcPeerPtr* peer, const std::string& client_id);
bool QueueEarlyData(const char* data, size_t size, const char* label, bool QueueEarlyData(const char* data, size_t size, const char* label,
size_t label_size); size_t label_size);
std::weak_ptr<lan::Session> direct_; std::weak_ptr<lan::Session> direct_;
bool direct_bound_ = false; bool direct_bound_ = false;
PeerPtr* direct_peer_ = nullptr; MiniRtcPeerPtr* direct_peer_ = nullptr;
std::mutex early_data_mutex_; std::mutex early_data_mutex_;
std::vector<std::string> early_data_; std::vector<std::string> early_data_;
size_t early_data_bytes_ = 0; size_t early_data_bytes_ = 0;
GuiRuntime& owner_; GuiRuntime& owner_;
DeviceLogin device_login_;
gui_detail::SessionCallbackGate callbacks_; gui_detail::SessionCallbackGate callbacks_;
}; };
@@ -51,7 +51,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) { if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now(); const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included. // Pair the clocks before preview conversion so its cost is included.
const auto local_now_us = GetSystemTimeMicros( const auto local_now_us = MiniRtcGetSystemTimeMicros(
handler->IsDirect() ? handler->DirectPeer() : props->peer_); handler->IsDirect() ? handler->DirectPeer() : props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame); const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record( props->received_video_stats_.Record(
@@ -183,9 +183,11 @@ void PeerEventHandler::OnReceiveVideoBuffer(
return; return;
} }
if (submit_result == VideoRenderer::SubmitResult::dropped || if (submit_result == VideoRenderer::SubmitResult::dropped ||
submit_result == VideoRenderer::SubmitResult::failed) { (submit_result == VideoRenderer::SubmitResult::failed &&
native_renderer->IsActive())) {
// Keep presenting the last native frame. A later decoded frame can // Keep presenting the last native frame. A later decoded frame can
// reuse the renderer without changing ownership mid-window. // reuse an active renderer. If rendering disabled it concurrently,
// continue below to materialize this frame for the CPU fallback.
props->streaming_ = true; props->streaming_ = true;
return; return;
} }
@@ -16,6 +16,7 @@
namespace crossdesk { namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
if (!TrustedControllerAllowed(remote_id)) return false;
if (remote_id.rfind("lan/", 0) == 0) { if (remote_id.rfind("lan/", 0) == 0) {
const auto events = ControllerEvents(remote_id); const auto events = ControllerEvents(remote_id);
if (!events || !events->DataAllowed()) return false; if (!events || !events->DataAllowed()) return false;
@@ -25,7 +26,7 @@ bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id); const auto found = connection_status_.find(remote_id);
return found != connection_status_.end() && return found != connection_status_.end() &&
found->second == ConnectionStatus::Connected; found->second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
} }
void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id, void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
@@ -33,9 +34,9 @@ void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
std::unique_lock lock(connection_status_mutex_); std::unique_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id); const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end()) return; if (found == connection_status_.end()) return;
if (found->second != ConnectionStatus::Connected) { if (found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
if (found->second == ConnectionStatus::Connecting || if (found->second == MiniRtcConnectionStatus::MiniRtcConnectionConnecting ||
found->second == ConnectionStatus::Gathering) { found->second == MiniRtcConnectionStatus::MiniRtcConnectionGathering) {
privacy_sessions_.RememberSupport(remote_id, supported); privacy_sessions_.RememberSupport(remote_id, supported);
LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}", LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}",
remote_id, supported); remote_id, supported);
@@ -59,10 +60,11 @@ void GuiRuntime::ApplyPrivacyAdmission(bool automatic_enable) {
void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id, void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
const PrivacyCommand& command) { const PrivacyCommand& command) {
if (!TrustedControllerAllowed(remote_id)) return;
std::shared_lock session_lock(connection_status_mutex_); std::shared_lock session_lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id); const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() || if (found == connection_status_.end() ||
found->second != ConnectionStatus::Connected || found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected ||
!privacy_sessions_.Supports(remote_id)) return; !privacy_sessions_.Supports(remote_id)) return;
std::lock_guard lock(privacy_commands_mutex_); std::lock_guard lock(privacy_commands_mutex_);
if (privacy_commands_.size() >= 32) return; if (privacy_commands_.size() >= 32) return;
@@ -86,12 +88,13 @@ void GuiRuntime::HandlePrivacy() {
commands.swap(privacy_commands_); commands.swap(privacy_commands_);
} }
for (const auto& [remote_id, command] : commands) { for (const auto& [remote_id, command] : commands) {
if (!TrustedControllerAllowed(remote_id)) continue;
// Keep admission and activation under the connection lock so an unknown // Keep admission and activation under the connection lock so an unknown
// controller cannot join between the capability check and enabling. // controller cannot join between the capability check and enabling.
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id); const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() || if (found == connection_status_.end() ||
found->second != ConnectionStatus::Connected || found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected ||
!privacy_sessions_.Supports(remote_id)) continue; !privacy_sessions_.Supports(remote_id)) continue;
if (command.flag == PrivacyCommandFlag::enable) { if (command.flag == PrivacyCommandFlag::enable) {
if (privacy_sessions_.CanEnable()) if (privacy_sessions_.CanEnable())
@@ -121,7 +124,7 @@ void GuiRuntime::HandlePrivacy() {
privacy_.CancelAutomaticResume(); privacy_.CancelAutomaticResume();
status = privacy_.Snapshot(); status = privacy_.Snapshot();
for (const auto& [id, state] : connection_status_) for (const auto& [id, state] : connection_status_)
if (state == ConnectionStatus::Connected && if (state == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
privacy_sessions_.Supports(id)) privacy_sessions_.Supports(id))
controllers.push_back(id); controllers.push_back(id);
if (!privacy_sessions_.CanEnable()) { if (!privacy_sessions_.CanEnable()) {
@@ -73,6 +73,7 @@ struct FileTransferState {
uint32_t file_id = 0; uint32_t file_id = 0;
uint32_t rate_bps = 0; uint32_t rate_bps = 0;
bool receiving = false; bool receiving = false;
uint32_t wire_file_id = 0;
}; };
std::vector<FileTransferInfo> file_transfer_list_; std::vector<FileTransferInfo> file_transfer_list_;
std::mutex file_transfer_list_mutex_; std::mutex file_transfer_list_mutex_;
@@ -111,8 +112,8 @@ struct RemoteSession {
std::mutex gallery_conversion_mutex_; std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_; std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_; std::shared_ptr<lan::Session> lan_session_;
Params params_; MiniRtcParams params_;
PeerPtr* peer_ = nullptr; MiniRtcPeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
std::atomic<bool> closing_{false}; std::atomic<bool> closing_{false};
// Transport callbacks request tab closure; only the UI thread destroys it. // Transport callbacks request tab closure; only the UI thread destroys it.
@@ -129,7 +130,7 @@ struct RemoteSession {
std::string remote_id_; std::string remote_id_;
bool exit_ = false; bool exit_ = false;
bool signal_connected_ = false; bool signal_connected_ = false;
SignalStatus signal_status_ = SignalStatus::SignalClosed; MiniRtcSignalStatus signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
bool connection_established_ = false; bool connection_established_ = false;
std::atomic<bool> was_connected_{false}; std::atomic<bool> was_connected_{false};
// Transport callbacks publish changes; the UI thread persists history. // Transport callbacks publish changes; the UI thread persists history.
@@ -210,8 +211,8 @@ struct RemoteSession {
bool remote_cursor_state_received_ = false; bool remote_cursor_state_received_ = false;
CursorPresentation cursor_presentation_; CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks. // Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed; std::atomic<MiniRtcConnectionStatus> connection_status_ = MiniRtcConnectionStatus::MiniRtcConnectionClosed;
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode}; std::atomic<MiniRtcTraversalMode> traversal_mode_{MiniRtcTraversalMode::MiniRtcTraversalUnknown};
int fps_ = 0; int fps_ = 0;
int frame_count_ = 0; int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_; std::chrono::steady_clock::time_point last_time_;
+10 -8
View File
@@ -67,11 +67,11 @@ struct RecentConnectionsState {
}; };
struct PeerState { struct PeerState {
SignalStatus signal_status_ = SignalStatus::SignalClosed; MiniRtcSignalStatus signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
std::string signal_status_str_; std::string signal_status_str_;
std::atomic<bool> signal_connected_{false}; std::atomic<bool> signal_connected_{false};
PeerPtr *peer_ = nullptr; MiniRtcPeerPtr *peer_ = nullptr;
PeerPtr *peer_reserved_ = nullptr; MiniRtcPeerPtr *peer_reserved_ = nullptr;
std::string video_primary_label_ = "primary_display"; std::string video_primary_label_ = "primary_display";
std::string video_secondary_label_ = "secondary_display"; std::string video_secondary_label_ = "secondary_display";
std::string audio_label_ = "audio"; std::string audio_label_ = "audio";
@@ -83,7 +83,7 @@ struct PeerState {
std::string file_label_ = kFileStream; std::string file_label_ = kFileStream;
std::string file_feedback_label_ = kFileFeedbackStream; std::string file_feedback_label_ = kFileFeedbackStream;
std::string clipboard_label_ = kClipboardStream; std::string clipboard_label_ = kClipboardStream;
Params params_{}; MiniRtcParams params_{};
}; };
// OS-specific service and permission state is kept separate from transport // OS-specific service and permission state is kept separate from transport
@@ -167,21 +167,23 @@ struct UserSettingsState {
}; };
struct PasswordChangeState { struct PasswordChangeState {
enum class CredentialRecoveryAction {
Idle, RetryActive, PromotePending, ClearPending, ResumePending
};
std::mutex password_change_mutex_; std::mutex password_change_mutex_;
uint64_t next_password_change_request_id_ = 0; uint64_t next_password_change_request_id_ = 0;
bool password_change_pending_ = false; bool password_change_pending_ = false;
bool password_change_result_ready_ = false; bool password_change_result_ready_ = false;
bool password_change_succeeded_ = false; bool password_change_succeeded_ = false;
bool password_change_result_uncertain_ = false; bool password_change_result_uncertain_ = false;
bool password_change_recovery_ = false;
std::chrono::steady_clock::time_point password_change_requested_at_; std::chrono::steady_clock::time_point password_change_requested_at_;
std::string pending_password_change_request_id_; std::string pending_password_change_request_id_;
std::string pending_local_password_; std::string pending_local_password_;
std::string password_change_error_; std::string password_change_error_;
bool credential_recovery_in_progress_ = false; bool credential_recovery_in_progress_ = false;
bool credential_recovery_attempt_pending_ = false; bool credential_recovery_attempt_pending_ = false;
bool credential_recovery_retry_active_ = false; CredentialRecoveryAction credential_recovery_action_ = CredentialRecoveryAction::Idle;
bool credential_recovery_promote_pending_ = false;
bool credential_recovery_clear_pending_ = false;
}; };
struct ConnectionState { struct ConnectionState {
@@ -201,7 +203,7 @@ struct ConnectionState {
std::unordered_map<std::string, PendingReconnect> pending_reconnects_; std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
std::shared_mutex connection_status_mutex_; std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_; std::unordered_map<std::string, MiniRtcConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_; std::unordered_map<std::string, std::string> connection_host_names_;
std::unordered_set<std::string> codec_status_controllers_; std::unordered_set<std::string> codec_status_controllers_;
// Cursor position is sampled asynchronously after remote mouse input has // Cursor position is sampled asynchronously after remote mouse input has
@@ -0,0 +1,233 @@
#include "runtime/trusted_devices.h"
#include <openssl/crypto.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <algorithm>
#include <array>
#include <stdexcept>
#include "private_file.h"
#include "runtime/device_identity_store.h"
namespace crossdesk {
namespace {
constexpr size_t kMaxTrustedDevices = 128;
void Require(bool ok) {
if (!ok) throw std::runtime_error("Invalid or unavailable trusted device identity");
}
std::string Hex(const uint8_t* data, size_t size) {
constexpr char digits[] = "0123456789abcdef";
std::string result;
result.reserve(size * 2);
for (size_t i = 0; i < size; ++i) {
result += digits[data[i] >> 4]; result += digits[data[i] & 15];
}
return result;
}
bool IsHex(const std::string& value, size_t bytes) {
return value.size() == bytes * 2 && std::all_of(value.begin(), value.end(),
[](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
}
std::vector<uint8_t> Unhex(const std::string& text, size_t bytes) {
Require(IsHex(text, bytes));
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
std::vector<uint8_t> result(bytes);
for (size_t i = 0; i < bytes; ++i)
result[i] = (digit(text[2 * i]) << 4) | digit(text[2 * i + 1]);
return result;
}
bool Label(const std::string& value) {
return value.size() <= 128 && std::none_of(value.begin(), value.end(),
[](unsigned char c) { return c < 32 || c == 127; });
}
std::string Transcript(const std::string& nonce, const TrustedDevices::Device& device) {
// Canonical encoding and a domain tag prevent ambiguous or cross-protocol use.
return nlohmann::json::array({"crossdesk.device-trust.v1", nonce, device.public_key,
device.device_id, device.name}).dump();
}
using Context = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>;
struct PrivateText {
std::string value;
~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); }
};
}
TrustedDevices::TrustedDevices(const std::filesystem::path& directory,
const std::filesystem::path& cache)
: path_(directory / "devices.json") {
private_file::EnsureDirectory(directory);
const bool exists = std::filesystem::symlink_status(path_).type() !=
std::filesystem::file_type::not_found;
PrivateText legacy_key;
if (exists) {
PrivateText text{private_file::Read(path_)};
auto saved = nlohmann::json::parse(text.value, nullptr, false);
Require(!saved.is_discarded());
const bool legacy = saved.at("version") == 1;
Require((legacy || saved.at("version") == 2) && saved.at("devices").is_array() &&
saved.at("devices").size() <= kMaxTrustedDevices);
if (legacy) {
legacy_key.value = saved.at("private_key").get<std::string>();
auto& encoded = saved["private_key"].get_ref<std::string&>();
OPENSSL_cleanse(encoded.data(), encoded.size());
Require(IsHex(legacy_key.value, 32));
} else Require(!saved.contains("private_key"));
for (const auto& row : saved.at("devices")) {
Device device{row.at("public_key"), row.at("name"), row.at("device_id")};
Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id));
Require(devices_.emplace(device.public_key, device).second);
}
}
device_identity_store::Update(cache, [&](nlohmann::json& identity) {
if (!identity.contains("trust_private_key") && !legacy_key.value.empty())
identity["trust_private_key"] = legacy_key.value;
if (identity.contains("trust_private_key")) {
const auto& encoded = identity.at("trust_private_key").get_ref<const std::string&>();
// Interrupted migration may leave both copies. Never replace either key
// on a mismatch: that would silently invalidate existing pairings.
Require(legacy_key.value.empty() || encoded == legacy_key.value);
auto secret = Unhex(encoded, 32);
key_.reset(EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr,
secret.data(), secret.size()));
OPENSSL_cleanse(secret.data(), secret.size());
} else {
key_.reset(EVP_PKEY_Q_keygen(nullptr, nullptr, "ED25519"));
Require(bool(key_));
std::array<uint8_t, 32> secret{};
size_t size = secret.size();
Require(EVP_PKEY_get_raw_private_key(key_.get(), secret.data(), &size) == 1 && size == 32);
identity["trust_private_key"] = Hex(secret.data(), secret.size());
OPENSSL_cleanse(secret.data(), secret.size());
}
Require(bool(key_));
});
std::array<uint8_t, 32> public_key{};
size_t size = public_key.size();
Require(EVP_PKEY_get_raw_public_key(key_.get(), public_key.data(), &size) == 1 && size == 32);
public_key_ = Hex(public_key.data(), public_key.size());
// The identity update is durable before removing the legacy secret. If this
// write fails, reopening can safely retry with the same key and device list.
if (!exists || !legacy_key.value.empty()) Save(devices_);
}
std::string TrustedDevices::Fingerprint(const std::string& public_key) {
const auto bytes = Unhex(public_key, 32);
std::array<uint8_t, SHA256_DIGEST_LENGTH> hash{};
SHA256(bytes.data(), bytes.size(), hash.data());
return Hex(hash.data(), hash.size());
}
std::string TrustedDevices::Fingerprint() const { return Fingerprint(public_key_); }
std::string TrustedDevices::PairingInfo(const std::string& device_id,
const std::string& name) const {
Require(Label(device_id) && Label(name));
// Export public information only. Connection admission still requires a fresh
// proof of private-key possession; importing this document grants no bypass.
return nlohmann::json{{"type", "crossdesk-pairing"}, {"version", 1},
{"public_key", public_key_}, {"device_id", device_id}, {"name", name}}.dump();
}
std::optional<TrustedDevices::Device> TrustedDevices::ParsePairingInfo(std::string_view text) {
if (text.empty() || text.size() > kMaxPairingInfoSize) return std::nullopt;
try {
const auto info = nlohmann::json::parse(text.begin(), text.end(), nullptr, false);
if (!info.is_object() || info.size() != 5 || info.at("type") != "crossdesk-pairing" ||
!info.at("version").is_number_integer() || info.at("version") != 1)
return std::nullopt;
Device device{info.at("public_key"), info.at("name"), info.at("device_id")};
if (IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id))
return device;
} catch (const std::exception&) {}
return std::nullopt;
}
std::string TrustedDevices::Nonce() {
std::array<uint8_t, 32> bytes{};
Require(RAND_bytes(bytes.data(), bytes.size()) == 1);
return Hex(bytes.data(), bytes.size());
}
nlohmann::json TrustedDevices::Prove(const std::string& nonce,
const std::string& device_id, const std::string& name) const {
Require(IsHex(nonce, 32) && Label(device_id) && Label(name));
const Device device{public_key_, name, device_id};
const auto text = Transcript(nonce, device);
return {{"type", "device_trust_proof"}, {"version", 1}, {"nonce", nonce},
{"public_key", public_key_}, {"device_id", device_id}, {"name", name},
{"signature", Sign(text)}};
}
std::string TrustedDevices::Sign(std::string_view text) const {
Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
std::array<uint8_t, 64> signature{};
size_t size = signature.size();
Require(context && EVP_DigestSignInit(context.get(), nullptr, nullptr, nullptr, key_.get()) == 1 &&
EVP_DigestSign(context.get(), signature.data(), &size,
reinterpret_cast<const uint8_t*>(text.data()), text.size()) == 1 && size == 64);
return Hex(signature.data(), signature.size());
}
std::optional<TrustedDevices::Device> TrustedDevices::Verify(
const nlohmann::json& proof, const std::string& nonce) {
try {
if (proof.at("version") != 1 || proof.at("nonce") != nonce || !IsHex(nonce, 32))
return std::nullopt;
Device device{proof.at("public_key"), proof.at("name"), proof.at("device_id")};
if (!Label(device.name) || !Label(device.device_id)) return std::nullopt;
const auto public_key = Unhex(device.public_key, 32);
const auto signature = Unhex(proof.at("signature"), 64);
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, public_key.data(), public_key.size()),
EVP_PKEY_free);
Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
const auto text = Transcript(nonce, device);
if (key && context && EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) == 1 &&
EVP_DigestVerify(context.get(), signature.data(), signature.size(),
reinterpret_cast<const uint8_t*>(text.data()), text.size()) == 1)
return device;
} catch (const std::exception&) {}
return std::nullopt;
}
bool TrustedDevices::Contains(const std::string& public_key) const {
std::lock_guard lock(mutex_);
return devices_.count(public_key) != 0;
}
std::vector<TrustedDevices::Device> TrustedDevices::List() const {
std::lock_guard lock(mutex_);
std::vector<Device> result;
for (const auto& [key, device] : devices_) result.push_back(device);
return result;
}
void TrustedDevices::Save(const std::map<std::string, Device>& devices) const {
nlohmann::json saved{{"version", 2}, {"devices", nlohmann::json::array()}};
for (const auto& [key, device] : devices)
saved["devices"].push_back({{"public_key", key}, {"name", device.name}, {"device_id", device.device_id}});
const auto text = saved.dump(2);
Require(text.size() <= 64 * 1024);
private_file::Write(path_, text);
}
void TrustedDevices::Add(const Device& device) {
Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id));
std::lock_guard lock(mutex_);
const auto existing = devices_.find(device.public_key);
if (existing != devices_.end() && existing->second == device) return;
Require(existing != devices_.end() || devices_.size() < kMaxTrustedDevices);
auto next = devices_;
next[device.public_key] = device;
Save(next);
devices_.swap(next);
}
void TrustedDevices::Remove(const std::string& public_key) {
std::lock_guard lock(mutex_);
if (!devices_.contains(public_key)) return;
auto next = devices_;
next.erase(public_key);
Save(next);
devices_.swap(next);
}
} // namespace crossdesk
@@ -0,0 +1,63 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TRUSTED_DEVICES_H_
#define _TRUSTED_DEVICES_H_
#include <openssl/evp.h>
#include <filesystem>
#include <map>
#include <memory>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace crossdesk {
class TrustedDevices {
public:
struct Device {
std::string public_key, name, device_id;
bool operator==(const Device&) const = default;
};
TrustedDevices(const std::filesystem::path& directory,
const std::filesystem::path& cache);
std::string Fingerprint() const;
const std::string& PublicKey() const { return public_key_; }
// Callers supply a canonical, purpose-specific transcript.
std::string Sign(std::string_view transcript) const;
static std::string Fingerprint(const std::string& public_key);
static std::string Nonce();
nlohmann::json Prove(const std::string& nonce,
const std::string& device_id, const std::string& name) const;
static std::optional<Device> Verify(const nlohmann::json& proof,
const std::string& nonce);
static constexpr size_t kMaxPairingInfoSize = 2048;
std::string PairingInfo(const std::string& device_id, const std::string& name) const;
static std::optional<Device> ParsePairingInfo(std::string_view text);
bool Contains(const std::string& public_key) const;
std::vector<Device> List() const;
// Call only after a local add action for a verified peer or valid pairing info.
void Add(const Device& device);
void Remove(const std::string& public_key);
private:
void Save(const std::map<std::string, Device>& devices) const;
std::filesystem::path path_;
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key_{nullptr, EVP_PKEY_free};
std::string public_key_;
mutable std::mutex mutex_;
std::map<std::string, Device> devices_;
};
} // namespace crossdesk
#endif
+1
View File
@@ -89,6 +89,7 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}"; out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}"; out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}"; out property <string> arrow-right-long: "\u{f178}";
out property <string> arrow-up: "\u{f062}";
out property <string> arrow-up-right-from-square: "\u{f35d}"; out property <string> arrow-up-right-from-square: "\u{f35d}";
out property <string> bars: "\u{f0c9}"; out property <string> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}"; out property <string> check: "\u{f00c}";
@@ -12,3 +12,7 @@ export { GalleryStrings } from "device_strings.slint";
export { LanStrings } from "lan_strings.slint"; export { LanStrings } from "lan_strings.slint";
export { GalleryNetworkDevice } from "gallery_network_dialog.slint"; export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
export { DeviceGroupOption } from "device_group_dialog.slint"; export { DeviceGroupOption } from "device_group_dialog.slint";
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
export { FileBrowserWindow, BrowserEntry, BrowserTreeEntry, BrowserTask } from "file_browser.slint";
+839
View File
@@ -0,0 +1,839 @@
import { ScrollView, ListView, Spinner } from "std-widgets.slint";
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint";
import { FlatSurface, UiSurface, SurfaceSeparator } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { FontAwesomeIcons, ControlStyle, ImGuiFontStyle } from "common.slint";
export struct BrowserEntry {
name: string, path: string, directory: bool, selected: bool,
size: string, modified: string, link: bool,
}
export struct BrowserTreeEntry {
name: string, path: string, depth: int, expanded: bool, selected: bool,
}
export struct BrowserTask {
id: int, name: string, size: string, detail: string, status: string,
progress: float, active: bool, failed: bool, can-cancel: bool, receiving: bool,
}
component BrowserButton inherits WorkbenchButton {
mouse-cursor: default;
}
component BrowserScrollView inherits Rectangle {
// Fluent overlays a 14px scrollbar on its viewport. Keep a 4px gap between
// that track and row content, including while the scrollbar expands on hover.
out property <length> content-gutter: 18px;
horizontal-stretch: 1;
vertical-stretch: 1;
clip: true;
ScrollView {
x: 0px; y: 0px; width: root.width; height: root.height;
viewport-width: self.visible-width;
horizontal-scrollbar-policy: always-off;
@children
}
}
// Keep the visual compact while retaining a generous pointer target.
// Selection stays model-owned so refreshing rows cannot toggle it accidentally.
component BrowserCheckBox inherits Rectangle {
in property <bool> checked;
in property <bool> indeterminate;
in property <bool> enabled: true;
in property <string> label;
in property <bool> show-label: false;
private property <bool> keyboard-focus: false;
callback toggled;
width: 24px; height: 24px;
forward-focus: focus;
accessible-role: checkbox;
accessible-label: root.label;
accessible-checked: root.checked;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.toggled(); } }
box := FlatSurface {
x: 4px; y: (parent.height - self.height) / 2; width: 16px; height: 16px;
border-radius: 4px;
background: !root.enabled ? #f4f6f9
: touch.pressed ? #dce7fb
: root.checked || root.indeterminate ? (touch.has-hover ? #dfe9fc : #edf2fc)
: touch.has-hover ? #f5f8fe : white;
outline-color: !root.enabled ? #dce2eb
: touch.has-hover || touch.pressed ? #7d9edb
: root.checked || root.indeterminate ? #a6bce6 : #b7c3d4;
animate background, outline-color { duration: 100ms; }
if root.checked && !root.indeterminate: Path {
width: parent.width; height: parent.height;
viewbox-width: 16; viewbox-height: 16;
commands: "M 4 8 L 6.75 10.75 L 12 5.5";
stroke: root.enabled ? WorkbenchStyle.accent : #aab5c6;
stroke-width: 1.7px; stroke-line-cap: round; stroke-line-join: round;
}
if root.indeterminate: Rectangle {
x: 4px; y: 7px; width: 8px; height: 2px; border-radius: 1px;
background: root.enabled ? WorkbenchStyle.accent : #aab5c6;
}
}
if root.show-label: Text {
x: 30px; width: parent.width - self.x; height: parent.height;
text: root.label; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base;
vertical-alignment: center; overflow: elide; accessible-role: none;
}
if root.keyboard-focus && focus.has-focus: FlatSurface {
x: 1px; y: box.y - 3px; width: 22px; height: 22px;
border-radius: 6px; outline-color: UiSurface.focus;
}
focus := FocusScope {
enabled: root.enabled;
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
focus-lost => { root.keyboard-focus = false; }
key-pressed(event) => {
if event.text == " " || event.text == Key.Return {
root.keyboard-focus = true; root.toggled(); return accept;
}
reject
}
touch := TouchArea {
enabled: root.enabled; mouse-cursor: default;
pointer-event(event) => {
if event.kind == PointerEventKind.down { root.keyboard-focus = false; }
}
clicked => { focus.focus(); root.keyboard-focus = false; root.toggled(); }
}
}
}
component BrowserSortHeader inherits FlatSurface {
in property <string> text;
in property <bool> chinese;
in property <bool> active;
in property <bool> descending;
in property <bool> default-descending;
in property <bool> right-aligned;
in property <bool> enabled: true;
private property <bool> keyboard-focus: false;
private property <bool> next-descending: root.active ? !root.descending : root.default-descending;
callback clicked;
height: 28px;
border-radius: UiRadius.control;
outline-enabled: focus.has-focus && root.keyboard-focus;
outline-color: UiSurface.focus;
background: touch.pressed ? ControlStyle.pressed.with-alpha(0.3)
: touch.has-hover && root.enabled ? ControlStyle.hover.with-alpha(0.2) : transparent;
forward-focus: focus;
accessible-role: button;
accessible-enabled: root.enabled;
accessible-label: root.text + (root.active ? (root.descending ? (root.chinese ? ",降序" : ", descending") : (root.chinese ? ",升序" : ", ascending")) : "")
+ (root.next-descending ? (root.chinese ? ",点击按降序排列" : ", sort descending") : (root.chinese ? ",点击按升序排列" : ", sort ascending"));
accessible-action-default => { if root.enabled { root.clicked(); } }
label := Text {
x: root.right-aligned ? parent.width - self.width - (root.active ? 14px : 0px) : 0px;
width: min(self.preferred-width, parent.width - (root.active ? 14px : 0px));
height: parent.height;
text: root.text; font-size: ImGuiFontStyle.body; font-weight: root.active ? 600 : 400;
color: root.active ? WorkbenchStyle.text : WorkbenchStyle.muted;
vertical-alignment: center; overflow: elide; accessible-role: none;
}
if root.active: Text {
x: label.x + label.width + 4px; width: 10px; height: parent.height;
text: root.descending ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
color: WorkbenchStyle.muted; vertical-alignment: center; accessible-role: none;
}
focus := FocusScope {
enabled: root.enabled;
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
focus-lost => { root.keyboard-focus = false; }
key-pressed(event) => {
if event.text == " " || event.text == Key.Return {
root.keyboard-focus = true; root.clicked(); return accept;
}
reject
}
touch := TouchArea {
enabled: root.enabled; mouse-cursor: default;
pointer-event(event) => { if event.kind == PointerEventKind.down { root.keyboard-focus = false; } }
clicked => { focus.focus(); root.keyboard-focus = false; root.clicked(); }
}
}
}
component BrowserPane inherits WorkbenchCard {
in property <bool> chinese;
in property <string> open-directory-text;
in property <string> heading;
in property <string> subtitle;
in-out property <string> path;
in property <string> current-path;
in-out property <string> filter-text;
in property <string> status;
in property <string> error;
in property <bool> busy;
in property <bool> enabled: true;
in property <bool> can-up;
in property <bool> all-selected;
in property <bool> show-hidden;
in property <int> selected-count;
in property <bool> destination;
in property <bool> transfer-ready;
in property <int> selection-revision;
in property <int> drag-count;
in property <bool> dragging;
in property <bool> drop-hover;
in property <[BrowserEntry]> entries;
in property <[BrowserTreeEntry]> tree;
in property <int> sort-column;
in property <bool> sort-descending;
private property <length> modified-column-width: 72px;
private property <length> size-column-width: 56px;
outline-color: root.destination ? UiSurface.hover : UiSurface.edge;
callback navigate(string);
callback up;
callback select-entry(int);
callback open-entry(int);
callback open-directory(string);
callback select-all;
callback tree-open(int);
callback tree-toggle(int);
callback filter(string);
callback toggle-hidden;
callback sort(int);
pure callback drag-data(string, int) -> data-transfer;
callback drag-start(data-transfer, BrowserEntry);
callback drag-move(length, length);
callback drag-end;
callback drag-cancel;
public pure function contains-drop(x: length, y: length) -> bool {
return root.enabled && root.transfer-ready
&& x >= body.absolute-position.x && x < body.absolute-position.x + body.width
&& y >= body.absolute-position.y && y < body.absolute-position.y + body.height;
}
changed enabled => { if !root.enabled { directory-popup.close(); } }
changed dragging => { if root.dragging { directory-popup.close(); } }
changed current-path => { file-list.reset-scroll(); }
changed filter-text => { file-list.reset-scroll(); }
changed sort-column => { file-list.reset-scroll(); }
changed sort-descending => { file-list.reset-scroll(); }
VerticalLayout {
padding-left: 14px; padding-right: 14px;
padding-top: 12px; padding-bottom: 12px; spacing: 6px;
HorizontalLayout {
height: 24px; spacing: 8px;
Text { text: root.heading; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.prominent; font-weight: 600; vertical-alignment: center; }
Text {
horizontal-stretch: 1;
text: root.destination ? (root.chinese ? "接收目录" : "Destination")
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项" : root.selected-count + " selected") : root.subtitle;
color: root.destination ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; horizontal-alignment: right; vertical-alignment: center; overflow: elide;
}
RoundedLineEdit {
width: min(164px, root.width * 0.4); height: 24px;
text <=> root.filter-text; tint-opacity: 0.15;
placeholder-text: root.chinese ? "筛选名称…" : "Filter by name…";
enabled: root.enabled && !root.busy && !root.dragging;
edited => { root.filter(self.text); }
accepted => { self.clear-focus(); }
}
}
HorizontalLayout {
height: 32px; spacing: 6px;
BrowserButton {
width: 32px; height: 32px; quiet: true; icon: FontAwesomeIcons.arrow-up;
tooltip: root.chinese ? "打开上一级目录" : "Open parent folder";
tooltip-above: true; tooltip-align-left: true;
accessible-label: self.tooltip;
enabled: root.can-up && !root.busy && root.enabled && !root.dragging;
clicked => { root.up(); }
}
path-focus := FocusScope {
horizontal-stretch: 1; height: 32px;
forward-focus: path-editor;
key-pressed(event) => {
if event.text == Key.Escape {
root.path = root.current-path;
path-editor.clear-focus();
return accept;
}
reject
}
path-editor := RoundedLineEdit {
width: parent.width; height: parent.height; text <=> root.path;
tint-opacity: 0.2;
enabled: root.enabled && !root.dragging;
placeholder-text: root.chinese ? "输入路径,按回车打开" : "Enter a folder path";
accepted => { root.navigate(self.text); self.clear-focus(); }
}
}
tree-trigger := BrowserButton {
width: 28px; height: 32px; quiet: true; icon: FontAwesomeIcons.folder-open;
enabled: root.enabled && !root.dragging;
accessible-label: root.chinese ? "浏览目录树" : "Browse folder tree";
clicked => { if directory-popup.is-open { directory-popup.close(); } else { directory-popup.show(); } }
}
}
body := Rectangle {
vertical-stretch: 1;
Rectangle {
width: parent.width; height: parent.height;
VerticalLayout {
spacing: 0px;
Rectangle {
height: 28px; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
HorizontalLayout {
padding-left: 6px; padding-right: 8px + file-list.content-gutter; spacing: 8px;
BrowserCheckBox {
width: 24px; height: 28px; checked: root.all-selected;
indeterminate: root.selected-count > 0 && !root.all-selected;
label: (root.chinese ? "选择全部" : "Select all")
+ (self.indeterminate ? (root.chinese ? ",部分已选" : ", partially selected") : "");
enabled: root.enabled && root.entries.length > 0 && !root.busy && root.error == "" && !root.dragging;
toggled => { root.select-all(); }
}
BrowserSortHeader {
horizontal-stretch: 1; text: root.chinese ? "名称" : "Name"; chinese: root.chinese;
active: root.sort-column == 0; descending: root.sort-descending;
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
clicked => { root.sort(0); }
}
BrowserSortHeader {
width: root.modified-column-width; text: root.chinese ? "修改日期" : "Modified"; chinese: root.chinese;
active: root.sort-column == 1; descending: root.sort-descending; default-descending: true; right-aligned: true;
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
clicked => { root.sort(1); }
}
BrowserSortHeader {
width: root.size-column-width; text: root.chinese ? "大小" : "Size"; chinese: root.chinese;
active: root.sort-column == 2; descending: root.sort-descending; default-descending: true; right-aligned: true;
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
clicked => { root.sort(2); }
}
}
}
file-list := Rectangle {
out property <length> content-gutter: 18px;
public function reset-scroll() { visible-files.viewport-y = 0px; }
vertical-stretch: 1;
clip: true;
visible-files := ListView {
width: parent.width; height: parent.height;
viewport-width: self.visible-width;
horizontal-scrollbar-policy: always-off;
for entry[index] in root.entries: Rectangle {
x: 0px; width: file-list.width - file-list.content-gutter;
height: 28px;
border-radius: UiRadius.control;
background: entry.selected
? (row-touch.pressed ? ControlStyle.selected-pressed.with-alpha(0.4) : row-touch.has-hover ? ControlStyle.selected-hover.with-alpha(0.35) : ControlStyle.selected.with-alpha(0.3))
: row-touch.pressed ? ControlStyle.pressed.with-alpha(0.3) : row-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
opacity: root.dragging && row-touch.drag-started ? 0.55 : 1;
folder-menu := ContextMenuArea {
enabled: entry.directory && root.enabled && !root.busy && root.error == "" && !root.dragging;
Menu {
MenuItem {
title: root.open-directory-text;
activated => { root.open-directory(entry.path); }
}
}
HorizontalLayout {
padding-left: 6px; padding-right: 8px; spacing: 8px;
Rectangle { width: 24px; }
Text { width: 17px; text: entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 14px; color: entry.directory ? WorkbenchStyle.accent : ControlStyle.icon; vertical-alignment: center; }
Text { text: entry.name; color: entry.link ? WorkbenchStyle.muted : entry.selected ? ControlStyle.selected-text : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
Text { width: root.modified-column-width; text: entry.modified != "" ? entry.modified : "—"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; horizontal-alignment: right; }
Text { width: root.size-column-width; text: entry.directory ? "—" : entry.size; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
}
row-touch := TouchArea {
// Only the row body starts drags; checkboxes remain selection controls.
x: 34px; width: parent.width - 34px; height: parent.height;
enabled: root.enabled && !root.busy && root.error == "";
private property <bool> drag-armed;
out property <bool> drag-started;
// Keep pointer capture throughout the in-window drag, so the cursor
// remains a closed hand instead of DragArea's copy/no-drop cursor.
mouse-cursor: self.drag-started && root.dragging ? MouseCursor.grabbing : MouseCursor.default;
function update-drag-position() {
root.drag-move(self.absolute-position.x + self.mouse-x,
self.absolute-position.y + self.mouse-y);
}
pointer-event(event) => {
if event.button == PointerEventButton.left && event.kind == PointerEventKind.down {
self.drag-armed = root.transfer-ready && !entry.link;
self.drag-started = false;
} else if event.kind == PointerEventKind.cancel {
self.drag-armed = false;
if self.drag-started && root.dragging { root.drag-cancel(); }
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
self.drag-armed = false;
if self.drag-started && root.dragging {
self.update-drag-position();
root.drag-end();
}
} else if event.button == PointerEventButton.right && event.kind == PointerEventKind.up && folder-menu.enabled {
folder-menu.show({ x: self.mouse-x + 34px, y: self.mouse-y });
}
}
moved => {
if self.drag-armed && !self.drag-started && root.transfer-ready
&& (abs(self.mouse-x - self.pressed-x) >= 5px || abs(self.mouse-y - self.pressed-y) >= 5px) {
self.drag-started = true;
root.drag-start(root.drag-data(entry.path, root.selection-revision), entry);
}
if self.drag-started && root.dragging { self.update-drag-position(); }
}
clicked => { if !self.drag-started { root.select-entry(index); } }
double-clicked => { if !self.drag-started { root.open-entry(index); } }
}
BrowserCheckBox { x: 6px; y: (parent.height - self.height) / 2; width: 24px; checked: entry.selected; label: (root.chinese ? "选择 " : "Select ") + entry.name; enabled: !entry.link && root.enabled && !root.busy && root.error == "" && !root.dragging; toggled => { root.select-entry(index); } }
}
}
}
}
}
}
if root.entries.length == 0 && !root.busy && root.error == "": VerticalLayout {
x: 12px; y: 34px; width: parent.width - 24px; height: parent.height - 40px;
alignment: center; spacing: 10px;
Text {
text: root.filter-text != "" ? (root.chinese ? "没有匹配的项目" : "No matching items")
: (root.chinese ? "此目录为空" : "This folder is empty");
color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center;
}
if root.filter-text != "": HorizontalLayout {
alignment: center;
BrowserButton { width: 112px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "清空筛选" : "Clear filter"; clicked => { root.filter-text = ""; root.filter(""); } }
}
}
if root.error != "" && !root.busy: Rectangle {
width: parent.width; height: parent.height; background: WorkbenchStyle.surface;
TouchArea {}
VerticalLayout {
padding: 16px; spacing: 10px; alignment: center;
Text { text: root.chinese ? "无法读取目录" : "Unable to open folder"; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
Text { text: root.error; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; }
HorizontalLayout {
alignment: center;
BrowserButton { width: 128px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "打开主目录" : "Open home folder"; clicked => { root.navigate(""); } }
}
}
}
if root.busy: Rectangle {
width: parent.width; height: parent.height; background: WorkbenchStyle.surface.with-alpha(0.9);
VerticalLayout { alignment: center; spacing: 12px; Spinner { width: 24px; height: 24px; } Text { text: root.status; horizontal-alignment: center; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; } }
}
if root.drop-hover: FlatSurface {
width: parent.width; height: parent.height;
background: ControlStyle.selected.with-alpha(0.3); outline-color: UiSurface.focus; border-radius: UiRadius.control;
Rectangle {
x: 8px; y: max(6px, parent.height - 68px); width: parent.width - 16px; height: 60px;
background: ControlStyle.surface; border-radius: UiRadius.control;
VerticalLayout {
padding: 9px; spacing: 4px;
Text { text: root.chinese ? "松手复制 " + root.drag-count + " 项到此目录" : "Release to copy " + root.drag-count + " items here"; color: WorkbenchStyle.accent; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
Text { text: root.path; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.body; overflow: elide; horizontal-alignment: center; }
}
}
}
}
Text {
height: 16px;
text: root.busy ? root.status
: root.path != root.current-path ? (root.chinese ? "Enter 打开路径 · Esc 还原" : "Enter to open · Esc to restore")
: root.error != "" ? (root.chinese ? "目录不可用" : "Folder unavailable")
: root.filter-text != "" ? (root.chinese ? "筛选结果 · " : "Filtered · ") + root.status : root.status;
color: root.path != root.current-path ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; overflow: elide;
}
}
directory-popup := PopupWindow {
x: 14px; y: 78px; width: root.width - 28px; height: min(330px, max(180px, root.height - 90px));
close-policy: close-on-click-outside;
FlatSurface {
width: parent.width; height: parent.height; background: WorkbenchStyle.surface; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
VerticalLayout {
padding: 12px; spacing: 10px;
HorizontalLayout {
height: 28px; spacing: 8px;
BrowserButton { text: root.chinese ? "主目录" : "Home"; quiet: true; height: 28px; clicked => { root.navigate(""); directory-popup.close(); } }
BrowserButton { text: root.chinese ? "磁盘" : "Drives"; quiet: true; height: 28px; clicked => { root.navigate("@roots"); directory-popup.close(); } }
}
tree-list := Rectangle {
out property <length> content-gutter: 18px;
vertical-stretch: 1;
clip: true;
ListView {
width: parent.width; height: parent.height;
viewport-width: self.visible-width;
horizontal-scrollbar-policy: always-off;
for node[index] in root.tree: Rectangle {
x: 0px; width: tree-list.width - tree-list.content-gutter;
height: 29px; border-radius: UiRadius.control;
background: node.selected ? ControlStyle.selected.with-alpha(0.3) : tree-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
Text { x: 24px + min(node.depth, 8) * 12px; width: parent.width - self.x - 4px; height: parent.height; text: node.name; color: node.selected ? WorkbenchStyle.accent : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide; }
tree-touch := TouchArea { clicked => { root.tree-open(index); directory-popup.close(); } }
BrowserButton { x: min(node.depth, 8) * 12px; width: 22px; height: 28px; quiet: true; icon: node.expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; clicked => { root.tree-toggle(index); } }
}
}
}
BrowserCheckBox { width: parent.width; show-label: true; label: root.chinese ? "显示隐藏项" : "Show hidden files"; checked: root.show-hidden; toggled => { root.toggle-hidden(); } }
}
}
}
}
export component FileBrowserWindow inherits Window {
title: root.chinese ? "文件传输" : "File transfer";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 900px; preferred-height: 620px;
min-width: 760px; min-height: 500px;
background: WorkbenchStyle.canvas;
default-font-size: ImGuiFontStyle.base;
in property <bool> chinese: true;
in property <string> send-local-text: root.chinese ? "发送到本机" : "Send to local";
in property <string> send-remote-text: root.chinese ? "发送到远端" : "Send to remote";
in property <string> clear-completed-text: root.chinese ? "清除已完成" : "Clear completed";
in property <string> open-directory-text: root.chinese ? "打开目录" : "Open folder";
in property <bool> connected: true;
in property <string> peer-name;
in property <string> local-role;
in property <string> remote-role;
in-out property <string> local-path;
in-out property <string> remote-path;
in property <string> local-current-path;
in property <string> remote-current-path;
in-out property <string> local-filter;
in-out property <string> remote-filter;
in property <string> local-status;
in property <string> remote-status;
in property <string> local-error;
in property <string> remote-error;
in property <bool> local-busy;
in property <bool> remote-busy;
in property <bool> local-up;
in property <bool> remote-up;
in property <bool> local-all;
in property <bool> remote-all;
in property <bool> local-hidden;
in property <bool> remote-hidden;
in property <int> local-sort-column;
in property <int> remote-sort-column;
in property <bool> local-sort-descending;
in property <bool> remote-sort-descending;
in property <int> local-selected;
in property <int> remote-selected;
in property <[BrowserEntry]> local-entries;
in property <[BrowserEntry]> remote-entries;
in property <[BrowserTreeEntry]> local-tree;
in property <[BrowserTreeEntry]> remote-tree;
in property <[BrowserTask]> tasks;
in property <string> task-summary;
in property <string> notice;
in property <bool> notice-error;
in property <bool> can-upload;
in property <bool> can-download;
in property <bool> source-remote;
in property <bool> transfer-ready;
in property <int> selection-revision;
in property <bool> drag-active;
in property <int> drag-count;
private property <data-transfer> drag-payload;
private property <BrowserEntry> drag-entry;
private property <length> drag-x;
private property <length> drag-y;
private property <int> drop-target: -1;
changed drag-active => { if !root.drag-active { root.drop-target = -1; } }
in-out property <bool> tasks-expanded: false;
private property <length> task-list-max-height: min(144px, max(72px, root.height - 440px));
private property <int> selected-count: root.local-selected + root.remote-selected;
private property <bool> path-pending: root.local-path != root.local-current-path || root.remote-path != root.remote-current-path;
private property <bool> notice-visible: false;
changed notice => { root.notice-visible = root.notice != ""; }
Timer {
interval: 3500ms;
running: root.notice-visible && !root.notice-error;
triggered => { root.notice-visible = false; }
}
callback navigate(bool, string);
callback refresh(bool);
callback up(bool);
callback select-entry(bool, int);
callback open-entry(bool, int);
callback open-directory(bool, string);
callback select-all(bool);
callback clear-selection;
callback tree-open(bool, int);
callback tree-toggle(bool, int);
callback filter(bool, string);
callback toggle-hidden(bool);
callback sort(bool, int);
callback transfer(bool);
callback cancel-task(int);
callback clear-finished;
pure callback drag-data(bool, string, int) -> data-transfer;
callback drag-state(data-transfer, bool);
callback can-drop(bool, data-transfer) -> bool;
callback dropped(bool, data-transfer) -> bool;
callback cancel-drag;
function start-drag(data: data-transfer, entry: BrowserEntry) {
root.drag-payload = data;
root.drag-entry = entry;
root.drop-target = -1;
root.drag-state(data, true);
}
function move-drag(x: length, y: length) {
if !root.drag-active { return; }
root.drag-x = x;
root.drag-y = y;
root.drop-target = local-pane.contains-drop(x, y) && root.can-drop(false, root.drag-payload) ? 0
: remote-pane.contains-drop(x, y) && root.can-drop(true, root.drag-payload) ? 1 : -1;
}
function finish-drag() {
if !root.drag-active { return; }
// The payload stays fixed from press-and-drag through release; C++ validates
// its source, destination, session and generation again before copying.
let data = root.drag-payload;
if root.drop-target >= 0 { root.dropped(root.drop-target == 1, data); }
root.drag-state(data, false);
}
forward-focus: focus;
SurfaceSeparator { x: 0px; y: 0px; width: parent.width; }
focus := FocusScope {
focus-on-click: true;
// Window sizing is defined above. Keep its layout constraints separate
// from children whose dimensions adapt to the current window size.
x: 0px; y: 0px; width: root.width; height: root.height;
key-pressed(event) => {
if event.text == Key.Escape { root.cancel-drag(); return accept; }
reject
}
focus-lost => { if root.drag-active { root.cancel-drag(); } }
VerticalLayout {
spacing: 12px; padding: 16px;
HorizontalLayout {
height: 32px; spacing: 12px;
Text { text: root.chinese ? "文件传输" : "File transfer"; font-size: ImGuiFontStyle.section-title; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; horizontal-stretch: 1; }
Text { text: root.peer-name; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: ImGuiFontStyle.base; overflow: elide; }
Rectangle {
width: root.chinese ? 104px : 144px;
BrowserButton {
y: (parent.height - self.height) / 2;
width: parent.width; height: 28px; icon: FontAwesomeIcons.rotate;
text: root.chinese ? "刷新目录" : "Refresh folders";
text-size: ImGuiFontStyle.base;
accessible-label: root.chinese ? "刷新双方目录" : "Refresh both folders";
enabled: root.connected && !root.drag-active && !root.local-busy && !root.remote-busy;
clicked => { root.refresh(false); root.refresh(true); }
}
}
}
HorizontalLayout {
vertical-stretch: 1; spacing: 12px;
local-pane := BrowserPane {
width: (parent.width - 12px) / 2;
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "本机" : "Local"; subtitle: root.local-role;
path <=> root.local-path; status: root.local-status; error: root.local-error; busy: root.local-busy;
open-directory-text: root.open-directory-text;
open-directory(path) => { root.open-directory(false, path); }
current-path: root.local-current-path; filter-text <=> root.local-filter;
can-up: root.local-up; all-selected: root.local-all; entries: root.local-entries; tree: root.local-tree;
show-hidden: root.local-hidden; toggle-hidden => { root.toggle-hidden(false); }
sort-column: root.local-sort-column; sort-descending: root.local-sort-descending;
sort(column) => { root.sort(false, column); }
selected-count: root.local-selected; destination: root.remote-selected > 0;
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
drop-hover: root.drag-active && root.drop-target == 0;
navigate(path) => { root.navigate(false, path); } up => { root.up(false); }
select-entry(index) => { root.select-entry(false, index); } open-entry(index) => { root.open-entry(false, index); }
select-all => { root.select-all(false); } tree-open(index) => { root.tree-open(false, index); } tree-toggle(index) => { root.tree-toggle(false, index); }
filter(text) => { root.filter(false, text); }
drag-data(path, revision) => { return root.drag-data(false, path, revision); }
drag-start(data, entry) => { root.start-drag(data, entry); }
drag-move(x, y) => { root.move-drag(x, y); }
drag-end => { root.finish-drag(); }
drag-cancel => { root.cancel-drag(); }
}
remote-pane := BrowserPane {
width: (parent.width - 12px) / 2;
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "远端" : "Remote"; subtitle: root.remote-role; enabled: root.connected;
path <=> root.remote-path; status: root.remote-status; error: root.remote-error; busy: root.remote-busy;
open-directory-text: root.open-directory-text;
open-directory(path) => { root.open-directory(true, path); }
current-path: root.remote-current-path; filter-text <=> root.remote-filter;
can-up: root.remote-up; all-selected: root.remote-all; entries: root.remote-entries; tree: root.remote-tree;
show-hidden: root.remote-hidden; toggle-hidden => { root.toggle-hidden(true); }
sort-column: root.remote-sort-column; sort-descending: root.remote-sort-descending;
sort(column) => { root.sort(true, column); }
selected-count: root.remote-selected; destination: root.local-selected > 0;
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
drop-hover: root.drag-active && root.drop-target == 1;
navigate(path) => { root.navigate(true, path); } up => { root.up(true); }
select-entry(index) => { root.select-entry(true, index); } open-entry(index) => { root.open-entry(true, index); }
select-all => { root.select-all(true); } tree-open(index) => { root.tree-open(true, index); } tree-toggle(index) => { root.tree-toggle(true, index); }
filter(text) => { root.filter(true, text); }
drag-data(path, revision) => { return root.drag-data(true, path, revision); }
drag-start(data, entry) => { root.start-drag(data, entry); }
drag-move(x, y) => { root.move-drag(x, y); }
drag-end => { root.finish-drag(); }
drag-cancel => { root.cancel-drag(); }
}
}
WorkbenchCard {
height: 83px + task-content.height;
VerticalLayout {
spacing: 0px; padding: 1px;
HorizontalLayout {
height: 48px; padding-left: 12px; padding-right: 12px; spacing: 12px;
VerticalLayout {
alignment: center; spacing: 3px; horizontal-stretch: 1;
Text {
text: root.path-pending ? (root.chinese ? "按 Enter 打开路径" : "Press Enter to open the path")
: root.drag-active ? (root.chinese ? "松手复制 · Esc 取消" : "Release to copy · Esc to cancel")
: root.notice-error ? root.notice
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项 · " + (root.source-remote ? "远端 → 本机" : "本机 → 远端")
: (root.source-remote ? "Remote → Local" : "Local → Remote") + " · " + root.selected-count + " selected")
: root.notice-visible ? root.notice
: (root.chinese ? "选择文件,或拖到另一侧" : "Select files or drag to the other side");
color: root.notice-error && !root.drag-active && !root.path-pending ? UiSurface.error
: root.selected-count > 0 ? WorkbenchStyle.text : WorkbenchStyle.muted;
font-size: ImGuiFontStyle.base; font-weight: 400; overflow: elide;
}
if root.selected-count > 0 && !root.path-pending && !root.drag-active && !root.notice-error: Text {
text: (root.chinese ? "目标:" : "To: ") + (root.source-remote ? root.local-current-path : root.remote-current-path);
color: WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; overflow: elide;
}
}
if root.selected-count > 0: Rectangle {
width: root.chinese ? 76px : 100px;
BrowserButton {
y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true;
text-size: ImGuiFontStyle.base; text: root.chinese ? "取消选择" : "Clear selection";
enabled: !root.drag-active; clicked => { root.clear-selection(); }
}
}
Rectangle {
width: max(root.chinese ? 148px : 160px, send-label-measure.preferred-width + 32px);
send-label-measure := Text {
visible: false;
text: root.source-remote ? root.send-local-text : root.send-remote-text;
font-size: ImGuiFontStyle.base; font-weight: 600;
}
BrowserButton {
y: (parent.height - self.height) / 2; width: parent.width; height: 32px; primary: true;
text-size: ImGuiFontStyle.base;
enabled: !root.drag-active && (root.source-remote ? root.can-download : root.can-upload);
text: send-label-measure.text;
clicked => { root.transfer(root.source-remote); }
}
}
}
Rectangle { height: 1px; SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; } }
Rectangle {
height: 32px;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 8px;
Rectangle {
horizontal-stretch: 1;
BrowserButton { x: 0px; width: min(parent.width, task-label.preferred-width + 46px); height: 32px; quiet: true; accessible-label: task-label.text; clicked => { root.tasks-expanded = !root.tasks-expanded; } }
Text { x: 8px; width: 14px; height: parent.height; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: ImGuiFontStyle.base; color: ControlStyle.icon; vertical-alignment: center; text: root.tasks-expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; }
task-label := Text { x: 30px; width: parent.width - 40px; height: parent.height; font-size: ImGuiFontStyle.base; color: WorkbenchStyle.muted; vertical-alignment: center; overflow: elide; text: (root.chinese ? "传输任务" : "Transfers") + " · " + (root.tasks.length == 0 ? (root.chinese ? "暂无任务" : "No tasks") : root.task-summary != "" ? root.task-summary : root.tasks.length); }
}
if root.tasks-expanded: Rectangle {
width: max(100px, clear-label-measure.preferred-width + 24px);
clear-label-measure := Text {
visible: false; text: root.clear-completed-text;
font-size: ImGuiFontStyle.body; font-weight: 500;
}
BrowserButton { y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true; text-size: ImGuiFontStyle.body; text: root.clear-completed-text; clicked => { root.clear-finished(); } }
}
}
}
task-content := Rectangle {
height: root.tasks-expanded ? min(root.task-list-max-height, root.tasks.length == 0 ? 40px : task-rows.preferred-height) : 0px;
visible: root.tasks-expanded;
clip: true;
SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; }
if root.tasks.length == 0: Text { text: root.chinese ? "暂无传输任务" : "No transfers yet"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center; vertical-alignment: center; }
task-list := BrowserScrollView {
x: 16px; width: parent.width - 32px; height: parent.height;
visible: root.tasks.length > 0;
task-rows := VerticalLayout {
spacing: 2px; padding-top: 4px; padding-bottom: 4px; padding-right: task-list.content-gutter;
for task in root.tasks: Rectangle {
height: task.active ? 46px : 32px;
HorizontalLayout {
spacing: 12px;
Text { width: 18px; text: task.receiving ? "↓" : "↑"; color: WorkbenchStyle.accent; font-size: 16px; vertical-alignment: center; }
VerticalLayout {
padding-top: 5px; padding-bottom: 5px; spacing: 3px; alignment: center; horizontal-stretch: 1;
Rectangle {
height: 16px;
task-name := Text {
x: 0px;
width: min(self.preferred-width, max(0px, parent.width - task-size.preferred-width - 8px));
height: parent.height; text: task.name; color: WorkbenchStyle.text;
font-size: ImGuiFontStyle.base; overflow: elide; vertical-alignment: center;
}
task-size := Text {
x: task-name.width + 8px; width: self.preferred-width; height: parent.height;
text: task.size; color: WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; vertical-alignment: center;
}
}
if task.active: Text { text: task.detail; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; }
}
Rectangle { width: root.width < 820px ? 80px : 110px; Rectangle { x: 0px; y: (parent.height - 4px) / 2; width: parent.width; height: 4px; background: WorkbenchStyle.separator; border-radius: 2px; Rectangle { x: 0px; y: 0px; width: parent.width * task.progress; height: 4px; border-radius: 2px; background: task.failed ? UiSurface.error : WorkbenchStyle.accent; } } }
Text { width: root.width < 820px ? 92px : 110px; text: task.status; color: task.failed ? UiSurface.error : WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
Rectangle { width: 56px; if task.can-cancel: BrowserButton { y: (parent.height - self.height) / 2; width: 56px; height: 26px; text: root.chinese ? "取消" : "Cancel"; text-size: ImGuiFontStyle.body; quiet: true; clicked => { root.cancel-task(task.id); } } }
}
}
}
}
}
}
}
}
}
// Visual only: pointer capture and drop validation stay with the source row.
if root.drag-active: FlatSurface {
private property <bool> label-on-left: root.drag-x - 12px + self.width > root.width - 8px;
z: 100;
width: min(300px, max(112px, drag-label.preferred-width + 54px + drag-badge.width));
height: 40px;
// Keep the icon's top-left corner at the pointer when the label flips at the right edge.
x: root.drag-x - drag-icon.x;
y: root.drag-y - drag-icon.y;
border-radius: UiRadius.control;
background: WorkbenchStyle.surface;
outline-color: root.drop-target >= 0 ? WorkbenchStyle.accent : UiSurface.hover;
opacity: 0.94;
drag-icon := Text {
x: parent.label-on-left ? parent.width - 30px : 12px;
y: (parent.height - self.height) / 2; width: 18px; height: 18px;
text: root.drag-entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 17px;
color: root.drag-entry.directory ? WorkbenchStyle.accent : ControlStyle.icon;
horizontal-alignment: left; vertical-alignment: top;
}
drag-label := Text {
x: parent.label-on-left ? 12px : 40px;
width: parent.width - 54px - drag-badge.width; height: parent.height;
text: root.drag-entry.name; color: WorkbenchStyle.text;
font-size: ImGuiFontStyle.base; font-weight: 500;
vertical-alignment: center; overflow: elide;
}
drag-badge := Rectangle {
visible: root.drag-count > 1;
x: parent.width - self.width - (parent.label-on-left ? 38px : 10px); y: 10px;
width: root.drag-count > 1 ? max(24px, count-label.preferred-width + 12px) : 0px;
height: 20px; border-radius: 10px; background: WorkbenchStyle.accent;
count-label := Text {
width: parent.width; height: parent.height; text: root.drag-count;
color: white; font-size: ImGuiFontStyle.body; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
}
}
}
}
+156 -13
View File
@@ -1,4 +1,5 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint"; import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { TrustedDevicesDialog, TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
import { LanStrings } from "lan_strings.slint"; import { LanStrings } from "lan_strings.slint";
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint"; import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
@@ -75,6 +76,8 @@ export global UiStrings {
in-out property <string> capture-method: "Capture method"; in-out property <string> capture-method: "Capture method";
in-out property <string> capture-method-auto: "Auto"; in-out property <string> capture-method-auto: "Auto";
in-out property <string> codec: "Video codec"; in-out property <string> codec: "Video codec";
in-out property <string> pause-video-when-minimized: "Pause video when minimized";
in-out property <string> pause-video-when-minimized-hint: "Pause video reception when the control window is minimized to reduce local decoding resource usage.";
in-out property <string> force-relay: "Force relay"; in-out property <string> force-relay: "Force relay";
in-out property <string> self-hosted: "Self-hosted server"; in-out property <string> self-hosted: "Self-hosted server";
in-out property <string> autostart: "Start with system"; in-out property <string> autostart: "Start with system";
@@ -93,7 +96,7 @@ export global UiStrings {
in-out property <string> license: "Licensed under GNU GPL v3."; in-out property <string> license: "Licensed under GNU GPL v3.";
in-out property <string> signal-connected: "Signal server connected"; in-out property <string> signal-connected: "Signal server connected";
in-out property <string> signal-disconnected: "Signal server disconnected"; in-out property <string> signal-disconnected: "Signal server disconnected";
in-out property <string> tls-error: "Signal server TLS certificate error"; in-out property <string> tls-error: "Server certificate invalid or expired. Retrying automatically.";
in-out property <string> signal-reconnect: "Reconnect"; in-out property <string> signal-reconnect: "Reconnect";
in-out property <string> signal-reconnecting: "Reconnecting..."; in-out property <string> signal-reconnecting: "Reconnecting...";
in-out property <string> update-available: "A new version is available"; in-out property <string> update-available: "A new version is available";
@@ -107,6 +110,7 @@ export global UiStrings {
in-out property <string> later: "Later"; in-out property <string> later: "Later";
in-out property <string> download: "Download"; in-out property <string> download: "Download";
in-out property <string> install-update: "Install update"; in-out property <string> install-update: "Install update";
in-out property <string> retry-update: "Retry update";
in-out property <string> install-update-confirm; in-out property <string> install-update-confirm;
in-out property <string> input-password: "Input password"; in-out property <string> input-password: "Input password";
in-out property <string> reinput-password: "Incorrect password, please try again"; in-out property <string> reinput-password: "Incorrect password, please try again";
@@ -116,6 +120,11 @@ export global UiStrings {
in-out property <string> permission-required: "The application requires the following permissions:"; in-out property <string> permission-required: "The application requires the following permissions:";
in-out property <string> screen-recording-permission: "Screen recording permission"; in-out property <string> screen-recording-permission: "Screen recording permission";
in-out property <string> accessibility-permission: "Accessibility permission"; in-out property <string> accessibility-permission: "Accessibility permission";
in-out property <string> system-permissions: "System permissions";
in-out property <string> permission-migration-running: "Migrating permissions…";
in-out property <string> permission-migration-failed;
in-out property <string> retry-permission-migration: "Retry migration";
in-out property <string> permission-later: "Later";
in-out property <string> wayland-notice-title: "Wayland Session Notice"; in-out property <string> wayland-notice-title: "Wayland Session Notice";
in-out property <string> wayland-notice-subtitle; in-out property <string> wayland-notice-subtitle;
in-out property <string> wayland-permission-title; in-out property <string> wayland-permission-title;
@@ -592,6 +601,7 @@ export component MainWindow inherits Window {
in property <bool> update-available: false; in property <bool> update-available: false;
in property <bool> update-install-available: false; in property <bool> update-install-available: false;
in property <bool> update-install-busy: false; in property <bool> update-install-busy: false;
in property <bool> update-install-failed: false;
changed update-install-available => { changed update-install-available => {
if !root.update-install-available { root.update-confirm-open = false; } if !root.update-install-available { root.update-confirm-open = false; }
} }
@@ -599,7 +609,8 @@ export component MainWindow inherits Window {
if root.update-install-busy { root.update-confirm-open = false; } if root.update-install-busy { root.update-confirm-open = false; }
} }
in property <string> update-install-status: ""; in property <string> update-install-status: "";
private property <string> update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download; private property <string> update-action-text: !root.update-install-available ? UiStrings.download
: root.update-install-failed && !root.update-install-busy ? UiStrings.retry-update : UiStrings.install-update;
in property <UpdateCheckStatus> update-check-status: idle; in property <UpdateCheckStatus> update-check-status: idle;
private property <string> update-status-text: private property <string> update-status-text:
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates : root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
@@ -619,6 +630,9 @@ export component MainWindow inherits Window {
in-out property <string> connection-password: ""; in-out property <string> connection-password: "";
in-out property <bool> connection-remember-password: false; in-out property <bool> connection-remember-password: false;
in property <bool> permission-dialog-open: false; in property <bool> permission-dialog-open: false;
in property <bool> mac-permission-settings-visible: false;
in property <bool> permission-migration-running: false;
in property <bool> permission-migration-failed: false;
in property <bool> screen-recording-granted: true; in property <bool> screen-recording-granted: true;
in property <bool> accessibility-granted: true; in property <bool> accessibility-granted: true;
in-out property <bool> wayland-notice-open: false; in-out property <bool> wayland-notice-open: false;
@@ -654,12 +668,24 @@ export component MainWindow inherits Window {
changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } } changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } }
in-out property <bool> daemon-enabled: false; in-out property <bool> daemon-enabled: false;
in-out property <bool> save-remote-previews: true; in-out property <bool> save-remote-previews: true;
in-out property <bool> pause-video-when-minimized: true;
// Show the preference on every platform; only adapted hosts can change it. // Show the preference on every platform; only adapted hosts can change it.
in property <bool> privacy-setting-available: false; in property <bool> privacy-setting-available: false;
in-out property <bool> privacy-on-connect-enabled: true; in-out property <bool> privacy-on-connect-enabled: true;
in-out property <bool> privacy-local-unlock-allowed: true; in-out property <bool> privacy-local-unlock-allowed: true;
in property <bool> lock-on-disconnect-available: false; in property <bool> lock-on-disconnect-available: false;
in-out property <bool> lock-on-disconnect-enabled: true; in-out property <bool> lock-on-disconnect-enabled: true;
in-out property <bool> trusted-devices-only: false;
in-out property <bool> trusted-devices-open: false;
in property <[TrustedDeviceEntry]> trusted-devices;
in property <[TrustedDeviceEntry]> trust-candidates;
in property <string> device-fingerprint;
in property <string> trusted-devices-error;
in property <string> trusted-devices-notice;
in property <string> trust-copy-error;
in property <string> trust-copy-notice;
in-out property <string> trust-pairing-input;
callback trust-action(string, string, string);
in-out property <string> file-save-path: ""; in-out property <string> file-save-path: "";
in-out property <string> server-host: ""; in-out property <string> server-host: "";
in-out property <string> server-port: ""; in-out property <string> server-port: "";
@@ -683,6 +709,7 @@ export component MainWindow inherits Window {
callback cancel-self-hosted-settings; callback cancel-self-hosted-settings;
callback browse-save-path; callback browse-save-path;
callback open-download; callback open-download;
callback open-manual-download;
callback confirm-update; callback confirm-update;
callback cancel-update; callback cancel-update;
callback open-website; callback open-website;
@@ -698,6 +725,9 @@ export component MainWindow inherits Window {
callback reset-remote-id; callback reset-remote-id;
callback request-screen-recording-permission; callback request-screen-recording-permission;
callback request-accessibility-permission; callback request-accessibility-permission;
callback open-permissions;
callback dismiss-permissions;
callback retry-permission-migration;
callback install-portable-service; callback install-portable-service;
callback cancel-portable-service; callback cancel-portable-service;
callback acknowledge-portable-service; callback acknowledge-portable-service;
@@ -710,6 +740,11 @@ export component MainWindow inherits Window {
in-out property <bool> settings-open: false; in-out property <bool> settings-open: false;
in-out property <int> settings-tab: 0; in-out property <int> settings-tab: 0;
private property <length> settings-scroll-y: 0px; private property <length> settings-scroll-y: 0px;
private property <bool> pause-video-hint-visible: false;
private property <length> pause-video-hint-x: 0px;
private property <length> pause-video-hint-y: 0px;
changed settings-tab => { root.pause-video-hint-visible = false; }
changed settings-scroll-y => { root.pause-video-hint-visible = false; }
in-out property <bool> self-host-settings-open: false; in-out property <bool> self-host-settings-open: false;
changed self-host-settings-open => { changed self-host-settings-open => {
if !root.self-host-settings-open { root.self-host-settings-error = ""; } if !root.self-host-settings-open { root.self-host-settings-error = ""; }
@@ -810,6 +845,7 @@ export component MainWindow inherits Window {
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } } changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } } changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
changed settings-open => { changed settings-open => {
root.pause-video-hint-visible = false;
if root.settings-open { if root.settings-open {
root.settings-tab = 0; root.settings-tab = 0;
root.settings-scroll-y = 0px; root.settings-scroll-y = 0px;
@@ -1087,7 +1123,7 @@ export component MainWindow inherits Window {
if event.text == Key.Escape { root.messages-open = false; } if event.text == Key.Escape { root.messages-open = false; }
return accept; return accept;
} }
if root.settings-open && !root.self-host-settings-open { if root.settings-open && !root.self-host-settings-open && !root.trusted-devices-open {
if event.text == Key.Escape { root.cancel-settings(); root.settings-open = false; } if event.text == Key.Escape { root.cancel-settings(); root.settings-open = false; }
return accept; return accept;
} }
@@ -1923,7 +1959,7 @@ export component MainWindow inherits Window {
text: root.current-version; text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
} }
if !root.lan-mode && !root.signal-connected && !root.signal-tls-error: WorkbenchButton { if !root.lan-mode && !root.signal-connected: WorkbenchButton {
x: parent.width - self.width - 20px; x: parent.width - self.width - 20px;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: max(64px, reconnect-label-metrics.preferred-width + 22px); width: max(64px, reconnect-label-metrics.preferred-width + 22px);
@@ -2552,9 +2588,18 @@ export component MainWindow inherits Window {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(400px, parent.width - 40px); width: min(400px, parent.width - 40px);
height: permission-content.preferred-height; height: min(permission-content.preferred-height, parent.height - 32px);
background: #f8fafc; outline-color: UiSurface.edge; background: #f8fafc; outline-color: UiSurface.edge;
TouchArea { clicked => { focus-sink.focus(); } } TouchArea { clicked => { focus-sink.focus(); } }
permission-scroll := ScrollView {
width: parent.width; height: parent.height;
viewport-width: self.visible-width;
viewport-height: permission-content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
Rectangle {
width: permission-scroll.viewport-width;
height: permission-scroll.viewport-height;
permission-content := VerticalLayout { permission-content := VerticalLayout {
padding: 24px; spacing: 20px; padding: 24px; spacing: 20px;
VerticalLayout { VerticalLayout {
@@ -2600,6 +2645,7 @@ export component MainWindow inherits Window {
} }
if !permission.granted: WorkbenchButton { if !permission.granted: WorkbenchButton {
width: parent.width; height: parent.height; primary: true; width: parent.width; height: parent.height; primary: true;
enabled: !root.permission-migration-running;
text: permission-action-metrics.text; text: permission-action-metrics.text;
accessible-label: permission.label + ": " + self.text; accessible-label: permission.label + ": " + self.text;
clicked => { clicked => {
@@ -2625,10 +2671,35 @@ export component MainWindow inherits Window {
VerticalLayout { VerticalLayout {
spacing: 12px; spacing: 12px;
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; } SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
if root.permission-migration-running || root.permission-migration-failed: Text {
text: root.permission-migration-running
? UiStrings.permission-migration-running : UiStrings.permission-migration-failed;
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
Text { Text {
text: GalleryStrings.texts[112]; text: GalleryStrings.texts[112];
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap; font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
} }
HorizontalLayout {
alignment: end; spacing: 8px;
WorkbenchButton {
text: root.accessibility-granted && root.screen-recording-granted
&& !root.permission-migration-running && !root.permission-migration-failed
? UiStrings.ok : UiStrings.permission-later;
clicked => { root.dismiss-permissions(); }
}
if root.permission-migration-failed: retry-migration := WorkbenchButton {
width: max(112px, retry-migration-label.preferred-width + 24px);
primary: true; text: UiStrings.retry-permission-migration;
clicked => { root.retry-permission-migration(); }
retry-migration-label := Text {
visible: false; accessible-role: none;
text: retry-migration.text; font-size: 13px; font-weight: 600;
}
}
}
}
}
} }
} }
} }
@@ -2714,15 +2785,30 @@ export component MainWindow inherits Window {
} }
SettingsGroup { SettingsGroup {
title: GalleryStrings.texts[115]; title: GalleryStrings.texts[115];
rows: root.capture-method-visible ? 2 : 1; rows: root.capture-method-visible ? 3 : 2;
if root.capture-method-visible: SettingsRow { if root.capture-method-visible: SettingsRow {
label: UiStrings.capture-method; enabled: !root.settings-session-active; label: UiStrings.capture-method; enabled: !root.settings-session-active;
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
} }
SettingsRow { SettingsRow {
label: UiStrings.codec; enabled: !root.settings-session-active; divider: false; label: UiStrings.codec; enabled: !root.settings-session-active;
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; }
} }
pause-video-hover := TouchArea {
height: 36px;
function update-hint() {
root.pause-video-hint-visible = self.has-hover;
root.pause-video-hint-x = self.absolute-position.x + self.mouse-x;
root.pause-video-hint-y = self.absolute-position.y + self.mouse-y;
}
changed has-hover => { self.update-hint(); }
pointer-event(event) => { self.update-hint(); }
SettingsToggleRow {
width: parent.width;
label: UiStrings.pause-video-when-minimized;
checked <=> root.pause-video-when-minimized; divider: false;
}
}
} }
SettingsGroup { SettingsGroup {
title: GalleryStrings.texts[116]; rows: 4; title: GalleryStrings.texts[116]; rows: 4;
@@ -2753,15 +2839,31 @@ export component MainWindow inherits Window {
} }
} }
} }
Text {
text: GalleryStrings.texts[119] + "\n" + GalleryStrings.texts[207];
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
SettingsGroup { SettingsGroup {
title: GalleryStrings.texts[120]; rows: 3; title: GalleryStrings.texts[120];
rows: root.mac-permission-settings-visible ? 6 : 5;
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; } SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; } SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; divider: false; } SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; }
SettingsToggleRow { label: TrustStrings.only; checked <=> root.trusted-devices-only; }
SettingsRow {
label: TrustStrings.title; divider: root.mac-permission-settings-visible;
SettingsButton {
x: parent.width - self.width - 12px; y: 4px;
width: max(104px, self.label-width); height: 28px;
text: TrustStrings.manage;
clicked => { root.trusted-devices-open = true; root.trust-action("open", "", ""); }
}
}
if root.mac-permission-settings-visible: SettingsRow {
label: UiStrings.system-permissions; divider: false;
SettingsButton {
x: parent.width - self.width - 12px; y: 4px;
width: max(104px, self.label-width); height: 28px;
text: TrustStrings.manage;
clicked => { root.open-permissions(); }
}
}
} }
Text { Text {
text: GalleryStrings.texts[121]; text: GalleryStrings.texts[121];
@@ -2821,6 +2923,25 @@ export component MainWindow inherits Window {
clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } } clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } }
} }
} }
if root.window-active && root.settings-tab == 0 && root.pause-video-hint-visible: Rectangle {
// Keep the immediate hint outside ScrollView's clipping area.
width: min(260px, parent.width - 16px);
height: max(28px, pause-video-hint-text.preferred-height + 12px);
x: clamp(root.pause-video-hint-x - settings-dialog.absolute-position.x,
8px, parent.width - self.width - 8px);
private property <length> pointer-y: root.pause-video-hint-y - settings-dialog.absolute-position.y;
y: self.pointer-y + 8px + self.height <= parent.height - 8px
? self.pointer-y + 8px : self.pointer-y - self.height - 8px;
background: #24324a;
border-radius: UiRadius.control; z: 20;
pause-video-hint-text := Text {
x: 10px; y: 6px;
width: parent.width - 20px; height: parent.height - 12px;
text: UiStrings.pause-video-when-minimized-hint;
font-size: 12px; font-weight: 400; color: white; wrap: word-wrap;
horizontal-alignment: left; vertical-alignment: center;
}
}
} }
} }
@@ -2871,6 +2992,20 @@ export component MainWindow inherits Window {
} }
} }
if root.trusted-devices-open: TrustedDevicesDialog {
z: 51;
devices: root.trusted-devices;
candidates: root.trust-candidates;
local-fingerprint: root.device-fingerprint;
error: root.trusted-devices-error;
notice: root.trusted-devices-notice;
copy-error: root.trust-copy-error;
copy-notice: root.trust-copy-notice;
pairing-input <=> root.trust-pairing-input;
close => { root.trust-action("close", "", ""); root.trusted-devices-open = false; }
action(action, id, key) => { root.trust-action(action, id, key); }
}
if root.self-host-settings-open: Rectangle { if root.self-host-settings-open: Rectangle {
background: #15223835; background: #15223835;
z: 49; z: 49;
@@ -3268,6 +3403,14 @@ export component MainWindow inherits Window {
text: UiStrings.later; text: UiStrings.later;
clicked => { root.cancel-update(); root.update-open = false; } clicked => { root.cancel-update(); root.update-open = false; }
} }
if root.update-install-failed && root.update-install-available: WorkbenchButton {
width: max(110px, manual-download-metrics.preferred-width + 24px);
height: 34px;
enabled: !root.update-install-busy;
text: UiStrings.download;
clicked => { root.open-manual-download(); }
manual-download-metrics := Text { visible: false; text: UiStrings.download; font-size: 12px; }
}
WorkbenchButton { WorkbenchButton {
width: max(110px, update-action-metrics.preferred-width + 24px); width: max(110px, update-action-metrics.preferred-width + 24px);
height: 34px; height: 34px;
@@ -12,6 +12,7 @@ export component RoundedLineEdit inherits Rectangle {
in property <bool> enabled: true; in property <bool> enabled: true;
in property <length> font-size: 12px; in property <length> font-size: 12px;
in property <color> normal-outline-color: UiSurface.edge; in property <color> normal-outline-color: UiSurface.edge;
in property <float> tint-opacity: 1;
in property <brush> surrounding-background: white; in property <brush> surrounding-background: white;
callback accepted(); callback accepted();
callback edited(); callback edited();
@@ -36,6 +37,7 @@ export component RoundedLineEdit inherits Rectangle {
// A visual-only tint preserves the standard editor and its built-in actions. // A visual-only tint preserves the standard editor and its built-in actions.
Rectangle { Rectangle {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
opacity: root.tint-opacity;
background: !root.enabled ? transparent background: !root.enabled ? transparent
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint; : editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
} }
@@ -6,11 +6,13 @@ import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages. // Shared visual treatment for the global settings pages.
export component SettingsButton inherits FlatSurface { export component SettingsButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: 12px;
in property <image> icon; in property <image> icon;
in property <bool> primary: false; in property <bool> primary: false;
in property <bool> quiet: false; in property <bool> quiet: false;
in property <bool> align-left: false; in property <bool> align-left: false;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> destructive: false;
out property <length> label-width: button-label.preferred-width + 2 * root.content-padding + (root.has-icon ? 24px : 0px); out property <length> label-width: button-label.preferred-width + 2 * root.content-padding + (root.has-icon ? 24px : 0px);
private property <bool> has-icon: root.icon.width > 0; private property <bool> has-icon: root.icon.width > 0;
private property <length> content-padding: root.has-icon ? 8px : 12px; private property <length> content-padding: root.has-icon ? 8px : 12px;
@@ -18,7 +20,8 @@ export component SettingsButton inherits FlatSurface {
width: 88px; height: 32px; border-radius: UiRadius.control; width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? ControlStyle.disabled background: !root.enabled ? ControlStyle.disabled
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc) : root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : touch.pressed ? (root.destructive ? #f6babb : ButtonStyle.pressed)
: touch.has-hover ? (root.destructive ? #fbe0e0 : ButtonStyle.hover)
: root.quiet ? transparent : ButtonStyle.surface; : root.quiet ? transparent : ButtonStyle.surface;
outline-enabled: !root.quiet && !root.primary; outline-enabled: !root.quiet && !root.primary;
outline-color: !root.enabled ? ButtonStyle.disabled-border outline-color: !root.enabled ? ButtonStyle.disabled-border
@@ -44,8 +47,9 @@ export component SettingsButton inherits FlatSurface {
button-label := Text { button-label := Text {
x: root.has-icon ? 24px : 0px; x: root.has-icon ? 24px : 0px;
width: parent.width - self.x; height: parent.height; width: parent.width - self.x; height: parent.height;
text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400; text: root.text; font-size: root.text-size; font-weight: root.primary ? 600 : 400;
color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63; color: !root.enabled ? #98a2b3 : root.primary ? white
: root.destructive ? #c43c43 : root.quiet ? #65758b : #3c4d63;
horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide; horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide;
} }
} }

Some files were not shown because too many files have changed in this diff Show More