mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-12 20:00:50 +08:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f526cd70e | ||
|
|
b1236534ac | ||
|
|
2a9faf1eb1 | ||
|
|
02a25a759f | ||
|
|
bdce69607f | ||
|
|
5047599084 | ||
|
|
d8739c324d | ||
|
|
b15a63cc9d | ||
|
|
6414f2317f | ||
|
|
043c2016fc | ||
|
|
b2817d0adc | ||
|
|
ec2c5485a2 | ||
|
|
25fb2a3d7f | ||
|
|
23f48dc761 | ||
|
|
a862bf6b35 | ||
|
|
17b4db25fb | ||
|
|
abcc0eef0a | ||
|
|
34b59de15d | ||
|
|
a3159b198e | ||
|
|
14fa95beeb | ||
|
|
69d4394717 | ||
|
|
3b60a32ba7 | ||
|
|
e47a3ac65c | ||
|
|
097add9397 | ||
|
|
6a8e79d4a9 | ||
|
|
67afb25c75 | ||
|
|
f0f0a68ec6 | ||
|
|
6898df7cdc | ||
|
|
ed58172d2e | ||
|
|
57c569424e | ||
|
|
da3c2ff2ab | ||
|
|
8370bf95d1 | ||
|
|
c179b3c243 | ||
|
|
81fc8463dc | ||
|
|
fadc8f15ab | ||
|
|
4e2532f33c | ||
|
|
930ccd9ea1 | ||
|
|
c75394e498 | ||
|
|
32b4e442a1 | ||
|
|
da4ffa6f06 | ||
|
|
80760cab47 | ||
|
|
f5f8eb94d4 | ||
|
|
458f9ef58d | ||
|
|
e3ad683809 | ||
|
|
33bc5bf13b | ||
|
|
276d314780 | ||
|
|
a663ebdb1a | ||
|
|
882982081a | ||
|
|
84466ccea4 | ||
|
|
1a12a1d7d5 | ||
|
|
a64a28a5a3 | ||
|
|
05782bce7c | ||
|
|
2846c19733 | ||
|
|
9c31acd4c9 | ||
|
|
9ef7ecbc82 | ||
|
|
82b8cc11bc | ||
|
|
02b10d0d8e | ||
|
|
b167b24cb3 | ||
|
|
0c1aa1339d | ||
|
|
12afcce690 | ||
|
|
dd4baf2fc4 | ||
|
|
6bfcd5cf3d | ||
|
|
0aed57ed4d | ||
|
|
0eab020c38 | ||
|
|
a49eb7ad5b | ||
|
|
2535a04d2b | ||
|
|
09ef60dc6f | ||
|
|
9ca4c3c7fe | ||
|
|
43b1d8fecb | ||
|
|
593bd550aa | ||
|
|
1b6248b05b | ||
|
|
c0fa5e6474 | ||
|
|
5e34360d25 | ||
|
|
751cf04434 | ||
|
|
6f7c5a29ab | ||
|
|
4d8a650afc | ||
|
|
dba7ac3203 | ||
|
|
47e16622f8 | ||
|
|
049cc0c8de | ||
|
|
fc1529ee2b | ||
|
|
e1ae449d36 | ||
|
|
f57b4cc4e3 |
No files matched your search
@@ -5,6 +5,9 @@ inputs:
|
||||
minimum-version:
|
||||
description: Minimum supported Xmake version (major.minor.patch).
|
||||
required: true
|
||||
cache-key:
|
||||
description: Host environment identity, including the image when running in a container.
|
||||
default: host
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -16,11 +19,19 @@ runs:
|
||||
XMAKE_MIN_VERSION: ${{ inputs.minimum-version }}
|
||||
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
|
||||
if: steps.check.outputs.needs-upgrade == 'true'
|
||||
uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
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
|
||||
shell: bash
|
||||
|
||||
@@ -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
|
||||
@@ -70,6 +70,7 @@ jobs:
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -78,6 +79,10 @@ jobs:
|
||||
# also exists when source_tag checks out an older Android release.
|
||||
cache-dependency-path: .github/workflows/build-android.yml
|
||||
|
||||
- name: Check shared client version
|
||||
if: hashFiles('scripts/version.py') != ''
|
||||
run: python3 scripts/version.py check client
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
crossdesk_missing_packages=()
|
||||
@@ -90,7 +95,9 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}"
|
||||
fi
|
||||
python3 -m pip install setuptools wheel
|
||||
if ! python3 -c 'import setuptools, wheel, pip' >/dev/null 2>&1; then
|
||||
python3 -m pip install setuptools wheel
|
||||
fi
|
||||
|
||||
# Use an isolated SDK directory so the cache contains only this toolchain,
|
||||
# rather than every SDK/NDK version preinstalled on the runner.
|
||||
@@ -134,13 +141,40 @@ jobs:
|
||||
actions-cache-folder: .xmake-toolchain-cache
|
||||
actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }}
|
||||
|
||||
# Repository selection follows desktop Xmake behavior. Keep installed
|
||||
# packages isolated; never reuse another platform's build/config.
|
||||
- name: Cache native dependencies
|
||||
uses: actions/cache@v5
|
||||
- name: Set native dependency cache key
|
||||
id: native-cache-key
|
||||
env:
|
||||
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:
|
||||
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
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
@@ -185,6 +219,13 @@ jobs:
|
||||
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
|
||||
: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
|
||||
working-directory: apps/android
|
||||
run: |
|
||||
|
||||
@@ -3,7 +3,7 @@ name: CLI native builds and npm packages
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags-ignore: ["cli-v*"]
|
||||
tags-ignore: ["cli-v*", "mcp-v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
@@ -19,18 +19,23 @@ jobs:
|
||||
include:
|
||||
- runner: macos-15
|
||||
target: darwin-arm64
|
||||
plat: macosx
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
target: darwin-x64
|
||||
plat: macosx
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: linux-x64
|
||||
plat: linux
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
plat: linux
|
||||
arch: arm64
|
||||
- runner: windows-2022
|
||||
target: win32-x64
|
||||
plat: windows
|
||||
arch: x64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
@@ -44,6 +49,11 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Check shared CLI and MCP version
|
||||
run: python scripts/version.py check cli
|
||||
# A runner label can receive compiler/SDK updates without changing its name.
|
||||
- name: Set cache environment
|
||||
id: runner-image
|
||||
@@ -90,23 +100,31 @@ jobs:
|
||||
fi
|
||||
- name: Prepare pinned dependency
|
||||
run: sh apps/cli/scripts/prepare-deps.sh
|
||||
# Exact matches only: changed local recipes/patches must not reuse old libraries.
|
||||
# Source, README and npm metadata edits do not change this key.
|
||||
# Use the runner's native shell for Xmake. Git Bash sets MSYSTEM on
|
||||
# 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
|
||||
id: native-cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
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/**', '.github/workflows/build-cli.yml') }}
|
||||
- name: Configure macOS
|
||||
if: runner.os == 'macOS'
|
||||
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
|
||||
- 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
|
||||
key: ${{ steps.dependency-key.outputs.key }}
|
||||
restore-keys: ${{ steps.dependency-key.outputs.prefix }}
|
||||
- name: Configure native dependencies
|
||||
run: xmake f -y --import="${{ runner.temp }}/crossdesk-cli-config.lua"
|
||||
# xmake f installs required packages. Save before compiling or packaging the CLI,
|
||||
# so failures in those later steps do not discard successfully installed dependencies.
|
||||
- name: Save native dependencies
|
||||
@@ -129,3 +147,29 @@ jobs:
|
||||
name: npm-${{ matrix.target }}
|
||||
path: build/npm/*.tgz
|
||||
if-no-files-found: error
|
||||
- name: Build MCP and DSH plugin
|
||||
shell: bash
|
||||
run: |
|
||||
npm --prefix apps/mcp ci
|
||||
npm --prefix apps/mcp run build
|
||||
- name: Package MCP with native CLI
|
||||
shell: bash
|
||||
run: |
|
||||
native_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$native_dir"' EXIT
|
||||
native_archives=(build/npm/chunkdash-crossdesk-cli-*-*.tgz)
|
||||
if [[ ${#native_archives[@]} -ne 1 || ! -f "${native_archives[0]}" ]]; then
|
||||
echo 'Expected exactly one native platform package' >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -xzf "${native_archives[0]}" -C "$native_dir"
|
||||
native_package_dir="$native_dir/package"
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
native_package_dir=$(cygpath -m "$native_package_dir")
|
||||
fi
|
||||
npm --prefix apps/mcp run pack:npm -- --native-package-dir "$native_package_dir"
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: crossdesk-mcp-${{ matrix.target }}
|
||||
path: build/npm-mcp/*.tgz
|
||||
if-no-files-found: error
|
||||
@@ -155,30 +155,59 @@ jobs:
|
||||
- name: Initialize submodules
|
||||
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
|
||||
id: python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
cache-dependency-path: .github/workflows/build-ios.yml
|
||||
|
||||
- name: Check shared client version
|
||||
if: hashFiles('scripts/version.py') != ''
|
||||
run: python3 scripts/version.py check client
|
||||
|
||||
- name: Install Python build tools
|
||||
shell: bash
|
||||
run: |
|
||||
# Xmake only accepts the host Python when all three modules exist;
|
||||
# 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
|
||||
uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
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
|
||||
shell: bash
|
||||
@@ -187,6 +216,27 @@ jobs:
|
||||
python3 -B apps/ios/scripts/verify_native_build.py \
|
||||
--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
|
||||
# native archives here so dependency downloads and compiler commands are
|
||||
# streamed to Actions; the Xcode phase then reuses the same build products.
|
||||
@@ -216,6 +266,13 @@ jobs:
|
||||
apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \
|
||||
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
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
@@ -241,6 +298,7 @@ jobs:
|
||||
with:
|
||||
name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ runner.temp }}/ios-native-prepare.log
|
||||
${{ runner.temp }}/ios-native-build.log
|
||||
${{ runner.temp }}/ios-app-build.log
|
||||
~/.xmake/cache/packages/**/logs/*.txt
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: MCP package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags-ignore: ["mcp-v*", "cli-v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Check shared CLI and MCP version
|
||||
run: python3 scripts/version.py check mcp
|
||||
- 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
|
||||
@@ -10,10 +10,11 @@ on:
|
||||
- "!ios-v*"
|
||||
- "!android-v*"
|
||||
- "!cli-v*"
|
||||
- "!mcp-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
desktop_version:
|
||||
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for 0.0.0; tags use their own version."
|
||||
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for the source version; tags use their own version."
|
||||
required: false
|
||||
type: string
|
||||
patch:
|
||||
@@ -49,6 +50,9 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
# Resolve the date once for every desktop build and the release job.
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Check shared client version
|
||||
run: python3 scripts/version.py check client
|
||||
- name: Resolve desktop version
|
||||
id: version
|
||||
shell: bash
|
||||
@@ -57,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
VERSION_REF="${GITHUB_REF##*/}"
|
||||
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
|
||||
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
|
||||
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-$(python3 scripts/version.py show desktop --field version)}"
|
||||
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
|
||||
echo "::error::desktop_version must be a numeric version such as 1.7.2"
|
||||
exit 1
|
||||
@@ -132,6 +136,7 @@ jobs:
|
||||
uses: ./.github/actions/ensure-xmake
|
||||
with:
|
||||
minimum-version: ${{ env.XMAKE_MIN_VERSION }}
|
||||
cache-key: ${{ matrix.image }}
|
||||
|
||||
- name: Verify Linux build image
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Check component versions
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python3 scripts/version.py check
|
||||
@@ -31,7 +31,9 @@ jobs:
|
||||
id: release
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
run: node apps/cli/scripts/release-npm.cjs plan
|
||||
run: |
|
||||
python3 scripts/version.py check cli
|
||||
node apps/cli/scripts/release-npm.cjs plan
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Publish MCP to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["mcp-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Build and validate without publishing (disable only on a matching mcp-v* tag)"
|
||||
type: boolean
|
||||
default: true
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
dry_run: ${{ steps.release.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Validate release version and Git tag
|
||||
id: release
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
run: |
|
||||
python3 scripts/version.py check mcp
|
||||
node apps/mcp/scripts/release-npm.cjs plan
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/build-mcp.yml
|
||||
|
||||
validate:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: mcp-package
|
||||
path: ${{ runner.temp }}/mcp-release
|
||||
- name: Validate the universal MCP package without publishing
|
||||
env:
|
||||
RELEASE_DRY_RUN: "true"
|
||||
RELEASE_ARTIFACTS: ${{ runner.temp }}/mcp-release
|
||||
run: node apps/mcp/scripts/release-npm.cjs publish "$RELEASE_ARTIFACTS"
|
||||
|
||||
publish:
|
||||
needs: [prepare, validate]
|
||||
if: needs.prepare.outputs.dry_run == 'false'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
environment: npm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: npm-mcp-publish
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Install npm with Trusted Publishing support
|
||||
run: npm install --global npm@12.2.1 --ignore-scripts --no-audit --no-fund
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: mcp-package
|
||||
path: ${{ runner.temp }}/mcp-release
|
||||
- name: Check native dependencies and publish MCP
|
||||
env:
|
||||
RELEASE_DRY_RUN: "false"
|
||||
RELEASE_ARTIFACTS: ${{ runner.temp }}/mcp-release
|
||||
run: node apps/mcp/scripts/release-npm.cjs publish "$RELEASE_ARTIFACTS"
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
CrossDesk 让你从电脑、浏览器、手机或终端(桌面 CLI)连接远端电脑,也能通过桌面墙(DeskHub)集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
||||
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [IP 直连](#lan-direct) · [可信设备](#trusted-devices) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [Agent / MCP](#mcp) · [IP 直连](#lan-direct) · [可信设备](#trusted-devices) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
@@ -33,9 +33,10 @@ CrossDesk 让你从电脑、浏览器、手机或终端(桌面 CLI)连接远
|
||||
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
||||
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
|
||||
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty / Sixel 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
|
||||
- 桌面墙:多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
||||
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,会话内调整画质、帧率和远端显示器分辨率;记住已确认的分辨率选择,最后一个控制端断开后恢复原显示模式。可用能力随平台与设备而异。
|
||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板;双栏文件浏览支持目录导航、文件与文件夹双向传输、拖放和任务管理。
|
||||
- **Agent / MCP:** 通过本机 MCP 服务授权 Agent 观察和操作远端电脑,提供实时监看、人工接管、多会话、剪贴板和文件工具;目前从源码构建使用。
|
||||
- **可信设备:** 桌面端可选开启可信设备限制,在连接密码之外校验设备身份;支持复制、粘贴配对信息,提前添加允许访问本机的设备。
|
||||
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
|
||||
|
||||
@@ -50,18 +51,19 @@ CrossDesk 让你从电脑、浏览器、手机或终端(桌面 CLI)连接远
|
||||
| Windows | **控制 / 被控**;Windows 10+,x64;`.exe` 安装包,也支持便携构建 |
|
||||
| macOS | **控制 / 被控**;macOS 14.0+;按 Intel / Apple Silicon 选择 x64 / arm64 `.pkg` |
|
||||
| Linux | **控制 / 被控**;Ubuntu 20.04+、glibc 2.31 基线;amd64 / arm64 `.deb` |
|
||||
| iOS / iPadOS | **仅控制**;iOS 16.0+;发布包为未签名应用,需自行签名,或使用 Xcode 构建安装 |
|
||||
| iOS / iPadOS | **仅控制**;从 [App Store](https://apps.apple.com/app/id6828859211) 安装,商店版系统要求以页面为准;当前源码目标为 iOS 16.0+,也可自行构建签名 |
|
||||
| Android | **仅控制**;Android 8.0+,arm64;下载已签名 APK 安装,或自行构建 |
|
||||
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
|
||||
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
|
||||
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
|
||||
| 终端 CLI(开发版) | **仅控制**;分发目标为 macOS / Linux(x64、arm64)和 Windows(x64);通过 [npm 安装](#cli-npm) 或从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) |
|
||||
| MCP(开发版) | **仅控制**;在 Codex 或 DSH 中操作远端并监看画面;DSH 安装一个插件包,Codex 需 Node.js 22+,见 [安装与使用](apps/mcp/README.md) |
|
||||
|
||||
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。
|
||||
|
||||
- **Linux:** 已有桌面选普通版 `crossdesk-linux-…deb`;需要独立 Xfce 桌面的服务器选 `crossdesk-virtual-desktop-xfce-…deb`。两者选择其一,安装与启动方法见 [使用指南](docs/USER_GUIDE.md#installation) 和 [无头运行](docs/HEADLESS.md)。
|
||||
- **macOS:** 首次运行时按提示授予屏幕录制及辅助功能权限;前者用于桌面采集,后者用于远程键鼠输入。[权限说明](docs/USER_GUIDE.md#installation)
|
||||
- **iOS / 鸿蒙:** 签名与构建步骤分别见 [iOS](apps/ios/README.md) 和 [鸿蒙](apps/harmony/README.md),不要将未签名产物当作可直接安装的应用。
|
||||
- **iOS:** 优先从 [App Store](https://apps.apple.com/app/id6828859211) 安装;GitHub 的未签名产物仍需自行签名,步骤见 [iOS 构建说明](apps/ios/README.md)。鸿蒙需使用 DevEco 构建并签名,见 [鸿蒙说明](apps/harmony/README.md)。
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
@@ -135,12 +137,26 @@ CrossDesk 让你从电脑、浏览器、手机或终端(桌面 CLI)连接远
|
||||
|
||||
群控会向所有已连接设备发送输入;关闭桌面墙会断开全部远程连接。操作范围与详细用法见 [桌面墙使用指南](docs/USER_GUIDE.md#deskhub)。截图使用演示数据,更多界面及来源见 [使用指南](docs/USER_GUIDE.md) 与 [截图说明](docs/images/README.md)。
|
||||
|
||||
### 双栏文件传输
|
||||
|
||||
在会话工具栏打开文件传输,同时浏览本机与远端目录,选择文件或文件夹后发送,也可在两栏之间拖放;任务列表显示进度并支持取消未完成任务。远端目录浏览需要兼容的被控端。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-files-zh.png" width="960" alt="双栏文件传输:本机与远端路径、文件列表、发送按钮和任务列表;数据为演示" />
|
||||
</p>
|
||||
|
||||
<a id="session"></a>
|
||||
<a id="settings"></a>
|
||||
|
||||
## 会话操作与设置
|
||||
|
||||
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、可信设备限制、编码、中继、自托管与桌面墙画面预算。
|
||||
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和双向文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、可信设备限制、编码、中继、自托管、最小化时暂停视频及桌面墙画面预算。
|
||||
|
||||
被控端为 Windows、macOS 或 Linux X11 时,可在 **画面设置 → 分辨率** 中选择当前显示器支持的模式,无需重连。桌面和原生移动端会记住远端确认成功的选择,重连时在仍受支持的情况下重新应用;最后一个控制端断开后,被控端恢复本轮连接首次调整前的显示模式。Linux 需要 X11 采集和 RandR,Wayland / XWayland 暂不支持此项调整。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-stream-video-settings-zh.png" width="960" alt="会话画面设置:质量、帧率、远端显示器分辨率和画面偏好;背景为公开页面演示图" />
|
||||
</p>
|
||||
|
||||
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
|
||||
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
|
||||
@@ -259,10 +275,27 @@ pi -e ./apps/cli/plugins/pi/index.ts
|
||||
|
||||
其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。
|
||||
|
||||
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty / Sixel 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
|
||||
交互式 CLI 当前提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步;需要 Agent 自动化及文件、音频等工具时,使用下方的 [MCP 服务](#mcp)。CLI 也暂不支持连接开启[可信设备限制](#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)
|
||||
|
||||
<a id="mcp"></a>
|
||||
|
||||
## Agent 与 MCP
|
||||
|
||||
[CrossDesk MCP](apps/mcp/README.md) 让你在 Codex 或 DeepSeek Harness 对话中安排远程任务,并在右侧查看远端画面。远端电脑运行 CrossDesk;本机按下表安装后,在面板中选择设备并授权。
|
||||
|
||||
| 使用环境 | 安装与配置 | 远端画面 |
|
||||
| --- | --- | --- |
|
||||
| Codex 桌面版 | [Codex 安装指南](apps/mcp/README.md#install-codex):注册 MCP,无需额外插件 | Agent 打开右侧内置浏览器 |
|
||||
| DeepSeek Harness 桌面版(DSH) | [DSH 安装指南](apps/mcp/README.md#install-dsh):只安装一个对应平台的 MCP 插件包,自动注册服务 | 插件自动打开当前对话侧栏 |
|
||||
|
||||
1. [下载与你的本机对应的平台安装包](apps/mcp/README.md#install-package),再按上表完成安装。
|
||||
2. 让 Agent 打开 CrossDesk,在监看面板选择设备、输入连接密码,并选择仅查看或允许控制。密码在本地面板中输入;保存密码与允许以后自动连接分别设置。
|
||||
3. Agent 通过截图、键鼠和结构化桌面工具执行任务;用户可实时监看、暂停 Agent、人工接管或断开连接。
|
||||
|
||||
支持最多 8 个并行设备会话、多屏观察、远端文本剪贴板、文件浏览与传输、声音、系统锁屏及本地输入密码的解锁流程。窗口与可访问性操作受远端平台和系统权限限制,部分工具需要新版被控端。浏览器、终端和 MCP Apps 的面板能力因宿主而异;配置及兼容范围见 [MCP 文档](apps/mcp/README.md)。
|
||||
|
||||
<a id="mobile"></a>
|
||||
|
||||
## 移动端与鸿蒙
|
||||
@@ -271,7 +304,7 @@ CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、
|
||||
|
||||
### iOS / iPadOS
|
||||
|
||||
原生控制端支持触控与虚拟鼠标、多显示器、远端音频、文件收发和文本剪贴板。悬浮菜单的“剪贴板”按钮可主动发送本机文本,单次上限为 128 KiB。iPhone / iPad 当前不能作为被控桌面。
|
||||
可从 [App Store](https://apps.apple.com/app/id6828859211) 安装原生控制端,支持触控与虚拟鼠标、多显示器、远端音频、文件收发和文本剪贴板。悬浮菜单的“剪贴板”按钮可主动发送本机文本,单次上限为 128 KiB;画面设置可调整受支持的远端显示器分辨率,并记住已确认的选择。iPhone / iPad 当前不能作为被控桌面,应用内更新检查指向 App Store。
|
||||
|
||||
[使用说明](docs/USER_GUIDE.md#ios) · [构建与签名](apps/ios/README.md)
|
||||
|
||||
@@ -295,7 +328,7 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
||||
|
||||
## 无人值守与无头运行
|
||||
|
||||
- **Windows:** CrossDesk Service 支持锁屏、登录和安全桌面控制。安装版可额外启用登录前远控,使用独立设备 ID 和密码;普通“开机自启”仅在用户登录后启动客户端。功能限制及启停命令见 [Windows 无人值守指南](docs/USER_GUIDE.md#windows-service)。
|
||||
- **Windows:** CrossDesk Service 支持锁屏、登录和安全桌面控制。安装版 Release 开启“开机自启”后,登录前后使用同一本机 ID 和密码;后台服务持有身份与远控连接,用户登录后 GUI 自动启动并接入。便携版和 Debug 不提供此开关。无显示器时会尝试准备虚拟显示器,并优先恢复已记录的显示分辨率。功能限制及启停命令见 [Windows 无人值守指南](docs/USER_GUIDE.md#windows-service)。
|
||||
- **Linux:** 可复用现有桌面,也可创建独立 Xvfb / Xfce 会话,操作方法见 [Linux 无头运行](docs/HEADLESS.md)。
|
||||
|
||||
<a id="self-hosting"></a>
|
||||
@@ -311,12 +344,17 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
||||
## 构建与反馈
|
||||
|
||||
- [终端客户端 CLI](apps/cli/README.md):独立构建、终端使用、Pi 扩展、桥接协议和 npm 打包。
|
||||
- [Agent / MCP 用户指南](apps/mcp/README.md):下载安装、Codex / DSH 配置、连接授权、日常使用与常见问题。
|
||||
- [MCP 开发参考](apps/mcp/DEVELOPMENT.md):源码构建、宿主集成、工具接口、打包与验证范围。
|
||||
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
||||
- [组件版本管理](docs/VERSIONING.md):通过统一脚本查看、设置、递增各组件版本,以及同步构建号和锁文件。
|
||||
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
||||
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
|
||||
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
|
||||
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
|
||||
|
||||
桌面端主菜单中的 **反馈与建议** 可打开 GitHub 问题页面,或将 CrossDesk、MiniRTC 日志及基本版本信息导出为 ZIP。提交问题时可附上日志包;操作入口见 [反馈与诊断日志](docs/USER_GUIDE.md#feedback)。
|
||||
|
||||
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
|
||||
|
||||
<a id="sponsor"></a>
|
||||
|
||||
+48
-10
@@ -24,7 +24,7 @@
|
||||
|
||||
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) · [Trusted devices](#trusted-devices) · [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) · [Agent / MCP](#mcp) · [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>
|
||||
|
||||
@@ -33,9 +33,10 @@ 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.
|
||||
- **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 / 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.
|
||||
- **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.
|
||||
- **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, frame-rate, and remote display resolution controls. Confirmed resolution choices are remembered; the original display mode is restored when the last controller disconnects. Capabilities depend on the platform and device.
|
||||
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, and text clipboard. A dual-pane file browser supports directory navigation, bidirectional file and folder transfers, drag and drop, and transfer management.
|
||||
- **Agent / MCP:** authorize an Agent through a local MCP service to observe and operate remote computers, with live monitoring, manual takeover, multiple sessions, clipboard, and file tools. Currently built from source.
|
||||
- **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.
|
||||
|
||||
@@ -50,18 +51,19 @@ Get a package matching your OS and CPU architecture from the [website](https://w
|
||||
| Windows | **Control + host**. Windows 10+, x64; `.exe` installer, with portable builds also supported |
|
||||
| macOS | **Control + host**. macOS 14.0+; x64 / arm64 `.pkg` for Intel / Apple Silicon |
|
||||
| Linux | **Control + host**. Ubuntu 20.04+, glibc 2.31 baseline; amd64 / arm64 `.deb` |
|
||||
| iOS / iPadOS | **Control only**. iOS 16.0+; sign the unsigned release app yourself, or build and install with Xcode |
|
||||
| iOS / iPadOS | **Control only**. Install from the [App Store](https://apps.apple.com/app/id6828859211); check the listing for its OS requirements. Current source targets iOS 16.0+ and can also be built and signed locally |
|
||||
| Android | **Control only**. Android 8.0+, arm64; install the signed APK or build from source |
|
||||
| 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 |
|
||||
| Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device |
|
||||
| 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 |
|
||||
| MCP (development) | **Control only**. Operate and watch a remote computer in Codex or DSH. DSH needs one plugin package; Codex also needs Node.js 22+. See [installation and usage](apps/mcp/README.md) (Chinese) |
|
||||
|
||||
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).
|
||||
|
||||
- **Linux:** use `crossdesk-linux-…deb` for an existing desktop, or `crossdesk-virtual-desktop-xfce-…deb` for a server needing a separate Xfce desktop. Choose one. See [installation details](docs/USER_GUIDE_EN.md#installation) and [headless operation](docs/HEADLESS_EN.md).
|
||||
- **macOS:** grant Screen Recording and Accessibility when prompted on first launch. These enable desktop capture and remote keyboard/mouse input. See [permissions](docs/USER_GUIDE_EN.md#installation).
|
||||
- **iOS / HarmonyOS:** follow the [iOS](apps/ios/README.md) or [HarmonyOS](apps/harmony/README.md) (Chinese) signing and build guide. Unsigned artifacts cannot be installed directly.
|
||||
- **iOS:** install from the [App Store](https://apps.apple.com/app/id6828859211). Unsigned GitHub artifacts still require signing; see the [iOS build guide](apps/ios/README.md). HarmonyOS requires a signed DevEco build; see the [HarmonyOS guide](apps/harmony/README.md) (Chinese).
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
@@ -135,12 +137,26 @@ View devices by group, click a preview to enlarge it, and take control when need
|
||||
|
||||
Group control sends input to all connected devices; closing DeskHub disconnects all remote connections. See the [DeskHub user guide](docs/USER_GUIDE_EN.md#deskhub) for exact scopes and controls. Screenshots use demonstration data; more views and sources are in the [user guide](docs/USER_GUIDE_EN.md) and [screenshot notes](docs/images/README.md).
|
||||
|
||||
### Dual-pane file transfers
|
||||
|
||||
Open file transfer from the session toolbar to browse local and remote directories side by side. Send selected files or folders, or drag them between panes; the task list shows progress and lets you cancel unfinished transfers. Remote directory browsing requires a compatible host.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-files-en.png" width="960" alt="Dual-pane file transfer with local and remote paths, file lists, send controls, and a transfer task; all data is illustrative" />
|
||||
</p>
|
||||
|
||||
<a id="session"></a>
|
||||
<a id="settings"></a>
|
||||
|
||||
## 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, trusted-device restrictions, 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 bidirectional file transfers. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, trusted-device restrictions, codecs, relay, self-hosting, video pausing when minimized, and the DeskHub video budget.
|
||||
|
||||
For Windows, macOS, and Linux X11 hosts, **Video settings → Resolution** selects a mode supported by the current remote display without reconnecting. Desktop and native mobile controllers remember confirmed choices and reapply them on reconnection when still supported. When the last controller disconnects, the host restores the display mode used before the first change in that connection period. Linux requires X11 capture and RandR; Wayland / XWayland resolution changes are not currently supported.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-stream-video-settings-en.png" width="960" alt="Session video settings: quality, frame rate, remote display resolution, and video preference, over an illustrative public-page capture" />
|
||||
</p>
|
||||
|
||||
- [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.
|
||||
@@ -259,10 +275,27 @@ 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.
|
||||
|
||||
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.
|
||||
The interactive CLI provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. For Agent automation and file or audio tools, use the [MCP service](#mcp) below. The CLI 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)
|
||||
|
||||
<a id="mcp"></a>
|
||||
|
||||
## Agents and MCP
|
||||
|
||||
[CrossDesk MCP](apps/mcp/README.md) lets you request remote tasks in a Codex or DeepSeek Harness chat and watch the remote screen on the right. Run CrossDesk on the remote computer, follow the installation guide for your local app below, then choose and authorize the device in the panel.
|
||||
|
||||
| Host | Installation and configuration | Remote video |
|
||||
| --- | --- | --- |
|
||||
| Codex desktop | [Codex setup](apps/mcp/README.md#install-codex) (Chinese): register the MCP server; no extra plugin | The Agent opens the built-in browser on the right |
|
||||
| DeepSeek Harness desktop (DSH) | [DSH setup](apps/mcp/README.md#install-dsh) (Chinese): install one platform plugin package; MCP registration is automatic | The plugin opens the current chat's browser sidebar automatically |
|
||||
|
||||
1. [Download the platform package for your local computer](apps/mcp/README.md#install-package), then follow the guide for your app above.
|
||||
2. Ask the Agent to open CrossDesk. Choose a device and enter its password in the local monitoring panel, then grant view-only or control access. Saving a password and allowing future automatic connections are separate choices.
|
||||
3. The Agent uses screenshots, keyboard and mouse actions, and structured desktop tools. You can watch live, pause the Agent, take over manually, or disconnect.
|
||||
|
||||
The service supports up to 8 concurrent device sessions, multi-display observation, remote text clipboard, file browsing and transfers, audio, system locking, and unlocking with a password entered locally. Window and accessibility actions depend on the remote platform and permissions; some tools require an updated host. Browser, terminal, and MCP Apps panels have different host requirements. See the [MCP guide](apps/mcp/README.md) (Chinese) for configuration and compatibility.
|
||||
|
||||
<a id="mobile"></a>
|
||||
|
||||
## Mobile and HarmonyOS
|
||||
@@ -271,7 +304,7 @@ The CLI currently provides ID-based viewing and input, without direct IP connect
|
||||
|
||||
### iOS / iPadOS
|
||||
|
||||
The native controller supports touch and a virtual mouse, multiple monitors, remote audio, file transfers, and text clipboard. Tap **剪贴板** (Clipboard) in the floating menu to send local text, up to 128 KiB per transfer. iPhone and iPad cannot currently act as remote hosts. The native interface uses Chinese labels.
|
||||
Install the native controller from the [App Store](https://apps.apple.com/app/id6828859211). It supports touch and a virtual mouse, multiple monitors, remote audio, file transfers, and text clipboard. Tap **Clipboard** in the floating menu to send local text, up to 128 KiB per transfer. Video settings can change supported remote display resolutions and remember confirmed choices. iPhone and iPad cannot currently act as remote hosts; in-app update checks point to the App Store.
|
||||
|
||||
[User guide](docs/USER_GUIDE_EN.md#ios) · [Build and signing](apps/ios/README.md)
|
||||
|
||||
@@ -295,7 +328,7 @@ The PC client acts as both controller and host, sharing the desktop UI and DeskH
|
||||
|
||||
## Unattended and headless access
|
||||
|
||||
- **Windows:** CrossDesk Service supports lock screens, sign-in, and secure desktops. Installed builds can enable access before sign-in with a separate device ID and password; ordinary **Auto Start** runs after a user signs in. See the [Windows unattended guide](docs/USER_GUIDE_EN.md#windows-service) for capabilities, limits, and commands.
|
||||
- **Windows:** CrossDesk Service supports lock screens, sign-in, and secure desktops. Enabling **Auto Start** in an installed Release build uses the same device ID and password before and after sign-in. The background service owns the identity and remote connections; the GUI starts and attaches after sign-in. Portable and Debug builds do not offer this switch. Without a monitor, the host attempts to prepare a virtual display and restore a previously recorded resolution. See the [Windows unattended guide](docs/USER_GUIDE_EN.md#windows-service) for capabilities, limits, and commands.
|
||||
- **Linux:** reuse an existing desktop or create an independent Xvfb / Xfce session. See [Linux headless operation](docs/HEADLESS_EN.md).
|
||||
|
||||
<a id="self-hosting"></a>
|
||||
@@ -310,8 +343,11 @@ Host your own signaling and TURN services, then enable them under **Settings →
|
||||
|
||||
## Build and feedback
|
||||
|
||||
- [Agent / MCP user guide](apps/mcp/README.md) (Chinese): downloads, Codex / DSH setup, device authorization, everyday usage, and troubleshooting.
|
||||
- [MCP developer reference](apps/mcp/DEVELOPMENT.md) (Chinese): source builds, host integration, tool interfaces, packaging, and validation scope.
|
||||
- [Terminal client CLI](apps/cli/README.md) (Chinese): independent builds, terminal usage, Pi extension, bridge protocol, and npm packaging.
|
||||
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
|
||||
- [Component versions](docs/VERSIONING.md) (Chinese): inspect, set, and increment component versions, with synchronized build numbers and lockfiles.
|
||||
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
||||
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
|
||||
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
|
||||
@@ -319,6 +355,8 @@ Host your own signaling and TURN services, then enable them under **Settings →
|
||||
|
||||
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
|
||||
|
||||
Open **Feedback & suggestions** from the desktop main menu to visit GitHub issues or export CrossDesk and MiniRTC logs with basic version information as a ZIP archive. Attach the archive when reporting a problem; see [feedback and diagnostic logs](docs/USER_GUIDE_EN.md#feedback).
|
||||
|
||||
<a id="sponsor"></a>
|
||||
|
||||
## Support CrossDesk
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CrossDesk Android
|
||||
|
||||
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.1,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
|
||||
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。支持 Android 8.0(API 26)及以上的 arm64 手机和平板。桌面、Android、iOS 和 HarmonyOS 共用客户端发布版本,各移动平台构建号独立;可在仓库根目录执行 `python3 scripts/version.py show client` 查看,使用 `set client <版本>` 同步修改,详见[组件版本管理](../../docs/VERSIONING.md)。
|
||||
|
||||
## 当前功能
|
||||
|
||||
@@ -71,7 +71,7 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
|
||||
|
||||
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。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 编译产物和安装包缓存仍独立保存。
|
||||
- `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 提供。
|
||||
@@ -85,11 +85,11 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
|
||||
|
||||
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
|
||||
|
||||
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
|
||||
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先在仓库根目录运行 `python3 scripts/version.py set client <版本>` 同步全部客户端版本及各移动端构建号,检查并提交改动,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
|
||||
|
||||
```sh
|
||||
git tag android-v1.6.1-20261010
|
||||
git push origin android-v1.6.1-20261010
|
||||
git tag android-v1.7.6-20261112
|
||||
git push origin android-v1.7.6-20261112
|
||||
```
|
||||
|
||||
该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
|
||||
@@ -149,7 +149,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
|
||||
|
||||
最近连接卡片与 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 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
plugins { id 'com.android.application' }
|
||||
|
||||
android {
|
||||
namespace 'cn.crossdesk.mobile'
|
||||
compileSdk 36
|
||||
ndkVersion '28.2.13676358'
|
||||
namespace = 'cn.crossdesk.mobile'
|
||||
compileSdk = 36
|
||||
ndkVersion = '28.2.13676358'
|
||||
defaultConfig {
|
||||
applicationId 'cn.crossdesk.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger()
|
||||
versionName providers.gradleProperty('crossdeskVersionName').get()
|
||||
applicationId = 'cn.crossdesk.mobile'
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = providers.gradleProperty('crossdeskVersionCode').get().toInteger()
|
||||
versionName = providers.gradleProperty('crossdeskVersionName').get()
|
||||
ndk { abiFilters 'arm64-v8a' }
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')]
|
||||
sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')]
|
||||
sourceSets.androidTest.java.srcDirs = ['../test/android/java']
|
||||
sourceSets.androidTest.assets.srcDirs = ['../test/android/assets']
|
||||
buildTypes.release { minifyEnabled false }
|
||||
buildTypes.release { minifyEnabled = false }
|
||||
packaging.jniLibs.useLegacyPackaging = false
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="false" android:usesCleartextTraffic="false">
|
||||
<service android:name=".RemoteSessionService" android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
|
||||
@@ -17,6 +17,7 @@ final class BackgroundSession implements NativeSession.Listener {
|
||||
String identity = "", platform = "", clipboard = "", fileStatus = "", endReason = "";
|
||||
JSONArray displays = new JSONArray();
|
||||
RemoteVideoSettings videoSettings;
|
||||
RemoteDisplayResolutions displayResolutions=new RemoteDisplayResolutions();
|
||||
int signalState, selectedDisplay, width, height;
|
||||
boolean muted, remoteServiceAvailable, background, ended;
|
||||
boolean autoLandscape;
|
||||
@@ -55,6 +56,8 @@ final class BackgroundSession implements NativeSession.Listener {
|
||||
}
|
||||
@Override public void data(JSONObject message) {
|
||||
if (view != null) { view.data(message); return; }
|
||||
RemoteDisplayResolutions.Request confirmed=displayResolutions.receive(message);
|
||||
if(confirmed!=null)session.rememberDisplayResolution(confirmed.displayKey,confirmed.mode);
|
||||
if (message.optInt("type", -1) == 12 && videoSettings != null) videoSettings.receive(message);
|
||||
if (message.optInt("type", -1) == 5) {
|
||||
JSONObject service = message.optJSONObject("service_status");
|
||||
@@ -80,6 +83,7 @@ final class BackgroundSession implements NativeSession.Listener {
|
||||
if (view != null) view.fileTransfer(name, progress, sending, received);
|
||||
}
|
||||
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
|
||||
@Override public void displayResolutionFailed(long requestId) { if(view!=null)view.displayResolutionFailed(requestId);else displayResolutions.expire(requestId); }
|
||||
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
|
||||
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
|
||||
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
|
||||
|
||||
@@ -311,6 +311,9 @@ final class LocalizationKeys {
|
||||
IDS.put(",已保存密码", R.string.text_ba9a792d063a8c86);
|
||||
IDS.put(",接收 ", R.string.text_f4c1f9d7896760a7);
|
||||
IDS.put(",点击重试", R.string.text_42237b5ec70c26f3);
|
||||
IDS.put("被控端分辨率", R.string.text_59c9dca5568a6cd3);
|
||||
IDS.put("设备不支持", R.string.text_36e0073276c0e39f);
|
||||
IDS.put("等待电脑端确认…", R.string.text_7b3440ad0f81b811);
|
||||
}
|
||||
private LocalizationKeys() {}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
|
||||
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private Runnable videoSettingsTimeout;
|
||||
private RemoteDisplayResolutions displayResolutions=new RemoteDisplayResolutions();
|
||||
private Runnable displayResolutionTimeout;
|
||||
private final Set<Integer> pressed=new HashSet<>();
|
||||
private Runnable backAction;
|
||||
|
||||
@@ -118,6 +120,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private String serverKey(){return host+":"+port;}
|
||||
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();}
|
||||
// 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){
|
||||
PageHost.Motion motion=PageHost.Motion.NONE;
|
||||
if(canvas!=null&&foreground&&!page.equals(nextPage)){
|
||||
@@ -147,6 +152,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||
applySystemBars(dark);
|
||||
}
|
||||
// Android 8–10 require system UI flags instead of WindowInsetsController.
|
||||
@SuppressWarnings("deprecation")
|
||||
private void applySystemBars(boolean dark){
|
||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||
android.view.WindowInsetsController controller=getWindow().getInsetsController();
|
||||
@@ -302,7 +309,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||
});connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48));
|
||||
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;});
|
||||
}
|
||||
private void sizePasswordDialog(){
|
||||
@@ -342,8 +351,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
|
||||
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
|
||||
public void displayResolution(RemoteDisplayResolutions.Mode mode){updateDisplayResolution(mode);}
|
||||
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
|
||||
});controls.setHostPlatform(hostPlatform);controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
});controls.setHostPlatform(hostPlatform);controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);controls.displayResolutions(displayResolutions,selectedDisplay);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
|
||||
privacyChanged();
|
||||
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
|
||||
@@ -499,7 +509,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
|
||||
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
|
||||
private void selectDisplay(){
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast(L10n.tr("等待远端屏幕信息"));return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?L10n.tr("显示器"):display.optString("name",L10n.tr("显示器")));}new AlertDialog.Builder(this).setTitle(L10n.tr("显示器")).setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast(L10n.tr("等待远端屏幕信息"));return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?L10n.tr("显示器"):display.optString("name",L10n.tr("显示器")));}new AlertDialog.Builder(this).setTitle(L10n.tr("显示器")).setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
}
|
||||
private void secureAttention(){
|
||||
if(!ready||session==null||!hostPlatform.equals("windows"))return;
|
||||
@@ -638,7 +648,26 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
remoteVersionCheck.reset();
|
||||
if(remoteUpdateDialog!=null){remoteUpdateDialog.dismiss();remoteUpdateDialog=null;}
|
||||
}
|
||||
private void cancelDisplayResolutionTimeout(){if(displayResolutionTimeout!=null)mainHandler.removeCallbacks(displayResolutionTimeout);displayResolutionTimeout=null;}
|
||||
private void updateDisplayResolution(RemoteDisplayResolutions.Mode mode){
|
||||
if(!ready||session==null||!hasNetworkConsent())return;
|
||||
RemoteDisplayResolutions.Display display=displayResolutions.display(selectedDisplay);
|
||||
if(!displayResolutions.pending&&display!=null&&display.supports(mode)&&display.current.same(mode)){
|
||||
session.rememberDisplayResolution(display.key,mode);return;
|
||||
}
|
||||
RemoteDisplayResolutions.Request request=displayResolutions.begin(selectedDisplay,mode);if(request==null)return;
|
||||
cancelDisplayResolutionTimeout();session.displayResolution(request);
|
||||
if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);
|
||||
displayResolutionTimeout=()->{displayResolutions.expire(request.id);if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);};
|
||||
mainHandler.postDelayed(displayResolutionTimeout,10000);
|
||||
}
|
||||
@Override public void displayResolutionFailed(long requestId){
|
||||
displayResolutions.expire(requestId);
|
||||
if(!displayResolutions.pending)cancelDisplayResolutionTimeout();
|
||||
if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);
|
||||
}
|
||||
private void resetVideoSettings(){
|
||||
cancelDisplayResolutionTimeout();displayResolutions=new RemoteDisplayResolutions();
|
||||
cancelVideoSettingsTimeout();
|
||||
android.util.DisplayMetrics metrics=getResources().getDisplayMetrics();
|
||||
videoSettings=new RemoteVideoSettings(preferences.getInt("videoPreference",1),Math.max(metrics.widthPixels,metrics.heightPixels));
|
||||
@@ -651,6 +680,13 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
@Override public void data(JSONObject message){
|
||||
if(session==null)return;
|
||||
if(message.optInt("type",-1)==3||message.optInt("type",-1)==16){
|
||||
RemoteDisplayResolutions.Request confirmed=displayResolutions.receive(message);
|
||||
if(confirmed!=null&&session!=null)session.rememberDisplayResolution(confirmed.displayKey,confirmed.mode);
|
||||
if(!displayResolutions.pending)cancelDisplayResolutionTimeout();
|
||||
if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);
|
||||
if(message.optInt("type",-1)==16)return;
|
||||
}
|
||||
if(message.optInt("type",-1)==5){JSONObject service=message.optJSONObject("service_status");remoteServiceAvailable=service!=null&&service.optBoolean("available",false);return;}
|
||||
if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;}
|
||||
JSONObject info=message.optJSONObject("host_info");
|
||||
@@ -715,6 +751,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 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);}
|
||||
// API 26–32 use this callback; API 33+ registers OnBackInvokedCallback in onCreate.
|
||||
@SuppressWarnings("deprecation")
|
||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||
@Override public void onConfigurationChanged(Configuration config){
|
||||
super.onConfigurationChanged(config);
|
||||
@@ -737,7 +775,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
BackgroundSession retained=backgroundSession;if(retained==null)return;
|
||||
retained.identity=localIdentity;retained.signalState=signalState;retained.platform=hostPlatform;retained.displays=displays;
|
||||
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
|
||||
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
|
||||
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.displayResolutions=displayResolutions;retained.receivedFile=receivedFile;
|
||||
retained.width=videoWidth;retained.height=videoHeight;
|
||||
retained.autoLandscape=remoteOrientation.autoLandscape();
|
||||
}
|
||||
@@ -774,10 +812,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
connectedRemote=retained.remote;localIdentity=retained.identity;signalState=retained.signalState;signalError="";
|
||||
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
|
||||
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
|
||||
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
|
||||
videoSettings=retained.videoSettings;displayResolutions=retained.displayResolutions;ready=true;retained.attach(this);
|
||||
if(rebuild)sessionScreen(retained.autoLandscape);
|
||||
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
|
||||
if(controls!=null){controls.setHostPlatform(hostPlatform);controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
|
||||
if(controls!=null){controls.setHostPlatform(hostPlatform);controls.setMuted(muted);controls.videoSettings(videoSettings);controls.displayResolutions(displayResolutions,selectedDisplay);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
|
||||
retained.setBackground(false);signaling(signalState,localIdentity);
|
||||
return true;
|
||||
}
|
||||
@@ -835,11 +873,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
if(!isFinishing()&&!enteredPictureInPicture&&ready&&(backgroundSession!=null||documentSession==null)&&prepareBackgroundSession(false)){
|
||||
captureBackgroundSession();backgroundSession.detach(this);backgroundSession.setBackground(true);
|
||||
mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime());announcementInbox.setConnected(false);
|
||||
cancelDisplayResolutionTimeout();if(displayResolutions.pending)displayResolutions.expire(displayResolutions.requested.id);
|
||||
cancelVideoSettingsTimeout();if(videoSettings.pending)videoSettings.expire(videoSettings.requestId);
|
||||
resetRemoteVersionCheck();if(disconnectDialog!=null)disconnectDialog.dismiss();
|
||||
}else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":L10n.tr("远程窗口已关闭,或后台保活未能启动,会话已结束"));stopSignaling();}
|
||||
super.onStop();}
|
||||
@Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
|
||||
@Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();cancelDisplayResolutionTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
|
||||
if(connectionDialog!=null)connectionDialog.dismiss();
|
||||
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
|
||||
if(backgroundSession!=null)backgroundSession.detach(this);
|
||||
|
||||
@@ -90,6 +90,8 @@ final class MobileUi {
|
||||
}
|
||||
@Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);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; }
|
||||
}
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
|
||||
|
||||
@@ -31,6 +31,7 @@ final class NativeSession implements AutoCloseable {
|
||||
default void fileTransfer(String name,double progress,boolean sending,File received) { }
|
||||
default void controlFeedback(String message) { }
|
||||
default void privacyChanged() { }
|
||||
default void displayResolutionFailed(long requestId) { }
|
||||
default void signaling(int state, String deviceId) { }
|
||||
default void presence(JSONObject message) { }
|
||||
default void announcement(JSONObject message) { }
|
||||
@@ -43,6 +44,7 @@ final class NativeSession implements AutoCloseable {
|
||||
private final Context context;
|
||||
private volatile Listener listener;
|
||||
private final SecretStore secrets;
|
||||
private final RemoteResolutionStore resolutionStore;
|
||||
private final String host, scope;
|
||||
private volatile String remote;
|
||||
private String password, identity = "";
|
||||
@@ -79,6 +81,7 @@ final class NativeSession implements AutoCloseable {
|
||||
this.host = host; this.port = port; this.remote = remote; this.password = password;
|
||||
scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port;
|
||||
secrets = new SecretStore(this.context);
|
||||
resolutionStore = new RemoteResolutionStore(this.context);
|
||||
documents = new SessionDocuments(this.context);
|
||||
}
|
||||
void start() {
|
||||
@@ -183,7 +186,8 @@ final class NativeSession implements AutoCloseable {
|
||||
int status = event.getInt("status");
|
||||
if (status == 1) {
|
||||
if (connected) return; // Repeated transport readiness is the same desktop session.
|
||||
connected = true; main.removeCallbacks(timeout); nReady(handle);
|
||||
connected = true; main.removeCallbacks(timeout);
|
||||
nReady(handle,resolutionStore.load(host,port,remote).toString().getBytes(StandardCharsets.UTF_8));
|
||||
audio = new AudioPlayer();
|
||||
nControl(handle, 2, 1);
|
||||
ui(() -> { listener.connected(); if(!closed){statisticsTick.run();fileTick.run();} });
|
||||
@@ -243,6 +247,12 @@ final class NativeSession implements AutoCloseable {
|
||||
if(!settings.valid()||requestId<0||requestId>0xffffffffL)return;
|
||||
execute(()->{if(connected&&!nVideoSettings(handle,settings.quality,settings.frameRate,settings.preference,requestId))ui(()->listener.data(settings.status(requestId,false)));});
|
||||
}
|
||||
void displayResolution(RemoteDisplayResolutions.Request request){
|
||||
execute(()->{if(!connected||!nDisplayResolution(handle,request.displayId,request.displayKey,request.mode.width,request.mode.height,request.id))ui(()->listener.displayResolutionFailed(request.id));});
|
||||
}
|
||||
void rememberDisplayResolution(String displayKey,RemoteDisplayResolutions.Mode mode){
|
||||
if(!closed&&connected)resolutionStore.remember(host,port,remote,displayKey,mode);
|
||||
}
|
||||
void announcementRequest(JSONObject request,String requestId){
|
||||
byte[] bytes=request.toString().getBytes(StandardCharsets.UTF_8);
|
||||
execute(()->{if(!signalReady||identity.isEmpty()||!nAnnouncementRequest(handle,bytes))ui(()->listener.announcementFailed(requestId));});
|
||||
@@ -339,11 +349,12 @@ final class NativeSession implements AutoCloseable {
|
||||
static native String nCheckMobileUpdate(String version, byte[] releaseJson);
|
||||
private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots);
|
||||
private static native boolean nConnect(long handle, String identity, String remote, String password);
|
||||
private static native void nReady(long handle);
|
||||
private static native void nReady(long handle,byte[] displayResolutions);
|
||||
private static native void nPointer(long handle, float x, float y, int flag, int wheel);
|
||||
private static native void nKey(long handle, int code, boolean down);
|
||||
private static native void nText(long handle,byte[] text);
|
||||
private static native void nControl(long handle, int type, int value);
|
||||
private static native boolean nDisplayResolution(long handle,int displayId,String displayKey,int width,int height,long requestId);
|
||||
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
|
||||
private static native boolean nAnnouncementRequest(long handle,byte[] data);
|
||||
private static native boolean nPresenceRequest(long handle,byte[] data);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Host-confirmed desktop modes. Requests are scoped to the native display identity. */
|
||||
final class RemoteDisplayResolutions {
|
||||
static final class Mode {
|
||||
final int width, height;
|
||||
Mode(int width,int height){this.width=width;this.height=height;}
|
||||
boolean same(Mode other){return other!=null&&width==other.width&&height==other.height;}
|
||||
String label(){return width+" × "+height;}
|
||||
}
|
||||
static final class Display {
|
||||
final int id;
|
||||
final String key;
|
||||
final Mode current;
|
||||
final List<Mode> modes=new ArrayList<>();
|
||||
Display(JSONObject value)throws Exception{
|
||||
id=(int)integer(value,"display_id",0,31);
|
||||
Object identity=value.get("display_key");
|
||||
if(!(identity instanceof String))throw new IllegalArgumentException();
|
||||
key=(String)identity;
|
||||
if(key.isEmpty()||key.getBytes(StandardCharsets.UTF_8).length>1024||key.indexOf('\0')>=0)throw new IllegalArgumentException();
|
||||
current=mode(value);
|
||||
JSONArray options=value.getJSONArray("modes");
|
||||
if(options.length()>256)throw new IllegalArgumentException();
|
||||
for(int i=0;i<options.length();i++){
|
||||
Mode item=mode(options.getJSONObject(i));
|
||||
if(!supports(item))modes.add(item);
|
||||
}
|
||||
}
|
||||
boolean supports(Mode value){for(Mode item:modes)if(item.same(value))return true;return false;}
|
||||
}
|
||||
static final class Request {
|
||||
final int displayId;
|
||||
final String displayKey;
|
||||
final Mode mode;
|
||||
final long id;
|
||||
Request(Display display,Mode mode,long id){this.displayId=display.id;this.displayKey=display.key;this.mode=mode;this.id=id;}
|
||||
boolean matches(JSONObject value)throws Exception{
|
||||
return integer(value,"request_id",1,0xffffffffL)==id&&integer(value,"display_id",0,31)==displayId
|
||||
&&displayKey.equals(value.get("display_key"))&&mode.same(mode(value));
|
||||
}
|
||||
}
|
||||
private List<Display> displays=new ArrayList<>();
|
||||
Request requested;
|
||||
boolean pending,failed;
|
||||
Display display(int id){for(Display display:displays)if(display.id==id)return display;return null;}
|
||||
Request begin(int displayId,Mode mode){
|
||||
Display display=display(displayId);
|
||||
if(pending||display==null||!display.supports(mode)||display.current.same(mode))return null;
|
||||
long next=requested==null?1:(requested.id+1)&0xffffffffL;
|
||||
requested=new Request(display,mode,next==0?1:next);pending=true;failed=false;return requested;
|
||||
}
|
||||
void expire(long id){if(pending&&requested!=null&&requested.id==id){pending=false;failed=true;}}
|
||||
Request receive(JSONObject message){
|
||||
try{
|
||||
int type=message.getInt("type");JSONArray values;JSONObject response=null;
|
||||
if(type==3){JSONObject host=message.getJSONObject("host_info");values=host.has("display_resolutions")?host.getJSONArray("display_resolutions"):new JSONArray();}
|
||||
else if(type==16){response=message.getJSONObject("display_resolution");if(requested==null||!requested.matches(response)||!(response.get("accepted") instanceof Boolean))return null;values=message.getJSONArray("display_resolutions");}
|
||||
else return null;
|
||||
if(values.length()>32)return null;
|
||||
List<Display> actual=new ArrayList<>();
|
||||
for(int i=0;i<values.length();i++){
|
||||
Display display=new Display(values.getJSONObject(i));
|
||||
for(Display previous:actual)if(previous.id==display.id)return null;
|
||||
actual.add(display);
|
||||
}
|
||||
displays=actual;
|
||||
if(response!=null){
|
||||
Display display=display(requested.displayId);pending=false;
|
||||
failed=!response.getBoolean("accepted")||display==null||!display.key.equals(requested.displayKey)||!display.current.same(requested.mode);
|
||||
if(!failed)return requested;
|
||||
}else if(pending){
|
||||
Display display=display(requested.displayId);
|
||||
if(display==null||!display.key.equals(requested.displayKey)||!display.supports(requested.mode))expire(requested.id);
|
||||
}
|
||||
}catch(Exception ignored){ /* Ignore malformed or unrelated control messages. */ }
|
||||
return null;
|
||||
}
|
||||
String feedback(){return pending?L10n.tr("等待电脑端确认…"):failed?L10n.tr("调整失败,请重试。"):"";}
|
||||
private static Mode mode(JSONObject value)throws Exception{return new Mode((int)integer(value,"width",1,32768),(int)integer(value,"height",1,32768));}
|
||||
private static long integer(JSONObject value,String key,long min,long max)throws Exception{
|
||||
Object raw=value.get(key);
|
||||
if(!(raw instanceof Integer)&&!(raw instanceof Long))throw new IllegalArgumentException();
|
||||
long number=((Number)raw).longValue();if(number<min||number>max)throw new IllegalArgumentException();return number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Successful selections, scoped to the signaling server, device and native screen. */
|
||||
final class RemoteResolutionStore {
|
||||
private final SharedPreferences preferences;
|
||||
RemoteResolutionStore(Context context) {
|
||||
preferences=context.getSharedPreferences("remote_display_resolutions",Context.MODE_PRIVATE);
|
||||
}
|
||||
private String key(String host,int port,String remote) {
|
||||
return new JSONArray().put(host.trim().toLowerCase(Locale.ROOT)).put(port).put(remote).toString();
|
||||
}
|
||||
private static boolean validKey(String key) {
|
||||
return !key.isEmpty()&&key.getBytes(StandardCharsets.UTF_8).length<=1024&&key.indexOf('\0')<0;
|
||||
}
|
||||
private static int dimension(JSONObject value,String key)throws Exception {
|
||||
Object raw=value.get(key);
|
||||
if(!(raw instanceof Integer)&&!(raw instanceof Long))throw new IllegalArgumentException();
|
||||
long number=((Number)raw).longValue();
|
||||
if(number<1||number>32768)throw new IllegalArgumentException();
|
||||
return (int)number;
|
||||
}
|
||||
synchronized JSONArray load(String host,int port,String remote) {
|
||||
if(remote.isEmpty())return new JSONArray();
|
||||
try {
|
||||
String text=preferences.getString(key(host,port,remote),"[]");
|
||||
if(text==null||text.length()>65536)return new JSONArray();
|
||||
JSONArray values=new JSONArray(text);
|
||||
if(values.length()>32)return new JSONArray();
|
||||
HashSet<String> keys=new HashSet<>();
|
||||
JSONArray result=new JSONArray();
|
||||
for(int i=0;i<values.length();i++) {
|
||||
JSONObject value=values.getJSONObject(i);
|
||||
Object identity=value.get("display_key");
|
||||
if(!(identity instanceof String)||!validKey((String)identity)||!keys.add((String)identity))return new JSONArray();
|
||||
result.put(new JSONObject().put("display_key",identity).put("width",dimension(value,"width")).put("height",dimension(value,"height")));
|
||||
}
|
||||
return result;
|
||||
} catch(Exception ignored) { return new JSONArray(); }
|
||||
}
|
||||
synchronized void remember(String host,int port,String remote,String displayKey,RemoteDisplayResolutions.Mode mode) {
|
||||
if(remote.isEmpty()||!validKey(displayKey)||mode==null||mode.width<1||mode.width>32768||mode.height<1||mode.height>32768)return;
|
||||
try {
|
||||
JSONArray saved=load(host,port,remote),updated=new JSONArray();
|
||||
for(int i=0;i<saved.length();i++) {
|
||||
JSONObject value=saved.getJSONObject(i);
|
||||
if(!displayKey.equals(value.getString("display_key")))updated.put(value);
|
||||
}
|
||||
if(updated.length()>=32)updated.remove(0);
|
||||
updated.put(new JSONObject().put("display_key",displayKey).put("width",mode.width).put("height",mode.height));
|
||||
preferences.edit().putString(key(host,port,remote),updated.toString()).apply();
|
||||
} catch(Exception error) { Log.w("CrossDesk","Could not save remote display resolution",error); }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final class SessionControls extends FrameLayout {
|
||||
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
|
||||
default boolean textEdit(int deletions,String value){return false;}
|
||||
void videoSettings(int field,int value);
|
||||
default void displayResolution(RemoteDisplayResolutions.Mode mode) { }
|
||||
void sendFile(); void saveFile(); void secureAttention();
|
||||
default void togglePrivacy() { }
|
||||
}
|
||||
@@ -36,7 +37,10 @@ final class SessionControls extends FrameLayout {
|
||||
int height=Math.max(0,getRootView().getHeight()-visible.bottom);
|
||||
keyboardInsets(height,height>ui.dp(80),0);
|
||||
}
|
||||
private TextView videoFeedback;
|
||||
private TextView videoFeedback,resolutionButton,resolutionFeedback;
|
||||
private RemoteDisplayResolutions resolutions=new RemoteDisplayResolutions();
|
||||
private int resolutionDisplay;
|
||||
private android.app.AlertDialog resolutionDialog;
|
||||
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
|
||||
private View privacyButton;
|
||||
private MobileUi.Icon privacyIcon;
|
||||
@@ -111,13 +115,14 @@ final class SessionControls extends FrameLayout {
|
||||
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt,network.remoteEncoder,network.localDecoder));}
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
||||
void displayResolutions(RemoteDisplayResolutions value,int display){resolutions=value;resolutionDisplay=display;renderResolutions();}
|
||||
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
|
||||
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
|
||||
private void renderFileStatus(){
|
||||
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
|
||||
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
|
||||
}
|
||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;privacyIcon=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription(L10n.tr("展开远程控制菜单"));positionMouse();}
|
||||
private void closeMenu(){if(resolutionDialog!=null){resolutionDialog.dismiss();resolutionDialog=null;}resolutionButton=resolutionFeedback=null;if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;privacyIcon=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription(L10n.tr("展开远程控制菜单"));positionMouse();}
|
||||
private void openMenu(String page){
|
||||
cancelMouseInput();
|
||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
||||
@@ -153,6 +158,9 @@ final class SessionControls extends FrameLayout {
|
||||
}else if(page.equals("network")){
|
||||
networkPage(body);
|
||||
}else{
|
||||
TextView resolutionTitle=ui.text(L10n.tr("被控端分辨率"),12,false);resolutionTitle.setTextColor(SECONDARY);body.addView(resolutionTitle);ui.gap(body,5);
|
||||
resolutionButton=ui.action("",INK,this::chooseResolution);resolutionButton.setTextSize(13);resolutionButton.setBackground(ui.background(0xFFF0F0F2,7));body.addView(resolutionButton,ui.size(-1,34));
|
||||
resolutionFeedback=ui.text("",12,false);resolutionFeedback.setTextColor(SECONDARY);resolutionFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(resolutionFeedback);ui.gap(body,12);renderResolutions();
|
||||
settingRows.add(new SettingRow(body,0,L10n.tr("画面质量"),new String[]{L10n.tr("低"),L10n.tr("中"),L10n.tr("高")},new int[]{0,1,2}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,1,L10n.tr("画面采集帧率"),new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,2,L10n.tr("画面偏好"),RemoteVideoSettings.preferences(),new int[]{0,1,2}));renderVideoSettings();
|
||||
@@ -192,6 +200,26 @@ final class SessionControls extends FrameLayout {
|
||||
}
|
||||
void render(){group.setSelection(selectionIndex());group.setOptionsEnabled(settings.supported);}
|
||||
}
|
||||
private void renderResolutions(){
|
||||
if(resolutionButton==null)return;
|
||||
RemoteDisplayResolutions.Display display=resolutions.display(resolutionDisplay);
|
||||
boolean enabled=display!=null&&!display.modes.isEmpty()&&!resolutions.pending;
|
||||
resolutionButton.setText(display==null?L10n.tr("设备不支持"):display.current.label()+" ▾");
|
||||
resolutionButton.setEnabled(enabled);resolutionButton.setAlpha(enabled?1f:.45f);
|
||||
String feedback=resolutions.feedback();resolutionFeedback.setText(feedback);resolutionFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);
|
||||
}
|
||||
private void chooseResolution(){
|
||||
RemoteDisplayResolutions.Display display=resolutions.display(resolutionDisplay);
|
||||
if(display==null||resolutions.pending||display.modes.isEmpty())return;
|
||||
String[] labels=new String[display.modes.size()];int selected=-1;
|
||||
for(int i=0;i<labels.length;i++){labels[i]=display.modes.get(i).label();if(display.current.same(display.modes.get(i)))selected=i;}
|
||||
resolutionDialog=new android.app.AlertDialog.Builder(getContext()).setTitle(L10n.tr("被控端分辨率"))
|
||||
.setSingleChoiceItems(labels,selected,(dialog,index)->{
|
||||
dialog.dismiss();resolutionDialog=null;
|
||||
RemoteDisplayResolutions.Display current=resolutions.display(resolutionDisplay);
|
||||
if(current!=null&¤t.id==display.id&¤t.key.equals(display.key))actions.displayResolution(display.modes.get(index));
|
||||
}).setNegativeButton(L10n.tr("取消"),null).show();
|
||||
}
|
||||
private void renderVideoSettings(){
|
||||
if(videoFeedback!=null){String feedback=settings.feedback();videoFeedback.setText(feedback);videoFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);}
|
||||
for(SettingRow row:settingRows)row.render();
|
||||
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">",已保存密码"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">",点击重试"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"被控端分辨率"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"设备不支持"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"等待电脑端确认…"</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">",已儲存密碼"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">",點選重試"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"被控端解析度"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"裝置不支援"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"等待電腦端確認…"</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", Passwort gespeichert"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", empfangen "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", klicken Sie auf Wiederholen"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Auflösung des entfernten Bildschirms"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"Nicht unterstützt"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"Warten auf Bestätigung vom Computer..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", contraseña guardada"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", recibir "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", haga clic en Reintentar"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Resolución de la pantalla remota"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"No compatible"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"Esperando confirmación del ordenador..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", mot de passe enregistré"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", recevoir "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", cliquez sur Réessayer"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Résolution de l’écran distant"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"Non pris en charge"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"En attente de confirmation de l\'ordinateur..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">"、パスワードが保存されました"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">"、受け取ります "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">"、「再試行」をクリックします"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"接続先の画面解像度"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"サポートされていません"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"コンピューターからの確認を待っています..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", 비밀번호가 저장되었습니다"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", 수신 "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", 재시도를 클릭하세요."</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"원격 화면 해상도"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"지원되지 않음"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"컴퓨터의 확인을 기다리는 중..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", senha salva"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", receba "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", clique em Tentar novamente"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Resolução da tela remota"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"Não suportado"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"Aguardando confirmação do computador..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", пароль сохранен"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", получить "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">"нажмите «Повторить»."</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Разрешение удалённого экрана"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"Не поддерживается"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"Ожидание подтверждения от компьютера..."</string>
|
||||
</resources>
|
||||
@@ -305,4 +305,7 @@
|
||||
<string name="text_ba9a792d063a8c86" formatted="false">", password saved"</string>
|
||||
<string name="text_f4c1f9d7896760a7" formatted="false">", receive "</string>
|
||||
<string name="text_42237b5ec70c26f3" formatted="false">", click Retry"</string>
|
||||
<string name="text_59c9dca5568a6cd3" formatted="false">"Remote display resolution"</string>
|
||||
<string name="text_36e0073276c0e39f" formatted="false">"Unsupported"</string>
|
||||
<string name="text_7b3440ad0f81b811" formatted="false">"Waiting for confirmation from the computer..."</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
crossdeskVersionName=1.6.1
|
||||
crossdeskVersionCode=3
|
||||
crossdeskVersionName=1.7.6
|
||||
crossdeskVersionCode=4
|
||||
BIN
Binary file not shown.
+4
-2
@@ -1,8 +1,10 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
distributionSha256Sum=17f277867f6914d61b1aa02efab1ba7bb439ad652ca485cd8ca6842fccec6e43
|
||||
distributionSha256Sum=7aaba33a6aa7d03e0c7890869da1f1b5e1765c4d71db981a9d5db5cc47a10a6d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+3
-3
@@ -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:
|
||||
#
|
||||
@@ -29,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (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.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
Vendored
+52
-33
@@ -19,12 +19,39 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
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
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -59,35 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem Execute gradlew
|
||||
@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 End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
: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
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -2,7 +2,6 @@
|
||||
#include <android/native_window_jni.h>
|
||||
#include <libyuv.h>
|
||||
#include <minirtc.h>
|
||||
#include <log.h>
|
||||
#include <remote_action.h>
|
||||
#include <app_version.h>
|
||||
#include <stream_names.h>
|
||||
@@ -19,7 +18,6 @@
|
||||
#include <vector>
|
||||
#include "controller_protocol.h"
|
||||
#include "file_transfer_session.h"
|
||||
#include "android_codec_support.h"
|
||||
#include "latest_frame_worker.h"
|
||||
#include "video_picture.h"
|
||||
#include "video_timing.h"
|
||||
@@ -52,8 +50,8 @@ struct CallbackContext { Session* owner; bool controller; };
|
||||
struct Session {
|
||||
jobject owner = nullptr;
|
||||
jmethodID event_method, audio_method, frame_method;
|
||||
PeerPtr* identity = nullptr;
|
||||
PeerPtr* controller = nullptr;
|
||||
MiniRtcPeerPtr* identity = nullptr;
|
||||
MiniRtcPeerPtr* controller = nullptr;
|
||||
CallbackContext identity_context{this, false}, controller_context{this, true};
|
||||
std::string host, log_path, identity_login, controller_login, remote;
|
||||
int port;
|
||||
@@ -80,7 +78,7 @@ struct Session {
|
||||
android_controller::FileTransferSession files{
|
||||
[this](const char* stream, const char* data, size_t size) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
std::lock_guard<std::mutex> lock(render_mutex);
|
||||
@@ -133,25 +131,25 @@ struct Session {
|
||||
stopping = true;
|
||||
renderer.Stop();
|
||||
if (controller) {
|
||||
LeaveConnection(controller, remote.c_str());
|
||||
DestroyPeer(&controller);
|
||||
MiniRtcLeaveConnection(controller, remote.c_str());
|
||||
MiniRtcDestroyPeer(&controller);
|
||||
}
|
||||
if (identity) DestroyPeer(&identity);
|
||||
if (identity) MiniRtcDestroyPeer(&identity);
|
||||
Environment scope;
|
||||
Surface(scope.env, nullptr);
|
||||
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);
|
||||
if (!ctx->controller && status == SignalStatus::SignalConnected &&
|
||||
if (!ctx->controller && status == MiniRtcSignalStatus::MiniRtcSignalConnected &&
|
||||
!ctx->owner->stopping && ctx->owner->identity) {
|
||||
// Report after every identity login, including reconnects. The server
|
||||
// ignores client_info sent through the temporary C- controller peer.
|
||||
constexpr char info[] =
|
||||
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)
|
||||
++ctx->owner->signal_generation;
|
||||
@@ -171,14 +169,14 @@ void SignalMessage(const char* data, size_t size, void* user) {
|
||||
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);
|
||||
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) {
|
||||
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);
|
||||
} else if (ctx->controller && stats) {
|
||||
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}});
|
||||
}
|
||||
// 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(
|
||||
picture.captured_us, picture.received_us, now);
|
||||
Environment scope;
|
||||
@@ -261,8 +259,8 @@ void RenderVideo(Session* s, VideoPicture picture) {
|
||||
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
||||
}
|
||||
}
|
||||
Params Parameters(Session* s, bool controller) {
|
||||
Params p{};
|
||||
MiniRtcParams Parameters(Session* s, bool controller) {
|
||||
MiniRtcParams p{};
|
||||
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());
|
||||
p.signal_server_port = s->port;
|
||||
@@ -270,12 +268,12 @@ Params Parameters(Session* s, bool controller) {
|
||||
// MediaCodec returns retained Surface buffers; software fallback returns
|
||||
// pooled CPU NV12 with the same ownership contract.
|
||||
p.native_video_output = controller;
|
||||
p.turn_mode = TurnAutoUdpTcp;
|
||||
p.turn_mode = MiniRtcTurnAutoUdpTcp;
|
||||
p.enable_srtp = true;
|
||||
p.video_content_type = VideoContentType::ScreenContent;
|
||||
p.video_quality = QualityMedium;
|
||||
p.video_content_type = MiniRtcVideoContentType::ScreenContent;
|
||||
p.video_quality = MiniRtcVideoQualityMedium;
|
||||
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_message = SignalMessage;
|
||||
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*) {
|
||||
vm = java_vm;
|
||||
minirtc::android::SetJavaVm(java_vm);
|
||||
if (MiniRtcSetAndroidJavaVm(java_vm) != 0) return JNI_ERR;
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
#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");
|
||||
// MiniRTC's diagnostic log includes login identities. Keep credentials
|
||||
// solely in Keystore-backed storage; do not persist its session logging.
|
||||
minirtc::InitLogger(s->log_path);
|
||||
minirtc::get_logger()->set_level(spdlog::level::off);
|
||||
if (MiniRtcConfigureLogging(MiniRtcLogOff, nullptr, nullptr) != 0)
|
||||
throw std::runtime_error("Unable to configure MiniRTC logging");
|
||||
auto params = Parameters(s.get(), false);
|
||||
s->identity = CreatePeer(¶ms);
|
||||
if (!s->identity || Init(s->identity) != 0) return 0;
|
||||
s->identity = MiniRtcCreatePeer(¶ms);
|
||||
if (!s->identity || MiniRtcInit(s->identity) != 0) return 0;
|
||||
return reinterpret_cast<jlong>(s.release());
|
||||
} catch (const std::exception&) { return 0; }
|
||||
}
|
||||
@@ -358,20 +356,26 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
|
||||
s->controller_login = "C-" + String(env, identity);
|
||||
s->remote = String(env, remote);
|
||||
auto params = Parameters(s, true);
|
||||
s->controller = CreatePeer(¶ms);
|
||||
s->controller = MiniRtcCreatePeer(¶ms);
|
||||
if (!s->controller) return false;
|
||||
AddAudioStream(s->controller, kAudioStream);
|
||||
AddDataStream(s->controller, kDataStream, false);
|
||||
MiniRtcAddAudioStream(s->controller, kAudioStream);
|
||||
MiniRtcAddDataStream(s->controller, kDataStream, false);
|
||||
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
|
||||
kClipboardStream, kFileStream, kFileFeedbackStream}) AddDataStream(s->controller, name, true);
|
||||
if (Init(s->controller) != 0) return false;
|
||||
kClipboardStream, kFileStream, kFileFeedbackStream}) MiniRtcAddDataStream(s->controller, name, true);
|
||||
if (MiniRtcInit(s->controller) != 0) return false;
|
||||
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* env, jclass, jlong handle, jbyteArray preferences) {
|
||||
auto* s = From(handle); if (!s || !s->controller) return;
|
||||
auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
|
||||
action.i.supports_video_codec_status = true;
|
||||
if (preferences && env->GetArrayLength(preferences) <= 65536) {
|
||||
std::string bytes(env->GetArrayLength(preferences), '\0');
|
||||
env->GetByteArrayRegion(preferences, 0, bytes.size(), reinterpret_cast<jbyte*>(bytes.data()));
|
||||
if (!env->ExceptionCheck())
|
||||
ParseDisplayResolutionPreferences(bytes, action.preferred_display_resolutions);
|
||||
}
|
||||
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
|
||||
@@ -422,13 +426,23 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)(
|
||||
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));
|
||||
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(nDisplayResolution)(
|
||||
JNIEnv* env, jclass, jlong handle, jint display_id, jstring display_key,
|
||||
jint width, jint height, jlong request_id) {
|
||||
auto* s = From(handle);
|
||||
if (!s || !s->controller || s->stopping || request_id <= 0 || request_id > UINT32_MAX) return false;
|
||||
const auto data = mobile_controller::DisplayResolutionMessage(
|
||||
display_id, String(env, display_key), width, height, static_cast<uint32_t>(request_id));
|
||||
return !data.empty() && MiniRtcSendReliableDataFrame(
|
||||
s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) {
|
||||
auto* s = From(handle);
|
||||
if (!s || !s->controller || s->stopping) return false;
|
||||
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)(
|
||||
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
|
||||
@@ -436,7 +450,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
|
||||
if (!s || !s->controller || s->stopping) return false;
|
||||
const auto data = android_controller::PrivacyMessage(flag, block_input);
|
||||
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) {
|
||||
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
|
||||
@@ -468,7 +482,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)(
|
||||
auto message = json::parse(bytes, nullptr, false);
|
||||
if (!message.is_object() || !message.contains("type") ||
|
||||
!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)(
|
||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||
@@ -485,7 +499,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
|
||||
!message.contains("user_id") || !message["user_id"].is_string() ||
|
||||
!message.contains("devices") || !message["devices"].is_array() ||
|
||||
!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)(
|
||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||
@@ -500,7 +514,7 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nSurface)(JNIEnv* env, jclass, jlon
|
||||
if (auto* s = From(handle)) {
|
||||
s->Surface(env, surface);
|
||||
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); }
|
||||
@@ -9,11 +9,9 @@ includes("../../../deps/submodules/minirtc")
|
||||
target("crossdesk_android")
|
||||
set_kind("shared")
|
||||
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_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_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true})
|
||||
set_targetdir("../app/build/native/arm64-v8a")
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the native controller in isolation from desktop and iOS caches."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -26,7 +27,7 @@ def find_xmake():
|
||||
return xmake
|
||||
|
||||
|
||||
def main():
|
||||
def main(configure_only=False):
|
||||
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
|
||||
or Path.home() / 'Library/Android/sdk')
|
||||
ndk = sdk / 'ndk/28.2.13676358'
|
||||
@@ -45,13 +46,20 @@ def main():
|
||||
# and updates. Only installed Android packages/build products are isolated.
|
||||
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
|
||||
'-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)
|
||||
if configure_only:
|
||||
return
|
||||
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
|
||||
|
||||
|
||||
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)
|
||||
with (CACHE / 'build.lock').open('w') as lock:
|
||||
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.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;
|
||||
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("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("total").at("inbound").get<uint32_t>() == UINT32_MAX);
|
||||
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
|
||||
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);
|
||||
}
|
||||
+3
-1
@@ -80,6 +80,8 @@ crossdesk-cli 123456789 --display 1
|
||||
|
||||
## 更多文档与功能范围
|
||||
|
||||
- [MCP 服务与实时面板](../mcp/README.md):Agent 截图和结构化远程操作,不要求控制端安装桌面客户端。
|
||||
- [原生自动化协议](docs/automation-protocol.md):独立 `agent --stdio` 模式。
|
||||
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
|
||||
- [私有桥接协议](docs/bridge-protocol.md)
|
||||
- [实际验证记录](docs/validation.md)
|
||||
@@ -87,4 +89,4 @@ crossdesk-cli 123456789 --display 1
|
||||
|
||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
||||
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 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` 包提供。
|
||||
+1276
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -0,0 +1,348 @@
|
||||
#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<std::vector<Step>> BuildInputSequence(const json &a, const InputGeometry &o) {
|
||||
Fields(a, {"type", "actions", "interval_ms"});
|
||||
if (String(a, "type", 32) != "sequence" || !a.contains("actions") ||
|
||||
!a["actions"].is_array() || a["actions"].empty() || a["actions"].size() > 8)
|
||||
throw std::invalid_argument("INVALID_SEQUENCE");
|
||||
const int interval = Number(a, "interval_ms", 0, 500, 100);
|
||||
int duration = int(a["actions"].size() - 1) * interval;
|
||||
size_t text_bytes = 0;
|
||||
std::vector<std::vector<Step>> result;
|
||||
for (const auto &item : a["actions"]) {
|
||||
const auto type = String(item, "type", 32);
|
||||
if (type == "sequence" || type == "key_down" || type == "key_up" ||
|
||||
type == "mouse_down" || type == "mouse_up")
|
||||
throw std::invalid_argument("INVALID_SEQUENCE");
|
||||
auto steps = BuildInputSteps(item, o);
|
||||
duration += steps.back().delay;
|
||||
for (const auto &step : steps) text_bytes += step.text.size();
|
||||
if (duration > 8000 || text_bytes > 16384)
|
||||
throw std::invalid_argument("SEQUENCE_LIMIT");
|
||||
result.push_back(std::move(steps));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* @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<std::vector<Step>> BuildInputSequence(const json &action,
|
||||
const InputGeometry &geometry);
|
||||
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos);
|
||||
} // namespace crossdesk::cli::automation
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
|
||||
`hello` and status advertise `action_sequence_protocol: "sequence/1"`. An action may be `{type:"sequence", actions:[...], interval_ms:100}` with 1–8 ordinary actions. Nested sequences and `key_down`, `key_up`, `mouse_down`, `mouse_up` are rejected. Sequences require no pre-held inputs, at most 16 KiB of total text and at most 8000 ms of planned input duration including the 0–500 ms inter-action interval. The complete sequence is validated against the initial observation before any input is sent. Only use fixed sequences that require no intermediate visual decision.
|
||||
|
||||
Each sub-action finishes its bounded receipt wait before the next starts; errors, acknowledgement timeouts, cancellation, pause and geometry changes stop the remainder. Legacy hosts retain `remote_execution: "unsupported"`; an interval is not an application-readiness guarantee. A sequence has a 25-second overall deadline and one operation-cache entry. Results include `sequence.action_count`, `finished_actions` (finished input stages, not application success) and a zero-based `stopped_at` on failure. `timing_ms.input` measures native input processing including receipt waits and intervals.
|
||||
|
||||
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.
|
||||
|
||||
With `wait_after_frame_supported: true` in `hello`/status, `wait_frame` also accepts `after_frame_id`. Only frames strictly beyond that boundary can match. The MCP action wrapper uses the completed action's frame boundary, runs a bounded visual or UI-condition wait, and returns one final screenshot within the same tool call. A visual match is not a causal acknowledgement; use a relevant cropped observation or a native UI condition for a more specific check. Wait timeouts preserve the input result and never trigger a replay.
|
||||
|
||||
## 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.
|
||||
@@ -100,11 +100,11 @@ Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描
|
||||
| 推送 `cli-v0.2.0-rc.1` | 校验源码版本为 `0.2.0-rc.1`,构建并发布到 `next` |
|
||||
| 在对应 `cli-v*` 标签上手动运行并取消 `dry_run` | 执行真实发布;分支上不允许关闭预演 |
|
||||
|
||||
准备下一版时同步修改 `apps/cli/package.json` 的 `version` 与 `apps/cli/xmake.lua` 的 `set_version`,提交并推送代码后创建同版本标签。例如源码已更新为 `0.1.1` 时:
|
||||
准备下一版时,在仓库根目录运行 `python3 scripts/version.py set cli <版本>`,同步 CLI、MCP、DSH 插件及 MCP 的原生依赖版本,再执行 `python3 scripts/version.py check cli`。`set mcp <版本>` 的修改范围相同,详见[组件版本管理](../../../docs/VERSIONING.md)。先发布 CLI 的平台包和入口包,再发布同版本 MCP。提交并推送代码后创建同版本标签。例如源码已更新为 `0.2.1` 时:
|
||||
|
||||
```sh
|
||||
git tag cli-v0.1.1
|
||||
git push origin cli-v0.1.1
|
||||
git tag cli-v0.2.1
|
||||
git push origin cli-v0.2.1
|
||||
```
|
||||
|
||||
已经上传过 `0.1.0` 的包不能被新构建覆盖;下一次原生程序发布应使用新版本。手动预演使用 `npm pack --dry-run` 加上内容校验,不调用 `npm publish` 或交换 OIDC 凭据;可对分支运行,也允许检查已发布的版本,但不验证 npm 发布权限。标签运行始终校验版本一致性。GitHub 的手动运行入口需要该工作流先进入默认分支。
|
||||
|
||||
@@ -7,6 +7,8 @@ CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启
|
||||
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
||||
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
||||
- 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。新增依赖的许可证和对应源码也应纳入正式发行的依赖材料。
|
||||
|
||||
MiniRTC 已在提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 中合入凭据日志脱敏:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 仅按主仓库记录初始化子模块,不再应用独立补丁;更新 MiniRTC 时只需更新子模块提交。共享子模块中的脱敏同样适用于桌面程序;该修改没有改变鉴权、TLS、TURN 或媒体协议。
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ crossdesk-cli 123456789 --server your-server.example --port 9099
|
||||
|
||||
需先部署支持 `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。自建证书问题应修复证书/系统信任链。
|
||||
|
||||
@@ -183,6 +183,6 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
|
||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||
- 画面比例异常时查看 `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) 提供。
|
||||
|
||||
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
||||
+28
-10
@@ -1,4 +1,5 @@
|
||||
#include "terminal_io.h"
|
||||
#include "agent.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <clocale>
|
||||
@@ -616,8 +617,8 @@ int View(SessionOptions options, const std::string &device,
|
||||
const auto bps = st["network"]["video_bps"].get<uint64_t>();
|
||||
const auto traversal = st["network"]["traversal"].get<int>();
|
||||
bar.network =
|
||||
std::string(traversal == P2P ? "P2P "
|
||||
: traversal == Relay ? "RELAY "
|
||||
std::string(traversal == MiniRtcTraversalP2P ? "P2P "
|
||||
: traversal == MiniRtcTraversalRelay ? "RELAY "
|
||||
: "Video ") +
|
||||
(bps < 1000 ? std::to_string(bps) + " bps"
|
||||
: bps < 1000000 ? std::to_string(bps / 1000) + " kbps"
|
||||
@@ -1034,6 +1035,7 @@ const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|ki
|
||||
crossdesk-cli view <9-digit-device-id> [options] (compatibility alias)
|
||||
crossdesk-cli doctor [--json]
|
||||
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.
|
||||
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
|
||||
@@ -1053,6 +1055,7 @@ Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers re
|
||||
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.
|
||||
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.
|
||||
)";
|
||||
} // namespace
|
||||
@@ -1087,12 +1090,19 @@ int main(int argc, char **argv) {
|
||||
{"kitty", "probe_on_view"},
|
||||
{"sixel", "probe_on_view"},
|
||||
{"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";
|
||||
return 0;
|
||||
}
|
||||
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)
|
||||
throw std::invalid_argument(
|
||||
"expected a 9-digit device ID or a command; use --help");
|
||||
@@ -1105,6 +1115,8 @@ int main(int argc, char **argv) {
|
||||
double zoom = 1, cell_aspect = 0;
|
||||
bool control = false, stdio = false, native = false, fps_explicit = false;
|
||||
int secret_fd = 3;
|
||||
bool video_websocket = false;
|
||||
if (command == "agent") options.fps = 30;
|
||||
if (command == "view") {
|
||||
if (i >= argc)
|
||||
throw std::invalid_argument("device ID required");
|
||||
@@ -1124,8 +1136,10 @@ int main(int argc, char **argv) {
|
||||
std::string a = argv[i];
|
||||
if (a == "--control" && command == "view")
|
||||
control = true;
|
||||
else if (a == "--stdio" && command == "bridge")
|
||||
else if (a == "--stdio" && command != "view")
|
||||
stdio = true;
|
||||
else if (a == "--video-ws" && command == "agent")
|
||||
video_websocket = true;
|
||||
else {
|
||||
if (i + 1 >= argc)
|
||||
throw std::invalid_argument("option value missing");
|
||||
@@ -1134,6 +1148,8 @@ int main(int argc, char **argv) {
|
||||
options.host = value;
|
||||
else if (a == "--port")
|
||||
options.port = number(value, 1, 65535);
|
||||
else if (a == "--log-dir" && command == "agent")
|
||||
options.log_directory = value;
|
||||
else if (a == "--fps" && command == "view") {
|
||||
options.fps = number(value, 1, 30);
|
||||
fps_explicit = true;
|
||||
@@ -1158,7 +1174,7 @@ int main(int argc, char **argv) {
|
||||
} else if (a == "--zoom" && command == "view") {
|
||||
native = value == "native";
|
||||
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);
|
||||
else
|
||||
throw std::invalid_argument("unsupported option");
|
||||
@@ -1167,14 +1183,14 @@ int main(int argc, char **argv) {
|
||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
||||
renderer != "sixel")
|
||||
throw std::invalid_argument("unknown renderer");
|
||||
if (command == "bridge" && !stdio)
|
||||
throw std::invalid_argument("bridge requires --stdio");
|
||||
if (command != "view" && !stdio)
|
||||
throw std::invalid_argument("automation and bridge require --stdio");
|
||||
Descriptor output(Duplicate(1));
|
||||
if (output.value < 0)
|
||||
throw std::runtime_error("cannot preserve output channel");
|
||||
// All library stdout writes go to diagnostics. Only Output uses the saved
|
||||
// fd.
|
||||
options.log_directory = Logs();
|
||||
if (options.log_directory.empty()) options.log_directory = Logs();
|
||||
if (command == "view") {
|
||||
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
||||
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
||||
@@ -1193,7 +1209,9 @@ int main(int argc, char **argv) {
|
||||
return command == "view" ? View(options, device, renderer, control,
|
||||
output.value, detail, zoom, native,
|
||||
cell_aspect, fps_explicit)
|
||||
: Bridge(output.value, options, secret_fd);
|
||||
: command == "agent"
|
||||
? Agent(output.value, options, secret_fd, video_websocket)
|
||||
: Bridge(output.value, options, secret_fd);
|
||||
} catch (const std::exception &e) {
|
||||
if (command == "view") {
|
||||
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
|
||||
|
||||
+13
-11
@@ -36,17 +36,19 @@ function main() {
|
||||
return;
|
||||
}
|
||||
const stdio = ['inherit', 'inherit', 'inherit'];
|
||||
if (args[0] === 'bridge') {
|
||||
const index = args.lastIndexOf('--password-fd');
|
||||
const fd = index === -1 ? 3 : Number(args[index + 1]);
|
||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||
while (stdio.length <= fd) stdio.push('ignore');
|
||||
stdio[fd] = fd;
|
||||
}
|
||||
} catch { /* The native bridge reports a missing credential descriptor. */ }
|
||||
if (args[0] === 'bridge' || args[0] === 'agent') {
|
||||
for (const [option, fallback] of [['--password-fd', 3]]) {
|
||||
const index = args.lastIndexOf(option);
|
||||
const fd = index === -1 ? fallback : Number(args[index + 1]);
|
||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||
while (stdio.length <= fd) stdio.push('ignore');
|
||||
stdio[fd] = fd;
|
||||
}
|
||||
} catch { /* Native code reports unavailable descriptors. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
const child = spawn(binary, args, { stdio, shell: false });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chunkdash/crossdesk-cli",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "CrossDesk remote desktop controller for the terminal",
|
||||
"license": "GPL-3.0-only",
|
||||
|
||||
@@ -84,6 +84,8 @@ function inspect(binary, target) {
|
||||
}
|
||||
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.automation_protocol !== 'crossdesk-automation/1')
|
||||
throw new Error('Native binary does not report the expected automation protocol.');
|
||||
return info;
|
||||
}
|
||||
function pack(directory, output) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
-5
@@ -4,18 +4,20 @@ local terminal_dir = path.join(source_dir, "libs/terminal")
|
||||
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
|
||||
|
||||
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
|
||||
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
||||
end
|
||||
|
||||
target("crossdesk_controller")
|
||||
set_kind("static")
|
||||
add_deps("minirtc", "crossdesk_wire")
|
||||
add_packages("spdlog")
|
||||
add_deps("minirtc", "crossdesk_wire", "crossdesk_file_browser")
|
||||
add_packages("spdlog", "libpng", "libyuv")
|
||||
add_includedirs(path.join(controller_dir, "include"),
|
||||
path.join(minirtc_dir, "src/api"), {public = true})
|
||||
add_includedirs(path.join(minirtc_dir, "src/log"))
|
||||
add_files(path.join(controller_dir, "src/*.cpp"))
|
||||
remove_files(path.join(controller_dir, "src/file_browser.cpp"))
|
||||
target_end()
|
||||
|
||||
target("crossdesk_terminal")
|
||||
@@ -32,12 +34,14 @@ target_end()
|
||||
|
||||
target("crossdesk-cli")
|
||||
set_kind("binary")
|
||||
set_version("0.1.6")
|
||||
set_version("0.2.0")
|
||||
add_deps("crossdesk_terminal")
|
||||
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")
|
||||
if is_plat("windows") then
|
||||
add_syslinks("advapi32", "user32")
|
||||
add_syslinks("advapi32", "user32", "ws2_32")
|
||||
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
||||
elseif is_plat("linux") then
|
||||
add_syslinks("rt")
|
||||
|
||||
@@ -175,6 +175,23 @@ Type=Application
|
||||
Categories=Utility;
|
||||
EOF
|
||||
|
||||
# Give shells a desktop entry for the remote window's separate app id. Without
|
||||
# it, some shells fall back to the process's main launcher and merge the groups;
|
||||
# Wayland also uses this entry to resolve the icon. Hide it from app menus: a
|
||||
# fresh launch can open CrossDesk, but cannot recreate a live remote session.
|
||||
cat > "$DESKTOP_DIR/$PKG_NAME-remote-control.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=$APP_NAME Remote Control
|
||||
Comment=Active CrossDesk remote control sessions
|
||||
Exec=/usr/bin/$PKG_NAME
|
||||
Icon=$PKG_NAME
|
||||
StartupWMClass=$PKG_NAME-remote-control
|
||||
Terminal=false
|
||||
Type=Application
|
||||
NoDisplay=true
|
||||
EOF
|
||||
|
||||
# Stage and strip the application once, then package it with two dependency sets.
|
||||
# Keep the executable, launcher and configuration paths identical when switching.
|
||||
for package_name in "$PKG_NAME" "$VIRTUAL_PACKAGE_NAME"; do
|
||||
|
||||
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
|
||||
VOL_NAME="Install ${APP_NAME_UPPER}"
|
||||
|
||||
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 "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
|
||||
|
||||
echo ".app created successfully."
|
||||
|
||||
mkdir -p build_pkg_scripts
|
||||
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
|
||||
chmod +x build_pkg_scripts/postinstall
|
||||
PKG_SCRIPT_ARGS=()
|
||||
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
|
||||
mkdir -p build_pkg_scripts
|
||||
cp "${SCRIPT_DIR}/tcc_migration_preinstall.sh" build_pkg_scripts/preinstall
|
||||
chmod +x build_pkg_scripts/preinstall
|
||||
PKG_SCRIPT_ARGS=(--scripts build_pkg_scripts)
|
||||
fi
|
||||
|
||||
mkdir -p build_pkg_resources
|
||||
cat > build_pkg_resources/welcome.html <<EOF
|
||||
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
|
||||
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
||||
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
||||
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
||||
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p>
|
||||
<p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
|
||||
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,7 +181,7 @@ pkgbuild \
|
||||
--version "${APP_VERSION}" \
|
||||
--install-location "/Applications" \
|
||||
--component "${APP_BUNDLE}" \
|
||||
--scripts build_pkg_scripts \
|
||||
${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
|
||||
build_pkg_temp/${APP_NAME}-component.pkg
|
||||
|
||||
cat > build_pkg_temp/Distribution <<EOF
|
||||
|
||||
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
|
||||
VOL_NAME="Install ${APP_NAME_UPPER}"
|
||||
|
||||
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 "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
|
||||
|
||||
echo ".app created successfully."
|
||||
|
||||
mkdir -p build_pkg_scripts
|
||||
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
|
||||
chmod +x build_pkg_scripts/postinstall
|
||||
PKG_SCRIPT_ARGS=()
|
||||
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
|
||||
mkdir -p build_pkg_scripts
|
||||
cp "${SCRIPT_DIR}/tcc_migration_preinstall.sh" build_pkg_scripts/preinstall
|
||||
chmod +x build_pkg_scripts/preinstall
|
||||
PKG_SCRIPT_ARGS=(--scripts build_pkg_scripts)
|
||||
fi
|
||||
|
||||
mkdir -p build_pkg_resources
|
||||
cat > build_pkg_resources/welcome.html <<EOF
|
||||
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
|
||||
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
||||
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
||||
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
||||
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p>
|
||||
<p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
|
||||
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,7 +181,7 @@ pkgbuild \
|
||||
--version "${APP_VERSION}" \
|
||||
--install-location "/Applications" \
|
||||
--component "${APP_BUNDLE}" \
|
||||
--scripts build_pkg_scripts \
|
||||
${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
|
||||
build_pkg_temp/${APP_NAME}-component.pkg
|
||||
|
||||
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
|
||||
@@ -1,76 +0,0 @@
|
||||
#include "daemon.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include "platform/daemon_backend.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRestartDelayMs = 1000;
|
||||
|
||||
} // namespace
|
||||
|
||||
Daemon::Daemon(const std::string& name) : name_(name), running_(false) {}
|
||||
|
||||
void Daemon::stop() {
|
||||
running_.store(false);
|
||||
crossdesk::platform::RequestDaemonStop();
|
||||
}
|
||||
|
||||
bool Daemon::isRunning() const {
|
||||
return running_.load() && !crossdesk::platform::DaemonStopRequested();
|
||||
}
|
||||
|
||||
bool Daemon::start(MainLoopFunc loop) {
|
||||
crossdesk::platform::ResetDaemonStop();
|
||||
if (!crossdesk::platform::PrepareDaemon()) return false;
|
||||
running_.store(true);
|
||||
return runWithRestart(std::move(loop));
|
||||
}
|
||||
|
||||
bool Daemon::runWithRestart(MainLoopFunc loop) {
|
||||
int restart_count = 0;
|
||||
const std::string executable_path =
|
||||
crossdesk::platform::GetDaemonExecutablePath();
|
||||
if (executable_path.empty()) {
|
||||
std::cerr
|
||||
<< "Failed to get executable path, falling back to direct execution"
|
||||
<< std::endl;
|
||||
while (isRunning()) {
|
||||
try {
|
||||
loop();
|
||||
break;
|
||||
} catch (...) {
|
||||
++restart_count;
|
||||
std::cerr << "Exception caught, restarting... (attempt "
|
||||
<< restart_count << ")" << std::endl;
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds(kRestartDelayMs));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
while (isRunning()) {
|
||||
const crossdesk::platform::DaemonChildResult result =
|
||||
crossdesk::platform::RunDaemonChild(
|
||||
executable_path, [this] { return isRunning(); });
|
||||
if (!isRunning() || result.normal_exit) break;
|
||||
|
||||
++restart_count;
|
||||
if (!result.started) {
|
||||
std::cerr << "Failed to create child process";
|
||||
} else if (result.terminated_by_signal) {
|
||||
std::cerr << "Child process crashed with signal " << result.exit_code;
|
||||
} else {
|
||||
std::cerr << "Child process exited with code " << result.exit_code;
|
||||
}
|
||||
std::cerr << ", restarting... (attempt " << restart_count << ")"
|
||||
<< std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kRestartDelayMs));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2025-11-19
|
||||
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DAEMON_H_
|
||||
#define _DAEMON_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
class Daemon {
|
||||
public:
|
||||
using MainLoopFunc = std::function<void()>;
|
||||
|
||||
Daemon(const std::string& name);
|
||||
|
||||
bool start(MainLoopFunc loop);
|
||||
|
||||
void stop();
|
||||
|
||||
bool isRunning() const;
|
||||
|
||||
private:
|
||||
std::string name_;
|
||||
bool runWithRestart(MainLoopFunc loop);
|
||||
|
||||
std::atomic<bool> running_;
|
||||
};
|
||||
|
||||
#endif
|
||||
+93
-140
@@ -17,15 +17,18 @@
|
||||
#include <cstdio>
|
||||
|
||||
#include "platform/windows/autostart_task.h"
|
||||
#include "platform/windows/executable_path.h"
|
||||
#include "platform/windows/gui/slint_backend.h"
|
||||
#include "platform/windows/runtime_daemon.h"
|
||||
#include "platform/windows/service/autostart_control.h"
|
||||
#include "service_host.h"
|
||||
#include "service_lifetime.h"
|
||||
#include "shutdown_order.h"
|
||||
#include "unattended_config.h"
|
||||
#include "shared_identity.h"
|
||||
#endif
|
||||
|
||||
#include "config_center.h"
|
||||
#include "daemon.h"
|
||||
#include "path_manager.h"
|
||||
#include "platform/instance_activation.h"
|
||||
#include "platform/single_instance.h"
|
||||
@@ -37,9 +40,15 @@
|
||||
|
||||
#ifdef __linux__
|
||||
#include "SimpleIni.h"
|
||||
#include "platform/linux/autostart.h"
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#include "platform/linux/headless/headless_session.h"
|
||||
#include "platform/linux/privacy/privacy_guard.h"
|
||||
#include "platform/linux/runtime_daemon.h"
|
||||
#endif
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include "platform/macos/runtime_daemon.h"
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -70,6 +79,7 @@ void PrintServiceCliUsage() {
|
||||
std::cout
|
||||
<< "CrossDesk service management commands\n"
|
||||
<< " --service-install Install the sibling crossdesk_service.exe\n"
|
||||
<< " --service-repair Update the service path and restart it\n"
|
||||
<< " --service-uninstall Remove the installed Windows service\n"
|
||||
<< " --service-start Start the Windows service\n"
|
||||
<< " --service-stop Stop the Windows service\n"
|
||||
@@ -79,30 +89,15 @@ void PrintServiceCliUsage() {
|
||||
<< " --service-status Query service runtime status\n"
|
||||
<< " --service-help Show this help\n";
|
||||
std::cout
|
||||
<< " --unattended-enable Enable a separate machine host before login\n"
|
||||
<< " --unattended-enable Enable the shared device identity before login\n"
|
||||
<< " --unattended-disable Stop the machine host and disable boot "
|
||||
"startup\n"
|
||||
<< " --unattended-status Show machine host status and connection "
|
||||
"credentials\n";
|
||||
}
|
||||
|
||||
std::wstring GetCurrentExecutablePathW() {
|
||||
wchar_t path[MAX_PATH] = {0};
|
||||
DWORD length = GetModuleFileNameW(nullptr, path, MAX_PATH);
|
||||
if (length == 0 || length >= MAX_PATH) {
|
||||
return L"";
|
||||
}
|
||||
return std::wstring(path, length);
|
||||
}
|
||||
|
||||
std::filesystem::path GetSiblingServiceExecutablePath() {
|
||||
std::wstring current_executable = GetCurrentExecutablePathW();
|
||||
if (current_executable.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return std::filesystem::path(current_executable).parent_path() /
|
||||
L"crossdesk_service.exe";
|
||||
return crossdesk::platform::SiblingExecutablePath(L"crossdesk_service.exe");
|
||||
}
|
||||
|
||||
bool IsServiceCliCommand(const char* arg) {
|
||||
@@ -111,6 +106,7 @@ bool IsServiceCliCommand(const char* arg) {
|
||||
}
|
||||
|
||||
return std::strcmp(arg, "--service-install") == 0 ||
|
||||
std::strcmp(arg, "--service-repair") == 0 ||
|
||||
std::strcmp(arg, "--unattended-enable") == 0 ||
|
||||
std::strcmp(arg, "--unattended-disable") == 0 ||
|
||||
std::strcmp(arg, "--unattended-status") == 0 ||
|
||||
@@ -133,6 +129,11 @@ void TryStartManagedWindowsService() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (crossdesk::IsUnattendedEnabled() && !crossdesk::IsSharedIdentityServiceConfigured()) {
|
||||
if (!crossdesk::RepairCrossDeskService(service_path.wstring()))
|
||||
crossdesk::get_logger()->warn("Could not prepare the service for shared-identity migration");
|
||||
return;
|
||||
}
|
||||
crossdesk::StartCrossDeskService();
|
||||
}
|
||||
|
||||
@@ -153,87 +154,19 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
std::cout << crossdesk::ReadUnattendedStatus() << '\n';
|
||||
return 0;
|
||||
}
|
||||
const auto service = GetSiblingServiceExecutablePath();
|
||||
if (service.empty() || !std::filesystem::exists(service)) {
|
||||
std::cerr << "Install CrossDesk with its Windows service first.\n";
|
||||
const bool enabled = command == "--unattended-enable";
|
||||
std::string error;
|
||||
if (!crossdesk::SetServiceAutostart(enabled, error)) {
|
||||
std::cerr << error << '\n';
|
||||
return 1;
|
||||
}
|
||||
if (command == "--unattended-disable") {
|
||||
// Disarm boot startup even if stopping the current process fails.
|
||||
const bool saved = crossdesk::SetUnattendedEnabled(false);
|
||||
const bool configured =
|
||||
saved && crossdesk::InstallCrossDeskService(service.wstring());
|
||||
const bool stopped = crossdesk::StopCrossDeskService(20000);
|
||||
std::cout << (configured && stopped
|
||||
? "Unattended host disabled.\n"
|
||||
: "Failed to disable unattended host.\n");
|
||||
return configured && stopped ? 0 : 1;
|
||||
}
|
||||
#if defined(CROSSDESK_DEBUG) || CROSSDESK_PORTABLE
|
||||
std::cerr << "Unattended mode requires the installed release build.\n";
|
||||
return 1;
|
||||
#else
|
||||
const bool was_enabled = crossdesk::IsUnattendedEnabled();
|
||||
if (!crossdesk::PrepareUnattendedDirectory(true)) {
|
||||
std::cerr
|
||||
<< "Cannot create a private machine profile. Existing permissive "
|
||||
"directories and links are rejected.\n";
|
||||
return 1;
|
||||
}
|
||||
if (crossdesk::IsCrossDeskServiceInstalled() &&
|
||||
!crossdesk::StopCrossDeskService(20000)) {
|
||||
std::cerr << "Could not stop the service to configure unattended mode.\n";
|
||||
return 1;
|
||||
}
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
crossdesk::ConfigCenter source(
|
||||
(paths.GetCachePath() / "config.ini").string());
|
||||
const auto machine_config = crossdesk::UnattendedDataPath() / "config.ini";
|
||||
const auto staged_config =
|
||||
crossdesk::UnattendedDataPath() / "config.pending.ini";
|
||||
// Import only server/media preferences. Never share the GUI identity or
|
||||
// consume its file-transfer paths in the SYSTEM process.
|
||||
const auto host = source.IsSelfHosted() ? source.GetSignalServerHost()
|
||||
: source.GetDefaultServerHost();
|
||||
const int port = source.IsSelfHosted()
|
||||
? source.GetSignalServerPort()
|
||||
: source.GetDefaultSignalServerPort();
|
||||
bool configured = std::filesystem::exists(machine_config);
|
||||
if (!configured) {
|
||||
crossdesk::ConfigCenter destination(staged_config.string());
|
||||
configured =
|
||||
destination.SetServerHost(host) == 0 &&
|
||||
destination.SetServerPort(port) == 0 &&
|
||||
destination.SetSelfHosted(source.IsSelfHosted()) == 0 &&
|
||||
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
|
||||
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
|
||||
0 &&
|
||||
destination.SetScreenCaptureMethod(
|
||||
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
|
||||
destination.SetPrivacyScreen(false) == 0 &&
|
||||
destination.SetSaveRemotePreviews(false) == 0;
|
||||
}
|
||||
if (configured && !std::filesystem::exists(machine_config)) {
|
||||
configured = MoveFileExW(staged_config.c_str(), machine_config.c_str(),
|
||||
MOVEFILE_WRITE_THROUGH) != FALSE;
|
||||
}
|
||||
if (!configured || !crossdesk::SetUnattendedEnabled(true) ||
|
||||
!crossdesk::InstallCrossDeskService(service.wstring()) ||
|
||||
!crossdesk::StartCrossDeskService()) {
|
||||
crossdesk::SetUnattendedEnabled(was_enabled);
|
||||
crossdesk::InstallCrossDeskService(service.wstring());
|
||||
if (was_enabled) crossdesk::StartCrossDeskService();
|
||||
std::cerr << "Failed to enable unattended host.\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Unattended host enabled for boot startup.\n"
|
||||
"After it connects, use --unattended-status to obtain its "
|
||||
"separate device ID and password.\n";
|
||||
std::cout << (enabled
|
||||
? "Unattended host enabled for boot startup with the GUI's device ID and password.\n"
|
||||
: "Unattended host disabled.\n");
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (command == "--service-install") {
|
||||
if (command == "--service-install" || command == "--service-repair") {
|
||||
std::filesystem::path service_path = GetSiblingServiceExecutablePath();
|
||||
if (service_path.empty()) {
|
||||
std::cerr << "Failed to locate crossdesk_service.exe" << std::endl;
|
||||
@@ -245,7 +178,9 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool success = crossdesk::InstallCrossDeskService(service_path.wstring());
|
||||
bool success = command == "--service-repair"
|
||||
? crossdesk::RepairCrossDeskService(service_path.wstring())
|
||||
: crossdesk::InstallCrossDeskService(service_path.wstring());
|
||||
std::cout << (success ? "install ok" : "install failed") << std::endl;
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
@@ -329,9 +264,7 @@ static int ExitForExistingInstance(
|
||||
return result == crossdesk::platform::InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
}
|
||||
|
||||
static int RunApplication(
|
||||
int argc, char* argv[],
|
||||
crossdesk::platform::SingleInstanceGuard& client_instance) {
|
||||
static int RunApplication(int argc, char* argv[]) {
|
||||
// The instance guards are held before any configuration or logger is opened.
|
||||
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
|
||||
crossdesk::InitLogger(path_manager->GetLogPath().string());
|
||||
@@ -339,19 +272,19 @@ static int RunApplication(
|
||||
#ifdef _WIN32
|
||||
crossdesk::get_logger()->info(
|
||||
"CrossDesk executable: {}",
|
||||
std::filesystem::path(GetCurrentExecutablePathW()).u8string());
|
||||
std::filesystem::path(crossdesk::platform::CurrentExecutablePath()).u8string());
|
||||
#endif
|
||||
|
||||
if (IsDaemonChild(argc, argv)) {
|
||||
// child process: run render directly
|
||||
// Compatibility with a supervisor from an older, still-running build.
|
||||
crossdesk::Render render;
|
||||
return render.Run();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
if (!crossdesk::platform::MigrateLegacyAutostart("CrossDesk")) {
|
||||
if (!crossdesk::CleanupLegacyServiceAutostart()) {
|
||||
crossdesk::get_logger()->warn(
|
||||
"Could not migrate the legacy Windows autostart entry");
|
||||
"Could not remove legacy Windows logon startup");
|
||||
}
|
||||
TryStartManagedWindowsService();
|
||||
#endif
|
||||
@@ -361,6 +294,13 @@ static int RunApplication(
|
||||
std::string cache_path = path_manager->GetCachePath().string();
|
||||
crossdesk::ConfigCenter config_center(cache_path + "/config.ini");
|
||||
enable_daemon = config_center.IsEnableDaemon();
|
||||
#ifdef __linux__
|
||||
if (config_center.IsEnableAutostart() &&
|
||||
!crossdesk::platform::MigrateLegacyAutostart("CrossDesk")) {
|
||||
crossdesk::get_logger()->warn(
|
||||
"Could not migrate the legacy Linux autostart entry");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
@@ -369,45 +309,46 @@ static int RunApplication(
|
||||
// session instead.
|
||||
if (std::getenv("CROSSDESK_HEADLESS_ACTIVE") ||
|
||||
crossdesk::HeadlessConsole::Instance().active()) enable_daemon = false;
|
||||
|
||||
auto& runtime_daemon = crossdesk::platform::LinuxRuntimeDaemon::Instance();
|
||||
runtime_daemon.Initialize(
|
||||
argc, argv, !std::getenv("CROSSDESK_HEADLESS_ACTIVE") &&
|
||||
!crossdesk::HeadlessConsole::Instance().active());
|
||||
#elif defined(_WIN32)
|
||||
auto& runtime_daemon = crossdesk::platform::WindowsRuntimeDaemon::Instance();
|
||||
runtime_daemon.Initialize();
|
||||
#elif defined(__APPLE__)
|
||||
auto& runtime_daemon = crossdesk::platform::MacRuntimeDaemon::Instance();
|
||||
runtime_daemon.Initialize(argc, argv);
|
||||
#endif
|
||||
|
||||
if (enable_daemon) {
|
||||
// The launcher lease stays in main for the supervisor's entire lifetime.
|
||||
// Each --child must acquire its own client lease, so it is neither blocked
|
||||
// by its supervisor nor able to bypass the single-client restriction.
|
||||
client_instance.Release();
|
||||
// start daemon with restart monitoring
|
||||
Daemon daemon("CrossDesk");
|
||||
|
||||
// define main loop function: run render and stop daemon on normal exit
|
||||
Daemon::MainLoopFunc main_loop = [&daemon, &client_instance]() {
|
||||
// Executable discovery can fail and make the supervisor run the GUI in
|
||||
// process. This fallback must retain the same client ownership rule.
|
||||
if (AcquireInstance(client_instance,
|
||||
crossdesk::platform::InstanceRole::kClient) !=
|
||||
crossdesk::platform::InstanceResult::kAcquired) {
|
||||
daemon.stop();
|
||||
return;
|
||||
}
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
daemon.stop();
|
||||
};
|
||||
|
||||
// start daemon and return result
|
||||
bool success = daemon.start(main_loop);
|
||||
return success ? 0 : 1;
|
||||
#if defined(__linux__) || defined(_WIN32) || defined(__APPLE__)
|
||||
std::string daemon_error;
|
||||
if (!runtime_daemon.SetEnabled(enable_daemon, daemon_error)) {
|
||||
crossdesk::get_logger()->warn("Could not start daemon: {}", daemon_error);
|
||||
}
|
||||
|
||||
// run without daemon: direct execution
|
||||
#endif
|
||||
crossdesk::Render render;
|
||||
return render.Run();
|
||||
const int result = render.Run();
|
||||
#if defined(__linux__) || defined(_WIN32) || defined(__APPLE__)
|
||||
runtime_daemon.Shutdown(result != 0);
|
||||
#endif
|
||||
return result;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef __APPLE__
|
||||
if (argc >= 2 &&
|
||||
std::strcmp(argv[1], crossdesk::platform::kMacDaemonArgument) == 0) {
|
||||
return crossdesk::platform::RunMacRuntimeDaemon(argc, argv);
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
// Apply in every process, including the daemon and --child. Windows does
|
||||
// not inherit shutdown priority from a parent process.
|
||||
if (argc >= 2 &&
|
||||
std::strcmp(argv[1], crossdesk::platform::kWindowsDaemonArgument) == 0) {
|
||||
return crossdesk::platform::RunWindowsRuntimeDaemon();
|
||||
}
|
||||
// Apply in every GUI process, including legacy --child launches. The watcher
|
||||
// keeps the default shutdown priority so it ends before this remote host.
|
||||
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
|
||||
crossdesk::WindowsShutdownRole::host);
|
||||
if (shutdown_error != ERROR_SUCCESS) {
|
||||
@@ -417,6 +358,10 @@ int main(int argc, char* argv[]) {
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or display initialization.
|
||||
if (argc >= 2 &&
|
||||
std::strcmp(argv[1], crossdesk::platform::kLinuxDaemonArgument) == 0) {
|
||||
return crossdesk::platform::RunLinuxRuntimeDaemon(argc, argv);
|
||||
}
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
|
||||
return crossdesk::RunLinuxPrivacyGuard();
|
||||
@@ -437,7 +382,8 @@ int main(int argc, char* argv[]) {
|
||||
// Internal service entry point: never elevate an ordinary invocation into
|
||||
// a SYSTEM host, and never open the GUI or the per-user daemon here.
|
||||
if (argc != 4 || !crossdesk::IsLocalSystemProcess() ||
|
||||
!crossdesk::IsUnattendedEnabled())
|
||||
(!crossdesk::IsUnattendedEnabled() && !crossdesk::SharedHostIdentity::GuiPresent()) ||
|
||||
!crossdesk::PrepareUnattendedDirectory(false))
|
||||
return 1;
|
||||
const std::string name(argv[2]);
|
||||
if (name.rfind("Global\\CrossDeskUnattendedStop-", 0) != 0) return 1;
|
||||
@@ -451,6 +397,9 @@ int main(int argc, char* argv[]) {
|
||||
CloseHandle(stop);
|
||||
return 1;
|
||||
}
|
||||
// Runtime members can log during construction. Select the protected host
|
||||
// log before constructing Render, not after its logger has already opened.
|
||||
crossdesk::InitLogger((crossdesk::UnattendedDataPath() / L"logs").string());
|
||||
crossdesk::Render render;
|
||||
const int result = render.RunUnattended(stop);
|
||||
CloseHandle(stop);
|
||||
@@ -464,7 +413,7 @@ int main(int argc, char* argv[]) {
|
||||
"crossdesk-autostart-cli");
|
||||
const bool success =
|
||||
std::strcmp(argv[1], "--autostart-migrate") == 0
|
||||
? crossdesk::platform::MigrateLegacyAutostart("CrossDesk")
|
||||
? crossdesk::CleanupLegacyServiceAutostart()
|
||||
: crossdesk::platform::RemoveInstalledAutostart("CrossDesk");
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
@@ -475,8 +424,12 @@ int main(int argc, char* argv[]) {
|
||||
if (argc > 1 && IsServiceCliCommand(argv[1])) {
|
||||
EnsureConsoleForCli();
|
||||
crossdesk::platform::SingleInstanceGuard cli_instance;
|
||||
if (AcquireInstance(cli_instance, crossdesk::platform::InstanceRole::kServiceCli) !=
|
||||
crossdesk::platform::InstanceResult::kAcquired) return 1;
|
||||
// Unattended changes acquire this lease in the shared GUI/CLI entry point.
|
||||
const bool unattended_change = std::strcmp(argv[1], "--unattended-enable") == 0 ||
|
||||
std::strcmp(argv[1], "--unattended-disable") == 0;
|
||||
if (!unattended_change &&
|
||||
AcquireInstance(cli_instance, crossdesk::platform::InstanceRole::kServiceCli) !=
|
||||
crossdesk::platform::InstanceResult::kAcquired) return 1;
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
crossdesk::InitLogger(paths.GetLogPath().string(), "crossdesk-service-cli");
|
||||
return HandleServiceCliCommand(argv[1]);
|
||||
@@ -509,7 +462,7 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
#ifdef __linux__
|
||||
const int exit_code = crossdesk::RunWithLinuxDisplay(
|
||||
argc, argv, [&] { return RunApplication(argc, argv, client_instance); }, [&] {
|
||||
argc, argv, [&] { return RunApplication(argc, argv); }, [&] {
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
if (console.Enable(paths.GetLogPath(), paths.GetCachePath() / "config.ini")) return true;
|
||||
std::cerr << "无法初始化日志或控制台,启动失败。\n";
|
||||
@@ -522,6 +475,6 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
return exit_code;
|
||||
#else
|
||||
return RunApplication(argc, argv, client_instance);
|
||||
return RunApplication(argc, argv);
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-08
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CREDENTIAL_CACHE_H_
|
||||
#define _CREDENTIAL_CACHE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk::credential_cache {
|
||||
inline constexpr uint32_t kMagic = 0x33444358;
|
||||
inline constexpr uint32_t kVersion = 3;
|
||||
|
||||
struct CacheV1 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
unsigned char iv[16];
|
||||
};
|
||||
|
||||
struct CacheV2 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
unsigned char iv[16];
|
||||
char self_hosted_id[17];
|
||||
};
|
||||
|
||||
struct CacheV3 {
|
||||
uint32_t magic;
|
||||
uint32_t version;
|
||||
CacheV2 base;
|
||||
char pending_identity[17];
|
||||
char pending_server_host[256];
|
||||
int pending_server_port;
|
||||
bool pending_self_hosted;
|
||||
char pending_request_id[64];
|
||||
uint32_t checksum;
|
||||
};
|
||||
|
||||
|
||||
inline uint32_t Checksum(const void* data, size_t size) {
|
||||
const auto* bytes = static_cast<const unsigned char*>(data);
|
||||
uint32_t hash = 2166136261u;
|
||||
for (size_t i = 0; i < size; ++i) { hash ^= bytes[i]; hash *= 16777619u; }
|
||||
return hash;
|
||||
}
|
||||
inline bool Valid(const CacheV3& cache) {
|
||||
return cache.magic == kMagic && cache.version == kVersion &&
|
||||
cache.checksum == Checksum(&cache, offsetof(CacheV3, checksum));
|
||||
}
|
||||
} // namespace crossdesk::credential_cache
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -230,7 +230,10 @@ std::string Read(const std::filesystem::path& path) {
|
||||
}
|
||||
|
||||
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
|
||||
std::filesystem::create_directories(directory.parent_path());
|
||||
const auto parent = directory.parent_path();
|
||||
if (!parent.empty()) {
|
||||
std::filesystem::create_directories(parent);
|
||||
}
|
||||
#ifdef _WIN32
|
||||
CreatePrivateDirectory(directory, protect_existing);
|
||||
#else
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace crossdesk {
|
||||
// Called on the application thread. True means the OS accepted the request,
|
||||
// not that the asynchronous transition to the lock screen has finished.
|
||||
bool LockScreen();
|
||||
enum class ScreenLockState { unknown, unlocked, locked };
|
||||
ScreenLockState QueryScreenLock();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -10,7 +10,21 @@
|
||||
#include "SimpleIni.h"
|
||||
#include "autostart.h"
|
||||
#include "rd_log.h"
|
||||
#include "signal_server_address.h"
|
||||
#include <minirtc.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows/runtime_daemon.h"
|
||||
#include "platform/windows/service/autostart_control.h"
|
||||
#endif
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include "platform/macos/runtime_daemon.h"
|
||||
#endif
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
#include "platform/linux/autostart.h"
|
||||
#include "platform/linux/runtime_daemon.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -83,13 +97,8 @@ int ConfigCenter::Load() {
|
||||
? CONNECTION_MODE::DIRECT_IP
|
||||
: CONNECTION_MODE::DEVICE_ID;
|
||||
|
||||
const long screen_capture_method_value = ini_->GetLongValue(
|
||||
section_, "screen_capture_method",
|
||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||
screen_capture_method_ = IsValidScreenCaptureMethod(screen_capture_method_value)
|
||||
? static_cast<ScreenCaptureMethod>(
|
||||
screen_capture_method_value)
|
||||
: ScreenCaptureMethod::Auto;
|
||||
// Capture always follows DXGI -> WGC -> GDI; discard old forced choices.
|
||||
persist_config_migration |= ini_->Delete(section_, "screen_capture_method");
|
||||
|
||||
video_encode_format_ = static_cast<VIDEO_ENCODE_FORMAT>(
|
||||
ini_->GetLongValue(section_, "video_encode_format",
|
||||
@@ -154,6 +163,8 @@ int ConfigCenter::Load() {
|
||||
ini_->GetBoolValue(section_, "trusted_devices_only", true));
|
||||
save_remote_previews_.store(
|
||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||
pause_video_when_minimized_.store(
|
||||
ini_->GetBoolValue(section_, "pause_video_when_minimized", true));
|
||||
portable_service_prompt_suppressed_ =
|
||||
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
@@ -176,16 +187,20 @@ int ConfigCenter::Load() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::CommitIni() {
|
||||
int ConfigCenter::CommitIni(bool warn_only) {
|
||||
// Write beside the destination and rename only after a complete save. A
|
||||
// failed write must not truncate the last usable configuration.
|
||||
const auto log_failure = [warn_only](const std::string& message) {
|
||||
if (warn_only) LOG_WARN("{}", message);
|
||||
else LOG_ERROR("{}", message);
|
||||
};
|
||||
static std::atomic<unsigned long> sequence{0};
|
||||
const std::string temporary = config_path_ + ".tmp-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
|
||||
"-" + std::to_string(sequence++);
|
||||
std::error_code error;
|
||||
if (ini_->SaveFile(temporary.c_str()) < 0) {
|
||||
LOG_ERROR("Failed to write configuration: {}", config_path_);
|
||||
log_failure("Failed to write configuration: " + config_path_);
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
@@ -193,14 +208,14 @@ int ConfigCenter::CommitIni() {
|
||||
if (!error && std::filesystem::is_regular_file(previous)) {
|
||||
std::filesystem::permissions(temporary, previous.permissions(), error);
|
||||
if (error) {
|
||||
LOG_ERROR("Failed to preserve configuration permissions: {}", error.message());
|
||||
log_failure("Failed to preserve configuration permissions: " + error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
std::filesystem::rename(temporary, config_path_, error);
|
||||
if (!error) return 0;
|
||||
LOG_ERROR("Failed to replace configuration {}: {}", config_path_, error.message());
|
||||
log_failure("Failed to replace configuration " + config_path_ + ": " + error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
@@ -226,8 +241,6 @@ int ConfigCenter::Save() {
|
||||
ini_->SetValue(section_, "connection_mode",
|
||||
connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id");
|
||||
ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_);
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
ini_->SetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_));
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
@@ -253,6 +266,8 @@ int ConfigCenter::Save() {
|
||||
ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
|
||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||
save_remote_previews_.load());
|
||||
ini_->SetBoolValue(section_, "pause_video_when_minimized",
|
||||
pause_video_when_minimized_.load());
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
ini_->SetValue(section_, "sponsorship_prompt_dismissed_version",
|
||||
@@ -269,6 +284,56 @@ int ConfigCenter::Save() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string ConfigCenter::ExportSharedHostConfig() const {
|
||||
CSimpleIniA host;
|
||||
host.SetUnicode(true);
|
||||
host.SetLongValue(section_, "shared_identity_config_version", 1);
|
||||
host.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
host.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
host.SetLongValue(section_, "signal_server_port", signal_server_port_);
|
||||
host.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
host.SetLongValue(section_, "video_encode_format", static_cast<long>(video_encode_format_));
|
||||
host.SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
|
||||
host.SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
|
||||
host.SetBoolValue(section_, "enable_privacy_screen", false);
|
||||
host.SetBoolValue(section_, "save_remote_previews", false);
|
||||
host.SetBoolValue(section_, "enable_ip_direct", false);
|
||||
std::string contents;
|
||||
host.Save(contents);
|
||||
return contents;
|
||||
}
|
||||
|
||||
int ConfigCenter::ApplySharedHostConfig(const std::string& contents) {
|
||||
CSimpleIniA host;
|
||||
host.SetUnicode(true);
|
||||
if (host.LoadData(contents.c_str(), contents.size()) < 0 ||
|
||||
host.GetLongValue(section_, "shared_identity_config_version", 0) != 1) return -1;
|
||||
const bool self_hosted = host.GetBoolValue(section_, "enable_self_hosted", false);
|
||||
const std::string server = host.GetValue(section_, "signal_server_host", "");
|
||||
const long port = host.GetLongValue(section_, "signal_server_port", 0);
|
||||
const long turn = host.GetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
const long codec = host.GetLongValue(section_, "video_encode_format", static_cast<long>(video_encode_format_));
|
||||
const bool trusted = host.GetBoolValue(section_, "trusted_devices_only", false);
|
||||
const bool lock = host.GetBoolValue(section_, "lock_on_disconnect", true);
|
||||
MiniRtcSignalServerAddress address{};
|
||||
if (!IsValidTurnModeValue(turn) || codec < 0 || codec > 1 ||
|
||||
(self_hosted && (port < 1 || port > 65535 ||
|
||||
MiniRtcParseSignalServerAddress(server.data(), server.size(), &address) != 0))) return -1;
|
||||
if (StoreValues({{"enable_self_hosted", self_hosted ? "true" : "false"},
|
||||
{"signal_server_host", server}, {"signal_server_port", std::to_string(port)},
|
||||
{"turn_mode", std::to_string(turn)}, {"video_encode_format", std::to_string(codec)},
|
||||
{"trusted_devices_only", trusted ? "true" : "false"},
|
||||
{"lock_on_disconnect", lock ? "true" : "false"}}) != 0) return -1;
|
||||
enable_self_hosted_ = self_hosted;
|
||||
signal_server_host_ = server;
|
||||
signal_server_port_ = static_cast<int>(port);
|
||||
turn_mode_ = static_cast<TURN_MODE>(turn);
|
||||
video_encode_format_ = static_cast<VIDEO_ENCODE_FORMAT>(codec);
|
||||
trusted_devices_only_.store(trusted);
|
||||
lock_on_disconnect_.store(lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// setters
|
||||
|
||||
int ConfigCenter::SetLanguage(LANGUAGE language) {
|
||||
@@ -306,20 +371,6 @@ int ConfigCenter::SetIpDirectEnabled(bool enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||
return -1;
|
||||
}
|
||||
ini_->SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
|
||||
if (CommitIni() < 0) {
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
return -1;
|
||||
}
|
||||
screen_capture_method_ = method;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
|
||||
if (format != VIDEO_ENCODE_FORMAT::H264 && format != VIDEO_ENCODE_FORMAT::AV1) return -1;
|
||||
if (StoreValues({{"video_encode_format", std::to_string(static_cast<int>(format))}}) != 0) return -1;
|
||||
@@ -345,22 +396,24 @@ int ConfigCenter::SetForceRelay(bool force_relay) {
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerHost(const std::string& host) {
|
||||
const auto address = minirtc::ParseSignalServerAddress(host);
|
||||
if (!address) return -1;
|
||||
if (address->port) return SetServerEndpoint(host, *address->port);
|
||||
if (StoreValues({{"signal_server_host", address->Address()}}) != 0) return -1;
|
||||
signal_server_host_ = address->Address();
|
||||
MiniRtcSignalServerAddress address{};
|
||||
if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
|
||||
if (address.port) return SetServerEndpoint(host, address.port);
|
||||
const auto normalized = std::string(address.host) + address.target;
|
||||
if (StoreValues({{"signal_server_host", normalized}}) != 0) return -1;
|
||||
signal_server_host_ = normalized;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerEndpoint(const std::string& host, int port) {
|
||||
const auto address = minirtc::ParseSignalServerAddress(host);
|
||||
if (!address) return -1;
|
||||
port = address->port.value_or(port);
|
||||
MiniRtcSignalServerAddress address{};
|
||||
if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
|
||||
if (address.port) port = address.port;
|
||||
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_host_ = address->Address();
|
||||
signal_server_host_ = normalized;
|
||||
signal_server_port_ = port;
|
||||
return 0;
|
||||
}
|
||||
@@ -379,34 +432,76 @@ int ConfigCenter::SetSelfHosted(bool enabled) {
|
||||
}
|
||||
|
||||
int ConfigCenter::SetAutostart(bool enabled) {
|
||||
const bool previous = IsAutostartEnabled("CrossDesk");
|
||||
// Refresh the executable path and remove legacy registrations even when the
|
||||
// boolean state is unchanged. In particular, a Run entry no longer counts
|
||||
// as enabled for an elevated Windows build, but still needs deleting.
|
||||
#ifdef _WIN32
|
||||
const bool update_registration = true;
|
||||
// System registration determines success; INI only mirrors the last
|
||||
// successfully applied preference. Unsupported builds never touch services.
|
||||
if (!SupportsServiceAutostart()) {
|
||||
if (enabled) return -1;
|
||||
} else if (!ServiceAutostartMatches(enabled)) {
|
||||
std::string error;
|
||||
if (!SetServiceAutostart(enabled, error)) {
|
||||
LOG_ERROR("Cannot configure autostart: {}", error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
enable_autostart_ = enabled;
|
||||
ini_->SetBoolValue(section_, "enable_autostart", enabled);
|
||||
// Keep both in-memory copies even if the file is temporarily unwritable,
|
||||
// so a later settings save can persist them. Never roll back the service.
|
||||
CommitIni(true);
|
||||
return 0;
|
||||
#else
|
||||
const bool update_registration = enabled != previous;
|
||||
const bool previous = IsAutostartEnabled("CrossDesk");
|
||||
const auto configure = [](bool value) {
|
||||
return value ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk");
|
||||
};
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
// Repair old deb entries even when the saved boolean is unchanged, keeping
|
||||
// desktop-managed disabled flags and custom commands intact.
|
||||
if (enabled && previous && !platform::MigrateLegacyAutostart("CrossDesk")) {
|
||||
LOG_ERROR("Failed to migrate Linux autostart");
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
if (update_registration &&
|
||||
!(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
|
||||
LOG_ERROR("SetAutostart failed");
|
||||
if (enabled != previous && !configure(enabled)) {
|
||||
LOG_ERROR("Cannot configure autostart");
|
||||
return -1;
|
||||
}
|
||||
if (StoreValues({{"enable_autostart", enabled ? "true" : "false"}}) != 0) {
|
||||
if (enabled != previous) {
|
||||
if (previous) EnableAutostart("CrossDesk");
|
||||
else DisableAutostart("CrossDesk");
|
||||
}
|
||||
if (enabled != previous && !configure(previous))
|
||||
LOG_ERROR("Cannot restore autostart");
|
||||
return -1;
|
||||
}
|
||||
enable_autostart_ = enabled;
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int ConfigCenter::SetDaemon(bool enabled) {
|
||||
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||
const bool previous = enable_daemon_;
|
||||
#endif
|
||||
if (StoreValues({{"enable_daemon", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
enable_daemon_ = enabled;
|
||||
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||
std::string error;
|
||||
#ifdef _WIN32
|
||||
auto& daemon = platform::WindowsRuntimeDaemon::Instance();
|
||||
#elif defined(__APPLE__)
|
||||
auto& daemon = platform::MacRuntimeDaemon::Instance();
|
||||
#else
|
||||
auto& daemon = platform::LinuxRuntimeDaemon::Instance();
|
||||
#endif
|
||||
if (!daemon.SetEnabled(enabled, error)) {
|
||||
LOG_ERROR("Cannot apply daemon setting: {}", error);
|
||||
if (StoreValues({{"enable_daemon", previous ? "true" : "false"}}) == 0) {
|
||||
enable_daemon_ = previous;
|
||||
} else {
|
||||
LOG_ERROR("Cannot restore daemon preference after startup failure");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -467,6 +562,18 @@ bool ConfigCenter::IsSaveRemotePreviews() const {
|
||||
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) {
|
||||
portable_service_prompt_suppressed_ = suppressed;
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
@@ -499,10 +606,6 @@ ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const {
|
||||
|
||||
bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; }
|
||||
|
||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||
return screen_capture_method_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
@@ -533,7 +636,17 @@ int ConfigCenter::GetDefaultSignalServerPort() const {
|
||||
|
||||
bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; }
|
||||
|
||||
bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; }
|
||||
bool ConfigCenter::IsEnableAutostart() const {
|
||||
#ifdef _WIN32
|
||||
return IsServiceAutostartEnabled();
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
// Uninstalling or desktop startup settings can remove the registration while
|
||||
// leaving config.ini intact. Report the actual entry, not the saved intent.
|
||||
return IsAutostartEnabled("CrossDesk");
|
||||
#else
|
||||
return enable_autostart_;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; }
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#include <utility>
|
||||
#include <string>
|
||||
|
||||
#include "screen_capturer/screen_capture_method.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -42,7 +41,6 @@ class ConfigCenter {
|
||||
int SetLanguage(LANGUAGE language);
|
||||
int SetConnectionMode(CONNECTION_MODE mode);
|
||||
int SetIpDirectEnabled(bool enabled);
|
||||
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
||||
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
||||
int SetTurnMode(TURN_MODE turn_mode);
|
||||
int SetForceRelay(bool force_relay);
|
||||
@@ -57,6 +55,7 @@ class ConfigCenter {
|
||||
int SetLockOnDisconnect(bool enabled);
|
||||
int SetTrustedDevicesOnly(bool enabled);
|
||||
int SetSaveRemotePreviews(bool enabled);
|
||||
int SetPauseVideoWhenMinimized(bool enabled);
|
||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
||||
int SetFileTransferSavePath(const std::string& path);
|
||||
@@ -66,7 +65,6 @@ class ConfigCenter {
|
||||
LANGUAGE GetLanguage() const;
|
||||
CONNECTION_MODE GetConnectionMode() const;
|
||||
bool IsIpDirectEnabled() const;
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
TURN_MODE GetTurnMode() const;
|
||||
bool IsForceRelay() const;
|
||||
@@ -83,15 +81,19 @@ class ConfigCenter {
|
||||
bool IsLockOnDisconnect() const;
|
||||
bool IsTrustedDevicesOnly() const;
|
||||
bool IsSaveRemotePreviews() const;
|
||||
bool IsPauseVideoWhenMinimized() const;
|
||||
bool IsPortableServicePromptSuppressed() const;
|
||||
std::string GetSponsorshipPromptDismissedVersion() const;
|
||||
std::string GetFileTransferSavePath() const;
|
||||
|
||||
int Load();
|
||||
int Save();
|
||||
// Only connection/admission preferences cross into the SYSTEM host profile.
|
||||
std::string ExportSharedHostConfig() const;
|
||||
int ApplySharedHostConfig(const std::string& contents);
|
||||
|
||||
private:
|
||||
int CommitIni();
|
||||
int CommitIni(bool warn_only = false);
|
||||
int StoreValues(std::initializer_list<std::pair<const char*, std::string>> values);
|
||||
mutable std::mutex file_path_mutex_;
|
||||
std::string config_path_;
|
||||
@@ -102,7 +104,6 @@ class ConfigCenter {
|
||||
LANGUAGE language_ = LANGUAGE::SYSTEM;
|
||||
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
|
||||
bool enable_ip_direct_ = true;
|
||||
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
std::string signal_server_host_ = "";
|
||||
@@ -117,6 +118,7 @@ class ConfigCenter {
|
||||
std::atomic<bool> lock_on_disconnect_{true};
|
||||
std::atomic<bool> trusted_devices_only_{false};
|
||||
std::atomic<bool> save_remote_previews_{true};
|
||||
std::atomic<bool> pause_video_when_minimized_{true};
|
||||
bool portable_service_prompt_suppressed_ = false;
|
||||
std::string sponsorship_prompt_dismissed_version_;
|
||||
std::string file_transfer_save_path_ = "";
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -58,7 +58,12 @@ private:
|
||||
void BindStreamCallbacks();
|
||||
void BindServerCallbacks();
|
||||
void Tick();
|
||||
#ifdef _WIN32
|
||||
void TickLocalHost();
|
||||
#endif
|
||||
void PollUpdateCheck();
|
||||
void ExportDiagnosticLogs();
|
||||
void PollDiagnosticExport();
|
||||
void ShareLocalCursorState();
|
||||
bool RequestPasswordChange(const std::string &password);
|
||||
void HandlePasswordChangeResult();
|
||||
@@ -79,8 +84,8 @@ private:
|
||||
void DeviceGroupAction(const std::string& action, const std::string& value,
|
||||
bool gallery_window);
|
||||
bool SaveDeviceGroups(const std::string& new_group, bool gallery_window);
|
||||
void ReleasePressedInput();
|
||||
void ReleaseControl();
|
||||
void ReleasePressedInput(bool preserve_local_ui = false);
|
||||
void ReleaseControl(bool preserve_local_ui = false);
|
||||
void SyncAudioPlayback();
|
||||
void SaveGalleryWindowState();
|
||||
void SyncAnnouncements();
|
||||
@@ -95,6 +100,8 @@ private:
|
||||
void SyncServerWindow();
|
||||
void SyncTrustedDevices();
|
||||
void SyncFileTransferNotifications();
|
||||
void OpenFileManager(const std::string& remote);
|
||||
std::string FileManagerPeerName(const std::string& remote);
|
||||
void CancelFileTransfer(uint32_t file_id);
|
||||
void SetServerPanelExpanded(bool expanded);
|
||||
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
|
||||
@@ -433,7 +433,6 @@ inline constexpr TranslationRow kTranslationRows[] = {
|
||||
{"近期连接", {"近期连接", "Recent Connections", "Недавние подключения", "近期連線", "最近の接続", "최근 연결", "Aktuelle Verbindungen", "Connexions récentes", "Conexiones recientes", "Conexões recentes"}},
|
||||
{"返回", {"返回", "Back", "Назад", "返回", "戻る", "뒤로", "Zurück", "Retour", "Volver", "Voltar"}},
|
||||
{"还原窗口", {"还原窗口", "Restore window", "Восстановить окно", "還原視窗", "復元ウィンドウ", "복원 창", "Fenster „Wiederherstellen“.", "Fenêtre de restauration", "Restaurar ventana", "Janela de restauração"}},
|
||||
{"远程控制连接中,保留连接以确认或取消关机。", {"远程控制连接中,保留连接以确认或取消关机。", "Remote session active. Keeping the connection available to confirm or cancel shutdown.", "Удалённый сеанс активен. Соединение сохранено для подтверждения или отмены выключения.", "遠端控制連線中,保留連線以確認或取消關機。", "リモート接続中です。シャットダウンの確認または取り消しのため、接続を維持しています。", "원격 세션이 활성화되어 있습니다. 종료 확인 또는 취소를 위해 연결을 유지합니다.", "Remotesitzung aktiv. Die Verbindung bleibt zum Bestätigen oder Abbrechen des Herunterfahrens bestehen.", "Session distante active. La connexion reste disponible pour confirmer ou annuler l’arrêt.", "Sesión remota activa. Se mantiene la conexión para confirmar o cancelar el apagado.", "Sessão remota ativa. A conexão é mantida para confirmar ou cancelar o desligamento."}},
|
||||
{"远程桌面", {"远程桌面", "Remote Desktop", "Удаленный рабочий стол", "遠端桌面", "リモートデスクトップ", "원격 데스크톱", "Remotedesktop", "Bureau à distance", "Escritorio remoto", "Área de Trabalho Remota"}},
|
||||
{"远端 IP / 端口", {"远端 IP / 端口", "Remote IP / Port", "Удалённый IP / Порт", "遠端 IP / 埠", "リモートIP/ポート", "원격 IP / 포트", "Remote-IP/Port", "IP/port distant", "IP/puerto remoto", "IP/Porta Remota"}},
|
||||
{"远端 Windows 服务不可用", {"远端 Windows 服务不可用", "Remote Windows service unavailable", "Служба Windows на удаленной стороне недоступна", "遠端 Windows 服務不可用", "リモート Windows サービスが利用できない", "원격 Windows 서비스를 사용할 수 없습니다.", "Remote-Windows-Dienst nicht verfügbar", "Service Windows distant indisponible", "Servicio remoto de Windows no disponible", "Serviço remoto do Windows indisponível"}},
|
||||
@@ -519,6 +518,42 @@ inline constexpr TranslationRow kTranslationRows[] = {
|
||||
{"请核对控制端指纹。", {"请核对控制端指纹。", "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"}},
|
||||
{"修复", {"修复", "Repair", "Восстановить", "修復", "修復", "복구", "Reparieren", "Réparer", "Reparar", "Reparar"}},
|
||||
{"修复 CrossDesk 服务", {"修复 CrossDesk 服务", "Repair CrossDesk Service", "Восстановление службы CrossDesk", "修復 CrossDesk 服務", "CrossDesk サービスの修復", "CrossDesk 서비스 복구", "CrossDesk-Dienst reparieren", "Réparer le service CrossDesk", "Reparar el servicio CrossDesk", "Reparar o serviço CrossDesk"}},
|
||||
{"升级或移动 CrossDesk 后,锁屏控制服务可能不可用。修复将以管理员权限更新服务路径并重启服务。请确认当前目录包含完整的便携版文件。", {"升级或移动 CrossDesk 后,锁屏控制服务可能不可用。修复将以管理员权限更新服务路径并重启服务。请确认当前目录包含完整的便携版文件。", "After updating or moving CrossDesk, lock-screen control may be unavailable. Repair updates the service path and restarts the service with administrator permission. Keep all portable package files in the current folder.", "После обновления или перемещения CrossDesk управление экраном блокировки может быть недоступно. Восстановление обновит путь и перезапустит службу с правами администратора. Все файлы портативной версии должны находиться в текущей папке.", "升級或移動 CrossDesk 後,鎖定畫面控制服務可能無法使用。修復將以管理員權限更新服務路徑並重新啟動服務。請確認目前目錄包含完整的可攜版檔案。", "CrossDesk の更新や移動後は、ロック画面を操作できない場合があります。修復すると管理者権限でサービスのパスを更新し、再起動します。ポータブル版の全ファイルを現在のフォルダーに配置してください。", "CrossDesk를 업데이트하거나 이동한 후 잠금 화면 제어가 작동하지 않을 수 있습니다. 복구하면 관리자 권한으로 서비스 경로를 갱신하고 서비스를 다시 시작합니다. 현재 폴더에 포터블 버전의 모든 파일이 있어야 합니다.", "Nach dem Aktualisieren oder Verschieben von CrossDesk ist die Steuerung des Sperrbildschirms möglicherweise nicht verfügbar. Die Reparatur aktualisiert den Dienstpfad und startet den Dienst mit Administratorrechten neu. Alle Dateien der portablen Version müssen im aktuellen Ordner liegen.", "Après une mise à jour ou un déplacement de CrossDesk, le contrôle de l’écran verrouillé peut être indisponible. La réparation met à jour le chemin du service et le redémarre avec les droits administrateur. Conservez tous les fichiers de la version portable dans le dossier actuel.", "Tras actualizar o mover CrossDesk, el control de la pantalla de bloqueo puede no estar disponible. La reparación actualiza la ruta y reinicia el servicio con permisos de administrador. Mantenga todos los archivos de la versión portátil en la carpeta actual.", "Após atualizar ou mover o CrossDesk, o controle da tela de bloqueio pode ficar indisponível. O reparo atualiza o caminho e reinicia o serviço com permissão de administrador. Mantenha todos os arquivos da versão portátil na pasta atual."}},
|
||||
{"正在修复 Windows 服务…", {"正在修复 Windows 服务…", "Repairing Windows service…", "Восстановление службы Windows…", "正在修復 Windows 服務…", "Windows サービスを修復中…", "Windows 서비스 복구 중…", "Windows-Dienst wird repariert…", "Réparation du service Windows…", "Reparando el servicio de Windows…", "Reparando o serviço do Windows…"}},
|
||||
{"服务已修复并启动", {"服务已修复并启动", "Service repaired and started", "Служба восстановлена и запущена", "服務已修復並啟動", "サービスを修復し、起動しました", "서비스를 복구하고 시작했습니다", "Dienst repariert und gestartet", "Service réparé et démarré", "Servicio reparado e iniciado", "Serviço reparado e iniciado"}},
|
||||
{"修复失败,请检查管理员权限和便携版文件后重试", {"修复失败,请检查管理员权限和便携版文件后重试", "Repair failed. Check administrator permission and portable package files, then retry.", "Восстановление не удалось. Проверьте права администратора и файлы портативной версии, затем повторите попытку.", "修復失敗,請檢查管理員權限和可攜版檔案後重試", "修復に失敗しました。管理者権限とポータブル版のファイルを確認して再試行してください。", "복구하지 못했습니다. 관리자 권한과 포터블 버전 파일을 확인한 후 다시 시도하세요.", "Reparatur fehlgeschlagen. Prüfen Sie die Administratorrechte und die Dateien der portablen Version und versuchen Sie es erneut.", "Échec de la réparation. Vérifiez les droits administrateur et les fichiers de la version portable, puis réessayez.", "Error al reparar. Compruebe los permisos de administrador y los archivos de la versión portátil e inténtelo de nuevo.", "Falha no reparo. Verifique a permissão de administrador e os arquivos da versão portátil e tente novamente."}},
|
||||
{"远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", {"远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", "The remote Windows service is unavailable. Lock-screen control will not work. Install or repair the CrossDesk service on the remote device.", "Служба Windows на удалённом устройстве недоступна. Управление экраном блокировки не работает. Установите или восстановите службу CrossDesk на удалённом устройстве.", "遠端 Windows 服務無法使用,鎖定畫面時無法操作。請在遠端安裝或修復 CrossDesk 服務。", "リモートの Windows サービスが利用できないため、ロック画面を操作できません。リモート端末で CrossDesk サービスをインストールまたは修復してください。", "원격 Windows 서비스를 사용할 수 없어 잠금 화면을 제어할 수 없습니다. 원격 장치에서 CrossDesk 서비스를 설치하거나 복구하세요.", "Der Windows-Dienst auf dem entfernten Gerät ist nicht verfügbar. Der Sperrbildschirm kann nicht gesteuert werden. Installieren oder reparieren Sie dort den CrossDesk-Dienst.", "Le service Windows distant est indisponible. Le contrôle de l’écran verrouillé ne fonctionnera pas. Installez ou réparez le service CrossDesk sur l’appareil distant.", "El servicio de Windows remoto no está disponible. No se podrá controlar la pantalla de bloqueo. Instale o repare el servicio CrossDesk en el dispositivo remoto.", "O serviço do Windows remoto está indisponível. Não será possível controlar a tela de bloqueio. Instale ou repare o serviço CrossDesk no dispositivo remoto."}},
|
||||
{"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", {"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", "Boot startup requires the installed Release build. Portable and Debug builds do not support the unattended service.", "Автозапуск требует установленной сборки Release. Переносная версия и Debug не поддерживают автономную службу.", "開機自啟需要安裝版 Release;可攜版和 Debug 不支援無人值守服務。", "自動起動にはインストール版 Release が必要です。ポータブル版と Debug は無人アクセスサービスに対応していません。", "자동 시작에는 설치형 Release 버전이 필요합니다. 포터블 및 Debug 버전은 무인 접속 서비스를 지원하지 않습니다.", "Der Systemstart erfordert die installierte Release-Version. Portable- und Debug-Versionen unterstützen den unbeaufsichtigten Dienst nicht.", "Le démarrage automatique nécessite la version Release installée. Les versions portable et Debug ne prennent pas en charge le service sans surveillance.", "El inicio automático requiere la versión Release instalada. Las versiones portátil y Debug no admiten el servicio desatendido.", "A inicialização automática exige a versão Release instalada. As versões portátil e Debug não oferecem suporte ao serviço não assistido."}},
|
||||
{"无法配置无人值守服务,请检查管理员权限和安装文件。", {"无法配置无人值守服务,请检查管理员权限和安装文件。", "Could not configure the unattended service. Check administrator privileges and installation files.", "Не удалось настроить автономную службу. Проверьте права администратора и файлы установки.", "無法設定無人值守服務,請檢查管理員權限和安裝檔案。", "無人アクセスサービスを設定できません。管理者権限とインストールファイルを確認してください。", "무인 접속 서비스를 설정할 수 없습니다. 관리자 권한과 설치 파일을 확인하세요.", "Der unbeaufsichtigte Dienst konnte nicht eingerichtet werden. Prüfen Sie Administratorrechte und Installationsdateien.", "Impossible de configurer le service sans surveillance. Vérifiez les droits administrateur et les fichiers d’installation.", "No se pudo configurar el servicio desatendido. Compruebe los permisos de administrador y los archivos de instalación.", "Não foi possível configurar o serviço não assistido. Verifique as permissões de administrador e os arquivos de instalação."}},
|
||||
{"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", {"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", "Connect after boot without signing in. After login, keep the same ID, password and full functionality.", "Подключайтесь после загрузки без входа в Windows. После входа сохраняются ID, пароль и все функции.", "開機後無需登入即可連線;登入後繼續使用同一 ID、密碼和完整功能。", "サインイン前でも接続できます。サインイン後も同じ ID とパスワードですべての機能を利用できます。", "부팅 후 로그인하지 않아도 연결할 수 있습니다. 로그인 후에도 같은 ID와 비밀번호로 모든 기능을 사용할 수 있습니다.", "Verbinden Sie sich nach dem Start ohne Anmeldung. Nach der Anmeldung bleiben ID, Passwort und alle Funktionen erhalten.", "Connectez-vous après le démarrage sans ouvrir de session. Après connexion, conservez les mêmes ID et mot de passe et toutes les fonctions.", "Conéctese tras arrancar sin iniciar sesión. Después de iniciar sesión, conserve el mismo ID, contraseña y todas las funciones.", "Conecte-se após a inicialização sem fazer login. Após o login, mantenha o mesmo ID, senha e todas as funções."}},
|
||||
{"前往 GitHub 反馈", {"前往 GitHub 反馈", "Give feedback on GitHub", "Оставить отзыв на GitHub", "前往 GitHub 回饋", "GitHub でフィードバック", "GitHub에 피드백 보내기", "Feedback auf GitHub geben", "Donner un avis sur GitHub", "Enviar comentarios en GitHub", "Enviar comentários no GitHub"}},
|
||||
{"反馈与建议", {"反馈与建议", "Feedback & suggestions", "Отзывы и предложения", "回饋與建議", "フィードバック・ご提案", "피드백 및 제안", "Feedback und Vorschläge", "Avis et suggestions", "Comentarios y sugerencias", "Comentários e sugestões"}},
|
||||
{"导出诊断日志", {"导出诊断日志", "Export diagnostic logs", "Экспорт журналов диагностики", "匯出診斷日誌", "診断ログをエクスポート", "진단 로그 내보내기", "Diagnoseprotokolle exportieren", "Exporter les journaux de diagnostic", "Exportar registros de diagnóstico", "Exportar logs de diagnóstico"}},
|
||||
{"将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", {"将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", "Save CrossDesk and MiniRTC logs with basic version information as a ZIP file.", "Сохраните журналы CrossDesk и MiniRTC и сведения о версии в ZIP-файл.", "將 CrossDesk、MiniRTC 日誌和基本版本資訊儲存為 ZIP 檔案。", "CrossDesk・MiniRTC のログと基本的なバージョン情報を ZIP ファイルに保存します。", "CrossDesk 및 MiniRTC 로그와 기본 버전 정보를 ZIP 파일로 저장합니다.", "Speichern Sie CrossDesk- und MiniRTC-Protokolle mit Versionsinformationen als ZIP-Datei.", "Enregistrez les journaux CrossDesk et MiniRTC et les informations de version dans un fichier ZIP.", "Guarda los registros de CrossDesk y MiniRTC con la información de versión en un archivo ZIP.", "Salve os logs do CrossDesk e do MiniRTC com informações de versão em um arquivo ZIP."}},
|
||||
{"报告问题或提出功能建议。", {"报告问题或提出功能建议。", "Report a problem or suggest an improvement.", "Сообщите о проблеме или предложите улучшение.", "回報問題或提出功能建議。", "問題の報告や機能の改善をご提案ください。", "문제를 보고하거나 개선 사항을 제안하세요.", "Melden Sie ein Problem oder schlagen Sie eine Verbesserung vor.", "Signalez un problème ou proposez une amélioration.", "Informa de un problema o sugiere una mejora.", "Relate um problema ou sugira uma melhoria."}},
|
||||
{"无法保存诊断包,请选择其他位置重试。", {"无法保存诊断包,请选择其他位置重试。", "Could not save the diagnostic archive. Choose another location and try again.", "Не удалось сохранить архив диагностики. Выберите другое расположение и повторите попытку.", "無法儲存診斷包,請選擇其他位置重試。", "診断アーカイブを保存できません。別の場所を選んで再度お試しください。", "진단 파일을 저장할 수 없습니다. 다른 위치를 선택하고 다시 시도하세요.", "Das Diagnosearchiv konnte nicht gespeichert werden. Wählen Sie einen anderen Speicherort und versuchen Sie es erneut.", "Impossible de sauvegarder l’archive de diagnostic. Choisissez un autre emplacement et réessayez.", "No se pudo guardar el archivo de diagnóstico. Elige otra ubicación e inténtalo de nuevo.", "Não foi possível salvar o arquivo de diagnóstico. Escolha outro local e tente novamente."}},
|
||||
{"无法打开浏览器,请访问 GitHub 项目页面。", {"无法打开浏览器,请访问 GitHub 项目页面。", "Could not open the browser. Please visit the project on GitHub.", "Не удалось открыть браузер. Откройте страницу проекта на GitHub.", "無法開啟瀏覽器,請前往 GitHub 專案頁面。", "ブラウザーを開けません。GitHub のプロジェクトページにアクセスしてください。", "브라우저를 열 수 없습니다. GitHub 프로젝트 페이지를 방문하세요.", "Der Browser konnte nicht geöffnet werden. Besuchen Sie die Projektseite auf GitHub.", "Impossible d’ouvrir le navigateur. Consultez la page du projet sur GitHub.", "No se pudo abrir el navegador. Visita la página del proyecto en GitHub.", "Não foi possível abrir o navegador. Acesse a página do projeto no GitHub."}},
|
||||
{"无法读取日志,请稍后重试。", {"无法读取日志,请稍后重试。", "Could not read the logs. Please try again later.", "Не удалось прочитать журналы. Повторите попытку позже.", "無法讀取日誌,請稍後重試。", "ログを読み取れません。後でもう一度お試しください。", "로그를 읽을 수 없습니다. 나중에 다시 시도하세요.", "Die Protokolle konnten nicht gelesen werden. Versuchen Sie es später erneut.", "Impossible de lire les journaux. Réessayez plus tard.", "No se pudieron leer los registros. Inténtalo de nuevo más tarde.", "Não foi possível ler os logs. Tente novamente mais tarde."}},
|
||||
{"日志文件过大,无法导出。", {"日志文件过大,无法导出。", "The log file is too large to export.", "Файл журнала слишком велик для экспорта.", "日誌檔案過大,無法匯出。", "ログファイルが大きすぎるためエクスポートできません。", "로그 파일이 너무 커서 내보낼 수 없습니다.", "Die Protokolldatei ist zu groß für den Export.", "Le fichier journal est trop volumineux pour être exporté.", "El archivo de registro es demasiado grande para exportarlo.", "O arquivo de log é grande demais para exportar."}},
|
||||
{"正在导出…", {"正在导出…", "Exporting…", "Экспорт…", "正在匯出…", "エクスポート中…", "내보내는 중…", "Export wird erstellt…", "Exportation…", "Exportando…", "Exportando…"}},
|
||||
{"没有找到可导出的日志。", {"没有找到可导出的日志。", "No logs were found to export.", "Журналы для экспорта не найдены.", "找不到可匯出的日誌。", "エクスポートできるログが見つかりません。", "내보낼 로그를 찾을 수 없습니다.", "Keine Protokolle zum Exportieren gefunden.", "Aucun journal à exporter trouvé.", "No se encontraron registros para exportar.", "Nenhum log encontrado para exportar."}},
|
||||
{"诊断日志已导出,请将 ZIP 附到 GitHub Issue。", {"诊断日志已导出,请将 ZIP 附到 GitHub Issue。", "Diagnostic logs exported. Attach the ZIP to your GitHub issue.", "Журналы диагностики экспортированы. Прикрепите ZIP-файл к обращению на GitHub.", "診斷日誌已匯出,請將 ZIP 附到 GitHub Issue。", "診断ログをエクスポートしました。ZIP を GitHub Issue に添付してください。", "진단 로그를 내보냈습니다. GitHub 이슈에 ZIP을 첨부하세요.", "Diagnoseprotokolle exportiert. Hängen Sie die ZIP-Datei an Ihr GitHub-Issue an.", "Journaux de diagnostic exportés. Joignez le ZIP à votre ticket GitHub.", "Registros de diagnóstico exportados. Adjunta el ZIP a tu issue de GitHub.", "Logs de diagnóstico exportados. Anexe o ZIP à sua issue do GitHub."}},
|
||||
{"请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", {"请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", "Attach the exported ZIP log archive to your GitHub issue to help us investigate.", "Прикрепите экспортированный ZIP-архив журналов к обращению на GitHub, чтобы помочь нам разобраться в проблеме.", "請在 GitHub Issue 中附上匯出的 ZIP 日誌封存檔,便於排查問題。", "問題の調査のため、エクスポートしたログの ZIP ファイルを GitHub Issue に添付してください。", "문제 조사에 도움이 되도록 내보낸 ZIP 로그 파일을 GitHub 이슈에 첨부해 주세요.", "Hängen Sie das exportierte ZIP-Archiv mit den Protokollen an Ihr GitHub-Issue an, damit wir das Problem untersuchen können.", "Joignez l’archive ZIP des journaux exportés à votre ticket GitHub pour nous aider à analyser le problème.", "Adjunta el archivo ZIP de los registros exportados a tu issue de GitHub para ayudarnos a investigar el problema.", "Anexe o arquivo ZIP dos logs exportados à sua issue do GitHub para nos ajudar a investigar o problema."}},
|
||||
};
|
||||
|
||||
struct NamedTranslation { const char* name; const char* literal; };
|
||||
@@ -661,8 +696,6 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
|
||||
{"remote_unlock_requires_secure_desktop", "当前仍需要安全桌面专用采集/输入"},
|
||||
{"settings", "设置"},
|
||||
{"language", "语言"},
|
||||
{"screen_capture_method", "采集方式"},
|
||||
{"screen_capture_method_auto", "自动"},
|
||||
{"video_settings", "画面设置"},
|
||||
{"remote_update_title", "建议升级被控端"},
|
||||
{"remote_update", "新版本包含功能改进和性能优化,建议将被控端升级至最新版本。"},
|
||||
@@ -733,10 +766,12 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
|
||||
{"access_website", "访问官网: "},
|
||||
{"update", "更新"},
|
||||
{"install_update", "安装更新"},
|
||||
{"retry_update", "重试更新"},
|
||||
{"manual_download", "手动下载"},
|
||||
{"install_update_confirm", "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?"},
|
||||
{"update_preparing", "正在准备更新…"},
|
||||
{"update_install_failed", "更新未完成,可重试或手动安装。"},
|
||||
{"update_install_failed_manual", "更新未完成,请手动下载安装。"},
|
||||
{"connection_alias", "修改名称"},
|
||||
{"delete_connection", "删除连接"},
|
||||
{"connect_to_this_connection", "发起连接"},
|
||||
@@ -804,7 +839,20 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
|
||||
{"privacy_unlock_lock", "锁定"},
|
||||
{"privacy_screen_unlock_hint", "按下快捷键,解除隐私屏"},
|
||||
{"exit_program", "退出"},
|
||||
{"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", "稍后"},
|
||||
{"repair_windows_service", "修复"},
|
||||
{"windows_service_repair_title", "修复 CrossDesk 服务"},
|
||||
{"windows_service_repair_message", "升级或移动 CrossDesk 后,锁屏控制服务可能不可用。修复将以管理员权限更新服务路径并重启服务。请确认当前目录包含完整的便携版文件。"},
|
||||
{"windows_service_repairing", "正在修复 Windows 服务…"},
|
||||
{"windows_service_repair_success", "服务已修复并启动"},
|
||||
{"windows_service_repair_failed", "修复失败,请检查管理员权限和便携版文件后重试"},
|
||||
{"remote_service_unavailable_message", "远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。"},
|
||||
};
|
||||
inline constexpr const char* kSlintLiterals[] = {
|
||||
"此端口已被占用,",
|
||||
@@ -1015,6 +1063,8 @@ inline constexpr const char* kSlintLiterals[] = {
|
||||
"确认执行",
|
||||
"监听所有 IP 接口",
|
||||
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
|
||||
"最小化时暂停视频",
|
||||
"控制窗口最小化时暂停接收视频,降低本机解码资源占用。",
|
||||
};
|
||||
} // namespace detail
|
||||
|
||||
@@ -1157,8 +1207,6 @@ inline constexpr const char* kSlintLiterals[] = {
|
||||
X(remote_unlock_requires_secure_desktop) \
|
||||
X(settings) \
|
||||
X(language) \
|
||||
X(screen_capture_method) \
|
||||
X(screen_capture_method_auto) \
|
||||
X(video_settings) \
|
||||
X(remote_update_title) \
|
||||
X(remote_update) \
|
||||
@@ -1229,10 +1277,12 @@ inline constexpr const char* kSlintLiterals[] = {
|
||||
X(access_website) \
|
||||
X(update) \
|
||||
X(install_update) \
|
||||
X(retry_update) \
|
||||
X(manual_download) \
|
||||
X(install_update_confirm) \
|
||||
X(update_preparing) \
|
||||
X(update_install_failed) \
|
||||
X(update_install_failed_manual) \
|
||||
X(connection_alias) \
|
||||
X(delete_connection) \
|
||||
X(connect_to_this_connection) \
|
||||
@@ -1300,7 +1350,20 @@ inline constexpr const char* kSlintLiterals[] = {
|
||||
X(privacy_unlock_lock) \
|
||||
X(privacy_screen_unlock_hint) \
|
||||
X(exit_program) \
|
||||
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) \
|
||||
X(repair_windows_service) \
|
||||
X(windows_service_repair_title) \
|
||||
X(windows_service_repair_message) \
|
||||
X(windows_service_repairing) \
|
||||
X(windows_service_repair_success) \
|
||||
X(windows_service_repair_failed) \
|
||||
X(remote_service_unavailable_message)
|
||||
|
||||
} // namespace localization
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -59,6 +59,7 @@ void ClipboardController::Shutdown() {
|
||||
pending_remote_text_.reset();
|
||||
pending_complete_ = {};
|
||||
pending_allowed_ = {};
|
||||
pending_reads_.clear();
|
||||
}
|
||||
|
||||
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;
|
||||
session = found->second;
|
||||
}
|
||||
if (!hello) {
|
||||
if (!hello && kind != clip::kRead && kind != clip::kClear) {
|
||||
if (!session->ready.load(std::memory_order_acquire) ||
|
||||
kind != clip::kText || !request) return;
|
||||
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);
|
||||
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;
|
||||
const std::weak_ptr<WebSession> weak = session;
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
if (!data || size == 0) {
|
||||
if (!data || (size == 0 && !complete)) {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if (!events_enabled_.load(std::memory_order_acquire)) {
|
||||
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::string source_id;
|
||||
std::weak_ptr<gui_detail::RemoteSession> source_session;
|
||||
@@ -242,9 +286,8 @@ void ClipboardController::ApplyPendingRemoteText() {
|
||||
}
|
||||
|
||||
// SDL clipboard functions must run on the thread that initialized SDL.
|
||||
// Browser writes must also refresh an unchanged baseline: the local OS
|
||||
// clipboard may have changed since the last poll.
|
||||
if (!complete && *pending_text == last_text_) return;
|
||||
// Always apply remote writes: the local OS clipboard may have changed since
|
||||
// the last poll, even when the received text still matches last_text_.
|
||||
if (!SDL_SetClipboardText(pending_text->c_str())) {
|
||||
LOG_ERROR("Failed to set remote clipboard text: {}", SDL_GetError());
|
||||
if (complete) complete(false);
|
||||
@@ -315,12 +358,11 @@ void ClipboardController::SendDeferredLocalText() {
|
||||
return;
|
||||
}
|
||||
|
||||
const int ret = SendReliableDataFrame(
|
||||
const int ret = MiniRtcSendReliableDataFrame(
|
||||
session->peer_, last_text_.data(), last_text_.size(),
|
||||
session->clipboard_label_.c_str());
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Failed to send deferred clipboard data to peer [{}], ret={}",
|
||||
session->remote_id_, ret);
|
||||
LOG_WARN("Failed to send deferred clipboard data, ret={}", ret);
|
||||
return;
|
||||
}
|
||||
deferred_local_session_.reset();
|
||||
@@ -344,11 +386,10 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
||||
}
|
||||
|
||||
const int ret =
|
||||
SendReliableDataFrame(props->peer_, text.data(), text.size(),
|
||||
MiniRtcSendReliableDataFrame(props->peer_, text.data(), text.size(),
|
||||
props->clipboard_label_.c_str());
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
|
||||
remote_id.c_str(), ret);
|
||||
LOG_WARN("Failed to send clipboard data, ret={}", ret);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -42,6 +43,7 @@ public:
|
||||
std::function<void(bool)> complete = {},
|
||||
std::function<bool()> allowed = {});
|
||||
void ApplyPendingRemoteText();
|
||||
bool QueueRead(std::function<void(std::optional<std::string>)> complete);
|
||||
void HandleLocalUpdate();
|
||||
void ReceiveWeb(const char* data, size_t size, const std::string& remote_id,
|
||||
PeerEventHandler* source);
|
||||
@@ -49,6 +51,7 @@ public:
|
||||
|
||||
private:
|
||||
struct WebSession;
|
||||
void ApplyPendingWrite();
|
||||
bool WebSessionActive(const std::string& remote_id,
|
||||
const std::shared_ptr<WebSession>& session);
|
||||
int SendToController(const std::string& remote_id, const std::string& text);
|
||||
@@ -59,6 +62,7 @@ private:
|
||||
uint32_t event_type_ = 0;
|
||||
std::atomic<bool> events_enabled_{false};
|
||||
std::mutex pending_mutex_;
|
||||
std::deque<std::function<void(std::optional<std::string>)>> pending_reads_;
|
||||
std::optional<std::string> pending_remote_text_;
|
||||
std::string pending_remote_id_;
|
||||
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <SDL3/SDL_audio.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_init.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include <remote_action.h>
|
||||
@@ -48,9 +49,9 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
frame.size = size;
|
||||
for (const auto& id : owner_.ConnectedControllers())
|
||||
owner_.WithController(
|
||||
id, [&](PeerPtr* peer, const std::string& wire) {
|
||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||
return SendAudioFrameToPeer(peer, &frame,
|
||||
id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
|
||||
frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
|
||||
return PeerSendAudioFrameToPeer(peer, &frame,
|
||||
owner_.audio_label_.c_str(),
|
||||
wire.data(), wire.size());
|
||||
});
|
||||
@@ -58,6 +59,19 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
|
||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||
|
||||
#ifdef _WIN32
|
||||
bool SessionDeviceManager::OwnsNativeCursor() const {
|
||||
const auto* windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_);
|
||||
return windows && windows->OwnsNativeCursor();
|
||||
}
|
||||
void SessionDeviceManager::SetNativeCursorPolicy(bool default_enabled, bool current_enabled) {
|
||||
owner_.host_cursor_default_ = default_enabled;
|
||||
owner_.host_cursor_preference_ = current_enabled;
|
||||
if (auto* windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_))
|
||||
windows->SetNativeCursorPolicy(default_enabled, current_enabled);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool SessionDeviceManager::ShouldSendCapturedFrame(
|
||||
std::chrono::steady_clock::time_point now, int fps) {
|
||||
const auto interval =
|
||||
@@ -148,7 +162,8 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
screen_capturer_->SetPrivacyController(&owner_.privacy_);
|
||||
#ifdef _WIN32
|
||||
if (auto windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod());
|
||||
windows->SetUnattendedHost(owner_.unattended_host_);
|
||||
windows->SetNativeCursorPolicy(owner_.host_cursor_default_, owner_.host_cursor_preference_);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -176,6 +191,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
const auto now_time = std::chrono::steady_clock::now();
|
||||
bool from_secure_desktop = false;
|
||||
#ifdef _WIN32
|
||||
owner_.host_capture_tick_ = SDL_GetTicks();
|
||||
from_secure_desktop =
|
||||
ScreenCapturerWin::CurrentFrameIsFromSecureDesktop();
|
||||
#endif
|
||||
@@ -236,14 +252,17 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
const auto send_started = std::chrono::steady_clock::now();
|
||||
uint64_t send_errors = 0;
|
||||
for (const std::string &remote_id : connected_remote_ids) {
|
||||
if (owner_.managed_host_frontend_ && remote_id.rfind("lan/", 0) != 0) continue;
|
||||
// Warm/shared capture may already be running for another controller.
|
||||
if (!owner_.DisplayResolutionsReadyForCapture(remote_id)) continue;
|
||||
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
||||
stream_id)) continue;
|
||||
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)
|
||||
RequestVideoKeyFrame(peer, stream_id.c_str());
|
||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
||||
MiniRtcRequestVideoKeyFrame(peer, stream_id.c_str());
|
||||
frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
|
||||
return PeerSendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
||||
wire.data(), wire.size());
|
||||
}) != 0)
|
||||
++send_errors;
|
||||
@@ -285,7 +304,7 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
return std::any_of(owner_.connection_status_.begin(),
|
||||
owner_.connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected &&
|
||||
return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
|
||||
entry.first.find("web") != std::string::npos;
|
||||
});
|
||||
}
|
||||
@@ -294,7 +313,7 @@ bool SessionDeviceManager::HasConnectedController() const {
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
return std::any_of(owner_.connection_status_.begin(),
|
||||
owner_.connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected;
|
||||
return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -676,6 +695,9 @@ void SessionDeviceManager::PushAudio(const std::string& remote_id,
|
||||
}
|
||||
|
||||
void SessionDeviceManager::UpdateInteractions() {
|
||||
// Restore the previous session's modes and apply the new controller's saved
|
||||
// selections before starting capture or allowing an early input return.
|
||||
owner_.HandleDisplayResolutionChanges();
|
||||
// Release even when pending remote-input cleanup makes this tick return early.
|
||||
UpdateScreenSharingPower();
|
||||
#ifdef _WIN32
|
||||
@@ -702,14 +724,21 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
#endif
|
||||
|
||||
if (owner_.start_screen_capturer_ && !owner_.screen_capturer_is_started_) {
|
||||
if (StartScreenCapturer() == 0) {
|
||||
owner_.screen_capturer_is_started_ = true;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= next_capture_start_retry_ &&
|
||||
owner_.DisplayResolutionsReadyForCapture()) {
|
||||
next_capture_start_retry_ = now + std::chrono::seconds(1);
|
||||
if (StartScreenCapturer() == 0) {
|
||||
owner_.screen_capturer_is_started_ = true;
|
||||
next_capture_start_retry_ = {};
|
||||
}
|
||||
}
|
||||
} else if (!owner_.start_screen_capturer_ &&
|
||||
owner_.screen_capturer_is_started_) {
|
||||
StopScreenCapturer();
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
}
|
||||
if (!owner_.start_screen_capturer_) next_capture_start_retry_ = {};
|
||||
|
||||
UpdateScreenSharingPower();
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ public:
|
||||
return remote_mouse_buttons_.Apply(remote_id, flag, std::move(send));
|
||||
}
|
||||
#ifdef _WIN32
|
||||
bool OwnsNativeCursor() const;
|
||||
void SetNativeCursorPolicy(bool default_enabled, bool current_enabled);
|
||||
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
|
||||
#endif
|
||||
// Requests are asynchronous; Stop drains/revokes audio delivery before
|
||||
@@ -145,6 +147,7 @@ private:
|
||||
#endif
|
||||
std::chrono::steady_clock::time_point last_frame_time_{};
|
||||
std::chrono::steady_clock::time_point next_frame_deadline_{};
|
||||
std::chrono::steady_clock::time_point next_capture_start_retry_{};
|
||||
std::mutex capture_metrics_mutex_;
|
||||
std::chrono::steady_clock::time_point capture_metrics_started_{};
|
||||
std::chrono::steady_clock::time_point last_capture_callback_{};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "diagnostic_export.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "common/private_file.h"
|
||||
|
||||
namespace crossdesk::feedback {
|
||||
namespace {
|
||||
constexpr size_t kMaxSource = 8 * 1024 * 1024;
|
||||
|
||||
std::string TimeText(std::time_t value, const char* format, bool utc = false) {
|
||||
std::tm time{};
|
||||
#ifdef _WIN32
|
||||
if (utc)
|
||||
gmtime_s(&time, &value);
|
||||
else
|
||||
localtime_s(&time, &value);
|
||||
#else
|
||||
if (utc)
|
||||
gmtime_r(&value, &time);
|
||||
else
|
||||
localtime_r(&value, &time);
|
||||
#endif
|
||||
std::ostringstream out;
|
||||
out << std::put_time(&time, format);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
std::string name, text;
|
||||
uint32_t crc = 0, offset = 0;
|
||||
};
|
||||
|
||||
void Put(std::string& out, uint32_t value, int bytes) {
|
||||
for (int i = 0; i < bytes; ++i) out += static_cast<char>(value >> (i * 8));
|
||||
}
|
||||
uint32_t Crc32(std::string_view text) {
|
||||
static const auto table = [] {
|
||||
std::array<uint32_t, 256> values{};
|
||||
for (uint32_t i = 0; i < values.size(); ++i) {
|
||||
uint32_t value = i;
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
value = (value >> 1) ^ ((value & 1) ? 0xedb88320u : 0u);
|
||||
values[i] = value;
|
||||
}
|
||||
return values;
|
||||
}();
|
||||
uint32_t crc = 0xffffffffu;
|
||||
for (unsigned char byte : text) crc = table[(crc ^ byte) & 255] ^ (crc >> 8);
|
||||
return crc ^ 0xffffffffu;
|
||||
}
|
||||
|
||||
// A bounded, stored ZIP needs neither ZIP64 nor an external archiver. Fixed
|
||||
// ASCII entry names prevent exported paths from disclosing local directories.
|
||||
std::string Zip(std::vector<Entry>& entries) {
|
||||
std::string out;
|
||||
for (auto& entry : entries) {
|
||||
entry.offset = static_cast<uint32_t>(out.size());
|
||||
entry.crc = Crc32(entry.text);
|
||||
Put(out, 0x04034b50, 4);
|
||||
Put(out, 20, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 33, 2);
|
||||
Put(out, entry.crc, 4);
|
||||
Put(out, entry.text.size(), 4);
|
||||
Put(out, entry.text.size(), 4);
|
||||
Put(out, entry.name.size(), 2);
|
||||
Put(out, 0, 2);
|
||||
out += entry.name;
|
||||
out += entry.text;
|
||||
}
|
||||
const auto start = out.size();
|
||||
for (const auto& entry : entries) {
|
||||
Put(out, 0x02014b50, 4);
|
||||
Put(out, 20, 2);
|
||||
Put(out, 20, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 33, 2);
|
||||
Put(out, entry.crc, 4);
|
||||
Put(out, entry.text.size(), 4);
|
||||
Put(out, entry.text.size(), 4);
|
||||
Put(out, entry.name.size(), 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 4);
|
||||
Put(out, entry.offset, 4);
|
||||
out += entry.name;
|
||||
}
|
||||
const auto size = out.size() - start;
|
||||
Put(out, 0x06054b50, 4);
|
||||
Put(out, 0, 2);
|
||||
Put(out, 0, 2);
|
||||
Put(out, entries.size(), 2);
|
||||
Put(out, entries.size(), 2);
|
||||
Put(out, size, 4);
|
||||
Put(out, start, 4);
|
||||
Put(out, 0, 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string Platform() {
|
||||
#if defined(_WIN32)
|
||||
return "Windows";
|
||||
#elif defined(__APPLE__)
|
||||
return "macOS";
|
||||
#elif defined(__OHOS__)
|
||||
return "HarmonyOS";
|
||||
#else
|
||||
return "Linux";
|
||||
#endif
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string DiagnosticFileName() {
|
||||
return "CrossDesk-diagnostics-" +
|
||||
TimeText(std::time(nullptr), "%Y%m%d-%H%M%S") + ".zip";
|
||||
}
|
||||
|
||||
ExportResult ExportDiagnostics(const std::filesystem::path& application_logs,
|
||||
const std::filesystem::path& transport_logs,
|
||||
const std::filesystem::path& destination,
|
||||
const std::string& version, std::time_t now) {
|
||||
std::vector<Entry> entries;
|
||||
try {
|
||||
for (const auto& [directory, name] :
|
||||
{std::pair{application_logs, std::string("crossdesk.log")},
|
||||
std::pair{transport_logs, std::string("minirtc.log")}}) {
|
||||
const auto path = directory / name;
|
||||
std::error_code error;
|
||||
const auto status = std::filesystem::symlink_status(path, error);
|
||||
if (error == std::errc::no_such_file_or_directory ||
|
||||
(!error && status.type() == std::filesystem::file_type::not_found))
|
||||
continue;
|
||||
if (error || !std::filesystem::is_regular_file(status))
|
||||
return {ExportError::read_failed};
|
||||
std::ifstream input(path, std::ios::binary | std::ios::ate);
|
||||
if (!input || input.tellg() < 0) return {ExportError::read_failed};
|
||||
const auto size = static_cast<size_t>(input.tellg());
|
||||
// Both rotating loggers normally cap each active file at 5 MiB. Keep
|
||||
// the complete archive below GitHub's attachment limit without silently
|
||||
// truncating an unexpectedly large log.
|
||||
if (size > kMaxSource) return {ExportError::too_large};
|
||||
input.seekg(0);
|
||||
std::string data(size, '\0');
|
||||
input.read(data.data(), static_cast<std::streamsize>(size));
|
||||
if (input.bad() || static_cast<size_t>(input.gcount()) != size)
|
||||
return {ExportError::read_failed};
|
||||
if (!data.empty()) entries.push_back({name, std::move(data)});
|
||||
}
|
||||
} catch (...) {
|
||||
return {ExportError::read_failed};
|
||||
}
|
||||
if (entries.empty()) return {ExportError::no_logs};
|
||||
std::string info =
|
||||
"CrossDesk diagnostics\nVersion: " + version +
|
||||
"\nPlatform: " + Platform() +
|
||||
"\nProcess bits: " + std::to_string(sizeof(void*) * 8) +
|
||||
"\nExported UTC: " + TimeText(now, "%Y-%m-%d %H:%M:%S", true) +
|
||||
"\nActive log files are included as-is, without filtering or redaction.\n"
|
||||
"Configuration files and screen previews are not included.\n"
|
||||
"Review the logs before sharing. GitHub issue attachments are public.\n";
|
||||
entries.insert(entries.begin(), {"environment.txt", std::move(info)});
|
||||
try {
|
||||
private_file::Write(destination, Zip(entries));
|
||||
} catch (...) {
|
||||
return {ExportError::write_failed};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace crossdesk::feedback
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DIAGNOSTIC_EXPORT_H_
|
||||
#define _DIAGNOSTIC_EXPORT_H_
|
||||
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::feedback {
|
||||
|
||||
enum class ExportError { none, no_logs, read_failed, write_failed, too_large };
|
||||
struct ExportResult {
|
||||
ExportError error = ExportError::none;
|
||||
};
|
||||
|
||||
// Exports byte-for-byte snapshots of the active application and transport
|
||||
// logs, plus basic environment information. No account configuration is read.
|
||||
ExportResult ExportDiagnostics(const std::filesystem::path& application_logs,
|
||||
const std::filesystem::path& transport_logs,
|
||||
const std::filesystem::path& destination,
|
||||
const std::string& version,
|
||||
std::time_t now = std::time(nullptr));
|
||||
std::string DiagnosticFileName();
|
||||
|
||||
} // namespace crossdesk::feedback
|
||||
|
||||
#endif
|
||||
@@ -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(¢er);
|
||||
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 {
|
||||
|
||||
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(); }
|
||||
|
||||
void FileTransferManager::Shutdown() {
|
||||
browser_.Stop();
|
||||
std::vector<std::future<void>> workers;
|
||||
{
|
||||
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] {
|
||||
auto& state = state_for(props);
|
||||
const auto id = file.file_id;
|
||||
PeerPtr* peer;
|
||||
MiniRtcPeerPtr* peer;
|
||||
std::shared_ptr<PeerEventHandler> events;
|
||||
{
|
||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||
@@ -105,7 +126,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
||||
auto callback = events->EnterCallback();
|
||||
if (!callback || !events->DataAllowed()) return -1;
|
||||
const auto wire_id = events->WireId(file.remote_id);
|
||||
return SendReliableDataFrameToPeer(
|
||||
return PeerSendReliableDataFrameToPeer(
|
||||
peer, data, size, file.file_label.c_str(),
|
||||
wire_id.data(), wire_id.size());
|
||||
}, file_transfer::Session::Progress{});
|
||||
@@ -147,6 +168,18 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
||||
uint64_t previous = UINT64_MAX;
|
||||
auto progress = file_transfer::SendState{id, 0, 0,
|
||||
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);
|
||||
PeerSendReliableDataFrameToPeer(peer, packet.data(), packet.size(),
|
||||
file.file_label.c_str(), wire_id.data(), wire_id.size());
|
||||
}
|
||||
}
|
||||
if (started) {
|
||||
do {
|
||||
{
|
||||
@@ -288,6 +321,7 @@ void FileTransferManager::UpdateReceiveProgress(
|
||||
info.file_id = file_transfer::Session::NextFileId();
|
||||
info.remote_id = remote_id;
|
||||
info.receiving = true;
|
||||
info.wire_file_id = progress.id;
|
||||
receive_rate_samples_.emplace(info.file_id,
|
||||
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
|
||||
found = notifications.emplace(progress.id, files.size()).first;
|
||||
@@ -310,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) {
|
||||
const auto props = owner_.FindRemoteSession(remote);
|
||||
PeerPtr* peer;
|
||||
MiniRtcPeerPtr* peer;
|
||||
std::shared_ptr<PeerEventHandler> events;
|
||||
{
|
||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||
@@ -324,14 +358,14 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
||||
events = owner_.ControllerEvents(remote);
|
||||
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::filesystem::path default_directory;
|
||||
{
|
||||
std::lock_guard lock(sessions_mutex_);
|
||||
const auto closed = disconnected_.find(handler);
|
||||
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];
|
||||
if (!entry.session) {
|
||||
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
|
||||
@@ -348,7 +382,7 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
||||
if (!callback || !events->DataAllowed()) return -1;
|
||||
const auto bytes = EncodeFileTransferAck(ack);
|
||||
const auto wire_id = events->WireId(remote);
|
||||
return SendReliableDataFrameToPeer(
|
||||
return PeerSendReliableDataFrameToPeer(
|
||||
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
||||
wire_id.data(), wire_id.size());
|
||||
});
|
||||
@@ -358,7 +392,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
||||
}
|
||||
const auto configured = owner_.config_center_->GetFileTransferSavePath();
|
||||
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) {
|
||||
@@ -371,6 +410,17 @@ void FileTransferManager::Connected(PeerEventHandler* handler, const std::string
|
||||
}
|
||||
|
||||
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_);
|
||||
disconnected_[handler].insert(remote);
|
||||
for (auto& [id, sender] : senders_)
|
||||
@@ -411,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 PeerSendReliableDataFrameToPeer(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
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "file_transfer.h"
|
||||
#include <file_browser.h>
|
||||
#include "runtime/remote_session.h"
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -40,6 +41,15 @@ class FileTransferManager {
|
||||
bool CancelSend(uint32_t file_id);
|
||||
void Poll();
|
||||
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:
|
||||
struct Sender {
|
||||
@@ -53,6 +63,8 @@ class FileTransferManager {
|
||||
std::filesystem::path default_directory;
|
||||
};
|
||||
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,
|
||||
const file_transfer::SendState& progress,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
@@ -76,6 +88,11 @@ class FileTransferManager {
|
||||
FileTransferState received_state_;
|
||||
// Guarded by received_state_.file_transfer_list_mutex_.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -45,7 +45,7 @@ struct WindowState {
|
||||
}
|
||||
}
|
||||
|
||||
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
|
||||
bool RecoverSizeMismatch(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool visible,
|
||||
bool minimized, bool fullscreen, bool maximized,
|
||||
float compact_height = 486,
|
||||
@@ -53,8 +53,16 @@ struct WindowState {
|
||||
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
|
||||
// Leave those alone, and let an in-flight restoration retain its target.
|
||||
if (Restoring() || !visible || minimized || fullscreen || maximized ||
|
||||
size.width == 0 || size.height == 0 ||
|
||||
!IsUndersized(size, scale, compact_height, compact_width))
|
||||
size.width == 0 || size.height == 0)
|
||||
return false;
|
||||
// Display mode/DPI changes can enlarge the fixed connection window as
|
||||
// well as shrink it. Restore both directions at the current scale; the
|
||||
// resizable gallery only needs recovery below its minimum dimensions.
|
||||
const bool mismatch =
|
||||
wall ? IsUndersized(size, scale, compact_height, compact_width)
|
||||
: !SameSize(size, RestoreSize(false, scale, compact_height,
|
||||
compact_width));
|
||||
if (!mismatch)
|
||||
return false;
|
||||
if (wall) {
|
||||
wall_position = position;
|
||||
|
||||
@@ -379,8 +379,8 @@ bool KeyboardController::ReleaseRemotePressedKeysLocked(
|
||||
if (state_it != remote_states_.end()) {
|
||||
const auto& keys = state_it->second.pressed_keys;
|
||||
if (!keys.empty()) {
|
||||
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
|
||||
keys.size(), remote_id, reason ? reason : "unknown");
|
||||
LOG_WARN("Releasing {} remote keyboard keys, reason={}", keys.size(),
|
||||
reason ? reason : "unknown");
|
||||
}
|
||||
// Stage before erasing: subsequent dispatcher retries must still see the
|
||||
// keys if injection fails, even though active/heartbeat state is gone.
|
||||
|
||||
@@ -39,14 +39,14 @@ void RemoteInputDispatcher::Connect(const std::string& id) {
|
||||
}
|
||||
|
||||
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.
|
||||
if (!Accepts(action)) return Result::rejected;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_ || !connected_.count(id)) return Result::rejected;
|
||||
if (IsMove(action) && !pending_.empty()) {
|
||||
if (!completion && IsMove(action) && !pending_.empty()) {
|
||||
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.queued_at = Clock::now();
|
||||
return Result::coalesced;
|
||||
@@ -56,7 +56,7 @@ RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
|
||||
QueueReleaseLocked(id);
|
||||
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];
|
||||
ready_.notify_one();
|
||||
return Result::queued;
|
||||
@@ -328,6 +328,9 @@ void RemoteInputDispatcher::Run() {
|
||||
!pending_counts_.count(item.id))
|
||||
logs_.erase(item.id);
|
||||
lock.unlock();
|
||||
if (item.completion) {
|
||||
try { item.completion(outcome); } catch (...) {}
|
||||
}
|
||||
if (report) {
|
||||
try {
|
||||
report_(item.id, queued_ms, execution_ms, outcome);
|
||||
|
||||
@@ -38,6 +38,7 @@ class RemoteInputDispatcher {
|
||||
// Optional targeted adapter; legacy callers fall back to full Release.
|
||||
using ReleaseMouse =
|
||||
std::function<InputOutcome(const std::string&, unsigned)>;
|
||||
using Completion = std::function<void(InputOutcome)>;
|
||||
enum class Result { queued, coalesced, rejected, overflow };
|
||||
static constexpr size_t kMaxPendingPerController = 128;
|
||||
|
||||
@@ -47,7 +48,8 @@ class RemoteInputDispatcher {
|
||||
~RemoteInputDispatcher();
|
||||
static bool Accepts(const RemoteAction& action);
|
||||
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);
|
||||
|
||||
// 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
|
||||
// buttons.
|
||||
unsigned mouse_mask = 0;
|
||||
Completion completion;
|
||||
bool IsReset() const { return kind == ItemKind::reset_display; }
|
||||
bool IsInput() const { return kind == ItemKind::input; }
|
||||
bool IsCleanup() const { return kind == ItemKind::cleanup; }
|
||||
@@ -120,4 +123,4 @@ class RemoteInputDispatcher {
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _STREAM_KEYBOARD_FOCUS_H_
|
||||
#define _STREAM_KEYBOARD_FOCUS_H_
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Native window focus and ownership of keyboard input are different: a local
|
||||
// menu keeps the window active while temporarily suspending remote input.
|
||||
// Publish the new state before releasing input, since UI callbacks can reenter.
|
||||
template <typename Release>
|
||||
void UpdateStreamKeyboardFocus(bool focused, bool local_input_active,
|
||||
bool& remote_focused, Release release) {
|
||||
focused = focused && !local_input_active;
|
||||
if (remote_focused == focused) return;
|
||||
remote_focused = focused;
|
||||
if (!focused) release(local_input_active);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "remote_resolution_store.h"
|
||||
|
||||
#include <fstream>
|
||||
|
||||
#include <display_resolution_preferences.h>
|
||||
|
||||
#include "common/private_file.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
constexpr std::uintmax_t kMaxStoreBytes = 8 * 1024 * 1024;
|
||||
}
|
||||
|
||||
bool RemoteResolutionStore::Load(const std::filesystem::path &path) {
|
||||
std::lock_guard lock(mutex_);
|
||||
path_ = path;
|
||||
devices_.clear();
|
||||
try {
|
||||
if (!std::filesystem::exists(path_)) return true;
|
||||
if (std::filesystem::file_size(path_) > kMaxStoreBytes) return false;
|
||||
std::ifstream input(path_, std::ios::binary);
|
||||
const auto json = nlohmann::json::parse(input);
|
||||
if (json.at("version") != 1 || !json.at("devices").is_object())
|
||||
return false;
|
||||
for (const auto &[device, values] : json.at("devices").items()) {
|
||||
std::vector<DisplayResolutionPreference> preferences;
|
||||
if (!device.empty() && ReadDisplayResolutionPreferences(values, preferences))
|
||||
devices_.emplace(device, std::move(preferences));
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception &) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<DisplayResolutionPreference>
|
||||
RemoteResolutionStore::Get(const std::string &device) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto found = devices_.find(device);
|
||||
return found == devices_.end() ? std::vector<DisplayResolutionPreference>{}
|
||||
: found->second;
|
||||
}
|
||||
|
||||
bool RemoteResolutionStore::Remember(
|
||||
const std::string &device, const DisplayResolutionPreference &preference) {
|
||||
if (device.empty() || !ValidDisplayResolutionPreference(preference))
|
||||
return false;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (path_.empty()) return false;
|
||||
auto updated = devices_;
|
||||
auto &preferences = updated[device];
|
||||
const auto found = std::find_if(preferences.begin(), preferences.end(),
|
||||
[&](const auto &saved) { return saved.display_key == preference.display_key; });
|
||||
if (found != preferences.end()) {
|
||||
if (found->resolution == preference.resolution) return true;
|
||||
*found = preference;
|
||||
} else {
|
||||
if (preferences.size() >= kMaxResolutionDisplays)
|
||||
preferences.erase(preferences.begin());
|
||||
preferences.push_back(preference);
|
||||
}
|
||||
try {
|
||||
nlohmann::json json{{"version", 1}, {"devices", nlohmann::json::object()}};
|
||||
for (const auto &[id, saved] : updated)
|
||||
json["devices"][id] = DisplayResolutionPreferencesJson(saved);
|
||||
const auto text = json.dump(2);
|
||||
if (text.size() > kMaxStoreBytes) return false;
|
||||
private_file::EnsureDirectory(path_.parent_path());
|
||||
private_file::Write(path_, text);
|
||||
devices_ = std::move(updated);
|
||||
return true;
|
||||
} catch (const std::exception &) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _REMOTE_RESOLUTION_STORE_H_
|
||||
#define _REMOTE_RESOLUTION_STORE_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
#include <display_resolution.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class RemoteResolutionStore {
|
||||
public:
|
||||
bool Load(const std::filesystem::path &path);
|
||||
std::vector<DisplayResolutionPreference> Get(const std::string &device) const;
|
||||
bool Remember(const std::string &device,
|
||||
const DisplayResolutionPreference &preference);
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::filesystem::path path_;
|
||||
std::map<std::string, std::vector<DisplayResolutionPreference>> devices_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -34,9 +34,6 @@
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kCacheV3Magic = 0x33444358; // "XCD3"
|
||||
constexpr uint32_t kCacheV3Version = 3;
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
constexpr const char *kCredentialCacheNames[] = {
|
||||
"secure_cache_v3.enc", "secure_cache_v2.enc", "secure_cache.enc"};
|
||||
@@ -63,8 +60,7 @@ bool RestrictCachePermissions(const std::filesystem::path &path,
|
||||
close(fd);
|
||||
}
|
||||
if (error) {
|
||||
LOG_WARN("Cannot restrict credential cache permissions for {}: {}",
|
||||
path.string(),
|
||||
LOG_WARN("Cannot restrict credential cache permissions: {}",
|
||||
std::error_code(error, std::generic_category()).message());
|
||||
}
|
||||
return error == 0;
|
||||
@@ -132,16 +128,6 @@ void CopyString(char (&destination)[Size], const char *source) {
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t CacheChecksum(const void *data, size_t size) {
|
||||
const auto *bytes = static_cast<const unsigned char *>(data);
|
||||
uint32_t hash = 2166136261u;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
hash ^= bytes[i];
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
std::filesystem::path TemporaryPathFor(
|
||||
const std::filesystem::path &target) {
|
||||
const auto timestamp = std::chrono::steady_clock::now()
|
||||
@@ -259,6 +245,15 @@ bool WriteFileAtomically(const std::filesystem::path &target,
|
||||
|
||||
SettingsManager::SettingsManager(GuiRuntime &owner) : owner_(owner) {}
|
||||
|
||||
bool SettingsManager::ApplyHostIdentity(const std::string& identity, bool self_hosted) {
|
||||
if (identity.size() != 16 || identity[9] != '@' ||
|
||||
identity.substr(0, 9).find_first_not_of("0123456789") != std::string::npos)
|
||||
return false;
|
||||
std::lock_guard lock(cache_mutex_);
|
||||
ActivateIdentity(identity.c_str(), self_hosted);
|
||||
return SaveLocked() == 0;
|
||||
}
|
||||
|
||||
int SettingsManager::Save() {
|
||||
std::lock_guard<std::mutex> lock(cache_mutex_);
|
||||
return SaveLocked();
|
||||
@@ -271,8 +266,8 @@ int SettingsManager::SaveLocked() {
|
||||
std::memcpy(cache_v2_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_));
|
||||
CopyString(cache_v2_.self_hosted_id, owner_.self_hosted_id_);
|
||||
|
||||
cache_v3_.magic = kCacheV3Magic;
|
||||
cache_v3_.version = kCacheV3Version;
|
||||
cache_v3_.magic = credential_cache::kMagic;
|
||||
cache_v3_.version = credential_cache::kVersion;
|
||||
cache_v3_.base = cache_v2_;
|
||||
cache_v3_.pending_identity[sizeof(cache_v3_.pending_identity) - 1] = '\0';
|
||||
cache_v3_.pending_server_host[sizeof(cache_v3_.pending_server_host) - 1] =
|
||||
@@ -280,7 +275,7 @@ int SettingsManager::SaveLocked() {
|
||||
cache_v3_.pending_request_id[sizeof(cache_v3_.pending_request_id) - 1] =
|
||||
'\0';
|
||||
cache_v3_.checksum =
|
||||
CacheChecksum(&cache_v3_, offsetof(CacheV3, checksum));
|
||||
credential_cache::Checksum(&cache_v3_, offsetof(CacheV3, checksum));
|
||||
|
||||
if (!WriteFileAtomically(owner_.cache_path_ + "/secure_cache_v3.enc",
|
||||
&cache_v3_, sizeof(cache_v3_))) {
|
||||
@@ -302,7 +297,7 @@ int SettingsManager::SaveLocked() {
|
||||
LOG_WARN("Failed to update legacy v1 credential cache");
|
||||
}
|
||||
|
||||
return 0;
|
||||
return owner_.PublishSharedIdentity() ? 0 : -1;
|
||||
}
|
||||
|
||||
bool SettingsManager::ReadV3Locked() {
|
||||
@@ -325,9 +320,7 @@ bool SettingsManager::ReadV3Locked() {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
if (loaded.magic != kCacheV3Magic ||
|
||||
loaded.version != kCacheV3Version ||
|
||||
loaded.checksum != CacheChecksum(&loaded, offsetof(CacheV3, checksum))) {
|
||||
if (!credential_cache::Valid(loaded)) {
|
||||
LOG_WARN("Ignore invalid v3 credential cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <unordered_map>
|
||||
|
||||
#include "thumbnail.h"
|
||||
#include "common/credential_cache.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -21,6 +22,7 @@ public:
|
||||
int Save();
|
||||
int Load();
|
||||
std::string ActiveIdentity() const;
|
||||
bool ApplyHostIdentity(const std::string& identity, bool self_hosted);
|
||||
|
||||
int LoadRecentConnectionAliases();
|
||||
int SaveRecentConnectionAliases() const;
|
||||
@@ -56,46 +58,9 @@ public:
|
||||
bool ClearPendingPasswordChange();
|
||||
|
||||
private:
|
||||
struct CacheV1 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
unsigned char iv[16];
|
||||
};
|
||||
|
||||
struct CacheV2 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
unsigned char iv[16];
|
||||
char self_hosted_id[17];
|
||||
};
|
||||
|
||||
struct CacheV3 {
|
||||
uint32_t magic;
|
||||
uint32_t version;
|
||||
CacheV2 base;
|
||||
char pending_identity[17];
|
||||
char pending_server_host[256];
|
||||
int pending_server_port;
|
||||
bool pending_self_hosted;
|
||||
char pending_request_id[64];
|
||||
uint32_t checksum;
|
||||
};
|
||||
using CacheV1 = credential_cache::CacheV1;
|
||||
using CacheV2 = credential_cache::CacheV2;
|
||||
using CacheV3 = credential_cache::CacheV3;
|
||||
|
||||
int SaveLocked();
|
||||
bool ReadV3Locked();
|
||||
|
||||
@@ -88,6 +88,11 @@ struct SlintVideoPresenter::Impl {
|
||||
VideoRenderSize gl_pending_size;
|
||||
bool gl_pending_dirty = false;
|
||||
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
|
||||
|
||||
SurfaceState CurrentState() const {
|
||||
@@ -125,6 +130,13 @@ struct SlintVideoPresenter::Impl {
|
||||
auto* opengl_renderer =
|
||||
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
||||
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;
|
||||
glGenTextures(1, &texture);
|
||||
if (texture == 0) {
|
||||
@@ -165,9 +177,28 @@ struct SlintVideoPresenter::Impl {
|
||||
rounded_window ? static_cast<int>(std::lround(
|
||||
kStreamWindowCornerRadius * scale_factor))
|
||||
: 0;
|
||||
opengl_renderer->RenderLatest(
|
||||
const auto outcome = opengl_renderer->RenderLatest(
|
||||
surface.selected_stream, static_cast<int>(window_size.width),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -211,6 +242,8 @@ struct SlintVideoPresenter::Impl {
|
||||
gl_pending_size = {};
|
||||
gl_pending_dirty = false;
|
||||
gl_pending_frame.clear();
|
||||
gl_last_outcome = {};
|
||||
gl_rendered_stream.clear();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -239,6 +272,7 @@ void SlintVideoPresenter::PrepareWindow(
|
||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||
impl_->skip_attachment_once = true;
|
||||
#elif !defined(__OHOS__)
|
||||
stream->set_native_video_enabled(false);
|
||||
impl_->ConfigureOpenGlNotifier();
|
||||
#endif
|
||||
}
|
||||
@@ -313,7 +347,12 @@ bool SlintVideoPresenter::SelectStream(std::string remote_id) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!impl_->stream || frame.remote_id.empty()) {
|
||||
return result;
|
||||
@@ -355,25 +394,54 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
auto* opengl_renderer =
|
||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||
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()) {
|
||||
(*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 =
|
||||
frame.width > 0 && frame.height > 0 && frame.sequence > 0;
|
||||
outcome.result == VideoRenderer::RenderResult::rendered;
|
||||
(*impl_->stream)->set_has_frame(has_frame);
|
||||
if (has_frame) {
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
result.width = frame.width;
|
||||
result.height = frame.height;
|
||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
||||
result.width = outcome.width;
|
||||
result.height = outcome.height;
|
||||
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;
|
||||
}
|
||||
if ((*impl_->stream)->get_native_video_enabled()) {
|
||||
(*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
|
||||
|
||||
@@ -423,7 +491,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
uint32_t texture_id = 0;
|
||||
{
|
||||
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) {
|
||||
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace crossdesk {
|
||||
class VideoLatencyStats;
|
||||
|
||||
// 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 {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
std::shared_ptr<VideoLatencyStats> stats;
|
||||
|
||||
Loaded 100 of 383 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user