mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 14:49:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fc1529ee2b | ||
|
|
e1ae449d36 | ||
|
|
f57b4cc4e3 |
@@ -5,6 +5,9 @@ inputs:
|
|||||||
minimum-version:
|
minimum-version:
|
||||||
description: Minimum supported Xmake version (major.minor.patch).
|
description: Minimum supported Xmake version (major.minor.patch).
|
||||||
required: true
|
required: true
|
||||||
|
cache-key:
|
||||||
|
description: Host environment identity, including the image when running in a container.
|
||||||
|
default: host
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
@@ -16,11 +19,19 @@ runs:
|
|||||||
XMAKE_MIN_VERSION: ${{ inputs.minimum-version }}
|
XMAKE_MIN_VERSION: ${{ inputs.minimum-version }}
|
||||||
run: bash "$GITHUB_ACTION_PATH/check-version.sh"
|
run: bash "$GITHUB_ACTION_PATH/check-version.sh"
|
||||||
|
|
||||||
|
- name: Identify Xmake cache environment
|
||||||
|
if: steps.check.outputs.needs-upgrade == 'true'
|
||||||
|
id: cache-environment
|
||||||
|
shell: bash
|
||||||
|
run: printf 'image=%s-%s\n' "${ImageOS:-unknown}" "${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Install or upgrade Xmake
|
- name: Install or upgrade Xmake
|
||||||
if: steps.check.outputs.needs-upgrade == 'true'
|
if: steps.check.outputs.needs-upgrade == 'true'
|
||||||
uses: xmake-io/github-action-setup-xmake@v1
|
uses: xmake-io/github-action-setup-xmake@v1
|
||||||
with:
|
with:
|
||||||
xmake-version: ${{ inputs.minimum-version }}
|
xmake-version: ${{ inputs.minimum-version }}
|
||||||
|
actions-cache-folder: ${{ runner.temp }}/crossdesk-xmake-toolchain
|
||||||
|
actions-cache-key: crossdesk-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.cache-environment.outputs.image }}-${{ inputs.cache-key }}
|
||||||
|
|
||||||
- name: Verify Xmake meets the minimum version
|
- name: Verify Xmake meets the minimum version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -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"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
|
id: python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
@@ -90,7 +91,9 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}"
|
sudo apt-get install -y --no-install-recommends "${crossdesk_missing_packages[@]}"
|
||||||
fi
|
fi
|
||||||
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,
|
# Use an isolated SDK directory so the cache contains only this toolchain,
|
||||||
# rather than every SDK/NDK version preinstalled on the runner.
|
# rather than every SDK/NDK version preinstalled on the runner.
|
||||||
@@ -134,13 +137,40 @@ jobs:
|
|||||||
actions-cache-folder: .xmake-toolchain-cache
|
actions-cache-folder: .xmake-toolchain-cache
|
||||||
actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }}
|
actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }}
|
||||||
|
|
||||||
# Repository selection follows desktop Xmake behavior. Keep installed
|
- name: Set native dependency cache key
|
||||||
# packages isolated; never reuse another platform's build/config.
|
id: native-cache-key
|
||||||
- name: Cache native dependencies
|
env:
|
||||||
uses: actions/cache@v5
|
CACHE_ENVIRONMENT: ${{ runner.os }}-${{ runner.arch }}-android-arm64-native-v2-ndk28.2.13676358-api26-xmake${{ env.XMAKE_MIN_VERSION }}-python${{ steps.python.outputs.python-version }}
|
||||||
|
RECIPE_HASH: ${{ hashFiles('apps/android/scripts/build_native.py', 'deps/submodules/minirtc/thirdparty/**') }}
|
||||||
|
DEPENDENCY_HASH: ${{ hashFiles('apps/android/native/xmake.lua', 'deps/submodules/minirtc/xmake.lua') }}
|
||||||
|
run: |
|
||||||
|
prefix="${CACHE_ENVIRONMENT}-image${ImageVersion:?Missing GitHub runner image version}-${RECIPE_HASH}-"
|
||||||
|
printf 'prefix=%s\nkey=%s%s\n' "$prefix" "$prefix" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
|
||||||
|
# Historical source_tag checkouts keep exact-only dependency reuse.
|
||||||
|
if grep -q -- '--configure-only' apps/android/scripts/build_native.py && \
|
||||||
|
grep -q 'package.librarydeps.strict_compatibility' apps/android/scripts/build_native.py; then
|
||||||
|
echo 'incremental=true' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cache installed packages separately from native targets and Xmake state.
|
||||||
|
- name: Restore native dependencies
|
||||||
|
id: native-cache
|
||||||
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
path: apps/android/.native/packages
|
path: apps/android/.native/packages
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-arm64-native-v1-${{ hashFiles('apps/android/scripts/build_native.py', 'apps/android/native/xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}
|
key: ${{ steps.native-cache-key.outputs.key }}
|
||||||
|
restore-keys: ${{ steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache-key.outputs.prefix || '' }}
|
||||||
|
|
||||||
|
- name: Prepare native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental == 'true'
|
||||||
|
run: python3 -B apps/android/scripts/build_native.py --configure-only
|
||||||
|
|
||||||
|
- name: Save native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v5
|
||||||
|
with:
|
||||||
|
path: apps/android/.native/packages
|
||||||
|
key: ${{ steps.native-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
@@ -185,6 +215,13 @@ jobs:
|
|||||||
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
|
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
|
||||||
:app:assembleDebug :app:assembleRelease
|
:app:assembleDebug :app:assembleRelease
|
||||||
|
|
||||||
|
- name: Save legacy native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental != 'true' && steps.native-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v5
|
||||||
|
with:
|
||||||
|
path: apps/android/.native/packages
|
||||||
|
key: ${{ steps.native-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Verify APKs
|
- name: Verify APKs
|
||||||
working-directory: apps/android
|
working-directory: apps/android
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -19,18 +19,23 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- runner: macos-15
|
- runner: macos-15
|
||||||
target: darwin-arm64
|
target: darwin-arm64
|
||||||
|
plat: macosx
|
||||||
arch: arm64
|
arch: arm64
|
||||||
- runner: macos-15-intel
|
- runner: macos-15-intel
|
||||||
target: darwin-x64
|
target: darwin-x64
|
||||||
|
plat: macosx
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
- runner: ubuntu-22.04
|
- runner: ubuntu-22.04
|
||||||
target: linux-x64
|
target: linux-x64
|
||||||
|
plat: linux
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
- runner: ubuntu-22.04-arm
|
- runner: ubuntu-22.04-arm
|
||||||
target: linux-arm64
|
target: linux-arm64
|
||||||
|
plat: linux
|
||||||
arch: arm64
|
arch: arm64
|
||||||
- runner: windows-2022
|
- runner: windows-2022
|
||||||
target: win32-x64
|
target: win32-x64
|
||||||
|
plat: windows
|
||||||
arch: x64
|
arch: x64
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
@@ -90,23 +95,31 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Prepare pinned dependency
|
- name: Prepare pinned dependency
|
||||||
run: sh apps/cli/scripts/prepare-deps.sh
|
run: sh apps/cli/scripts/prepare-deps.sh
|
||||||
# Exact matches only: changed local recipes/patches must not reuse old libraries.
|
# Use the runner's native shell for Xmake. Git Bash sets MSYSTEM on
|
||||||
# Source, README and npm metadata edits do not change this key.
|
# Windows, which makes host tools such as jom resolve for msys.
|
||||||
|
- name: Read native dependency configuration
|
||||||
|
id: dependency-config
|
||||||
|
run: xmake lua .github/scripts/cli-dependency-cache.lua "${{ matrix.plat }}" "${{ matrix.arch }}" "${{ runner.temp }}/crossdesk-cli-config.lua"
|
||||||
|
- name: Set native dependency cache key
|
||||||
|
id: dependency-key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_PREFIX: cli-deps-v2-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ steps.dependency-config.outputs.configuration }}-${{ hashFiles('deps/thirdparty/openssl/**', 'deps/submodules/minirtc/thirdparty/**') }}
|
||||||
|
DEPENDENCY_HASH: ${{ steps.dependency-config.outputs.dependencies }}
|
||||||
|
run: |
|
||||||
|
printf 'prefix=%s-\n' "$CACHE_PREFIX" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'key=%s-%s\n' "$CACHE_PREFIX" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
|
||||||
|
# Reuse installed packages across dependency declaration changes within the
|
||||||
|
# same toolchain, build settings and local recipes/patches.
|
||||||
- name: Restore native dependencies
|
- name: Restore native dependencies
|
||||||
id: native-cache
|
id: native-cache
|
||||||
uses: actions/cache/restore@v5
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
|
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
|
||||||
key: cli-deps-v1-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ hashFiles('xmake.lua', 'apps/cli/xmake.lua', 'apps/desktop/xmake/options.lua', 'deps/thirdparty/openssl/**', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**', '.github/workflows/build-cli.yml') }}
|
key: ${{ steps.dependency-key.outputs.key }}
|
||||||
- name: Configure macOS
|
restore-keys: ${{ steps.dependency-key.outputs.prefix }}
|
||||||
if: runner.os == 'macOS'
|
- name: Configure native dependencies
|
||||||
run: xmake f -y -p macosx -a ${{ matrix.arch }} -m release --target_minver=13.0 --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
run: xmake f -y --import="${{ runner.temp }}/crossdesk-cli-config.lua"
|
||||||
- name: Configure Linux
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: xmake f -y -p linux -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
|
||||||
- name: Configure Windows
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
run: xmake f -y -p windows -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
|
||||||
# xmake f installs required packages. Save before compiling or packaging the CLI,
|
# xmake f installs required packages. Save before compiling or packaging the CLI,
|
||||||
# so failures in those later steps do not discard successfully installed dependencies.
|
# so failures in those later steps do not discard successfully installed dependencies.
|
||||||
- name: Save native dependencies
|
- name: Save native dependencies
|
||||||
|
|||||||
@@ -155,30 +155,55 @@ jobs:
|
|||||||
- name: Initialize submodules
|
- name: Initialize submodules
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Cache xmake dependencies
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: apps/ios/.xmake/packages
|
|
||||||
key: "${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-${{ github.run_id }}"
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-
|
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
|
id: python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: .github/workflows/build-ios.yml
|
||||||
|
|
||||||
- name: Install Python build tools
|
- name: Install Python build tools
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Xmake only accepts the host Python when all three modules exist;
|
# Xmake only accepts the host Python when all three modules exist;
|
||||||
# otherwise it falls back to compiling another Python from source.
|
# otherwise it falls back to compiling another Python from source.
|
||||||
python3 -m pip install --upgrade pip setuptools wheel
|
if ! python3 -c 'import setuptools, wheel, pip' >/dev/null 2>&1; then
|
||||||
|
python3 -m pip install setuptools wheel
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set native dependency cache key
|
||||||
|
id: native-cache-key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_ENVIRONMENT: ${{ runner.os }}-${{ runner.arch }}-ios-arm64-source-v3-xcode26.6-min16-xmake${{ env.XMAKE_MIN_VERSION }}-python${{ steps.python.outputs.python-version }}
|
||||||
|
RECIPE_HASH: ${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'deps/submodules/minirtc/thirdparty/**') }}
|
||||||
|
DEPENDENCY_HASH: ${{ hashFiles('xmake.lua', 'deps/submodules/minirtc/xmake.lua') }}
|
||||||
|
run: |
|
||||||
|
image="${ImageVersion:?Missing GitHub runner image version}"
|
||||||
|
prefix="${CACHE_ENVIRONMENT}-image${image}-${RECIPE_HASH}-"
|
||||||
|
printf 'image=%s\nprefix=%s\nkey=%s%s\n' "$image" "$prefix" "$prefix" "$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
|
||||||
|
# Historical source_tag checkouts keep exact-only dependency reuse.
|
||||||
|
if grep -q -- '--configure-only' apps/ios/scripts/build_minirtc_ios.sh && \
|
||||||
|
grep -q 'package.librarydeps.strict_compatibility' apps/ios/scripts/build_minirtc_ios.sh; then
|
||||||
|
echo 'incremental=true' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install xmake
|
- name: Install xmake
|
||||||
uses: xmake-io/github-action-setup-xmake@v1
|
uses: xmake-io/github-action-setup-xmake@v1
|
||||||
with:
|
with:
|
||||||
xmake-version: "${{ env.XMAKE_MIN_VERSION }}"
|
xmake-version: "${{ env.XMAKE_MIN_VERSION }}"
|
||||||
|
actions-cache-folder: ${{ runner.temp }}/crossdesk-ios-xmake-toolchain
|
||||||
|
actions-cache-key: ios-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-image${{ steps.native-cache-key.outputs.image }}-xcode26.6
|
||||||
|
|
||||||
|
# Keep recipe/patch and toolchain changes outside the fallback boundary.
|
||||||
|
- name: Restore native dependencies
|
||||||
|
id: native-cache
|
||||||
|
uses: actions/cache/restore@v5
|
||||||
|
with:
|
||||||
|
path: apps/ios/.xmake/packages
|
||||||
|
key: ${{ steps.native-cache-key.outputs.key }}
|
||||||
|
restore-keys: ${{ steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache-key.outputs.prefix || '' }}
|
||||||
|
|
||||||
- name: Verify minimum xmake version
|
- name: Verify minimum xmake version
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -187,6 +212,27 @@ jobs:
|
|||||||
python3 -B apps/ios/scripts/verify_native_build.py \
|
python3 -B apps/ios/scripts/verify_native_build.py \
|
||||||
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
|
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
|
||||||
|
|
||||||
|
- name: Prepare native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental == 'true'
|
||||||
|
timeout-minutes: 60
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CONFIGURATION: Release
|
||||||
|
PLATFORM_NAME: iphoneos
|
||||||
|
CURRENT_ARCH: arm64
|
||||||
|
CROSSDESK_NATIVE_VERBOSE: "1"
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
apps/ios/scripts/build_minirtc_ios.sh --configure-only 2>&1 | \
|
||||||
|
tee "${RUNNER_TEMP}/ios-native-prepare.log"
|
||||||
|
|
||||||
|
- name: Save native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental == 'true' && steps.native-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v5
|
||||||
|
with:
|
||||||
|
path: apps/ios/.xmake/packages
|
||||||
|
key: ${{ steps.native-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
# Xcode buffers Run Script output until the phase finishes. Build the
|
# Xcode buffers Run Script output until the phase finishes. Build the
|
||||||
# native archives here so dependency downloads and compiler commands are
|
# native archives here so dependency downloads and compiler commands are
|
||||||
# streamed to Actions; the Xcode phase then reuses the same build products.
|
# streamed to Actions; the Xcode phase then reuses the same build products.
|
||||||
@@ -216,6 +262,13 @@ jobs:
|
|||||||
apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \
|
apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \
|
||||||
tee "${RUNNER_TEMP}/ios-native-build.log"
|
tee "${RUNNER_TEMP}/ios-native-build.log"
|
||||||
|
|
||||||
|
- name: Save legacy native dependencies
|
||||||
|
if: steps.native-cache-key.outputs.incremental != 'true' && steps.native-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v5
|
||||||
|
with:
|
||||||
|
path: apps/ios/.xmake/packages
|
||||||
|
key: ${{ steps.native-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Build CrossDesk Mobile
|
- name: Build CrossDesk Mobile
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -241,6 +294,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
|
${{ runner.temp }}/ios-native-prepare.log
|
||||||
${{ runner.temp }}/ios-native-build.log
|
${{ runner.temp }}/ios-native-build.log
|
||||||
${{ runner.temp }}/ios-app-build.log
|
${{ runner.temp }}/ios-app-build.log
|
||||||
~/.xmake/cache/packages/**/logs/*.txt
|
~/.xmake/cache/packages/**/logs/*.txt
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: MCP package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/mcp/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- run: npm run build
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- run: npm run pack:npm
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: mcp-package
|
||||||
|
path: build/npm-mcp/*.tgz
|
||||||
|
if-no-files-found: error
|
||||||
@@ -132,6 +132,7 @@ jobs:
|
|||||||
uses: ./.github/actions/ensure-xmake
|
uses: ./.github/actions/ensure-xmake
|
||||||
with:
|
with:
|
||||||
minimum-version: ${{ env.XMAKE_MIN_VERSION }}
|
minimum-version: ${{ env.XMAKE_MIN_VERSION }}
|
||||||
|
cache-key: ${{ matrix.image }}
|
||||||
|
|
||||||
- name: Verify Linux build image
|
- name: Verify Linux build image
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
|
|||||||
|
|
||||||
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存:
|
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存:
|
||||||
|
|
||||||
- Gradle 9.3.1、AGP 9.1.0、NDK r28c 固定在工程中。
|
- Gradle 9.9.0、AGP 9.1.0、NDK r28c 固定在工程中。
|
||||||
- Xmake 依赖仓库沿用 PC 端策略:使用默认仓库或调用环境指定的仓库,不固定或校验仓库提交。Android 编译产物和安装包缓存仍独立保存。
|
- Xmake 依赖仓库沿用 PC 端策略:使用默认仓库或调用环境指定的仓库,不固定或校验仓库提交。Android 编译产物和安装包缓存仍独立保存。
|
||||||
- `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。
|
- `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。
|
||||||
- MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。
|
- MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。
|
||||||
@@ -149,7 +149,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
|
|||||||
|
|
||||||
最近连接卡片与 iOS 一样,在设备 ID 旁显示已保存密码的钥匙图标和在线/离线状态,在线设备优先,同一状态内保留原来的最近连接顺序。授权并登录服务器后,通过身份信令订阅 `recent_connections_presence`,接收完整快照与 `presence_update`;每 30 秒刷新,状态超过 60 秒未更新则显示离线。断线、切换服务器或进入后台会清除在线状态,重连后等待新的快照。自托管服务需要支持相同的设备在线状态协议。
|
最近连接卡片与 iOS 一样,在设备 ID 旁显示已保存密码的钥匙图标和在线/离线状态,在线设备优先,同一状态内保留原来的最近连接顺序。授权并登录服务器后,通过身份信令订阅 `recent_connections_presence`,接收完整快照与 `presence_update`;每 30 秒刷新,状态超过 60 秒未更新则显示离线。断线、切换服务器或进入后台会清除在线状态,重连后等待新的快照。自托管服务需要支持相同的设备在线状态协议。
|
||||||
|
|
||||||
证书信任来自 Android 默认 TrustManager 导出的根证书。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器应使用受系统信任的证书。当前没有在应用内导入自签名证书的入口。
|
证书信任来自 Android 默认 TrustManager 导出的根证书,应用的 Network Security Configuration 显式信任系统 CA 和用户安装的 CA。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器可使用受系统信任的证书,或通过 Android 系统设置安装签发服务器证书的 CA。安装或移除 CA 后,重启 CrossDesk 再连接;服务器证书须在有效期内,且包含与连接地址匹配的域名或 IP。
|
||||||
|
|
||||||
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
plugins { id 'com.android.application' }
|
plugins { id 'com.android.application' }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'cn.crossdesk.mobile'
|
namespace = 'cn.crossdesk.mobile'
|
||||||
compileSdk 36
|
compileSdk = 36
|
||||||
ndkVersion '28.2.13676358'
|
ndkVersion = '28.2.13676358'
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'cn.crossdesk.mobile'
|
applicationId = 'cn.crossdesk.mobile'
|
||||||
minSdk 26
|
minSdk = 26
|
||||||
targetSdk 36
|
targetSdk = 36
|
||||||
versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger()
|
versionCode = providers.gradleProperty('crossdeskVersionCode').get().toInteger()
|
||||||
versionName providers.gradleProperty('crossdeskVersionName').get()
|
versionName = providers.gradleProperty('crossdeskVersionName').get()
|
||||||
ndk { abiFilters 'arm64-v8a' }
|
ndk { abiFilters 'arm64-v8a' }
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')]
|
sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')]
|
||||||
sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')]
|
sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')]
|
||||||
sourceSets.androidTest.java.srcDirs = ['../test/android/java']
|
sourceSets.androidTest.java.srcDirs = ['../test/android/java']
|
||||||
sourceSets.androidTest.assets.srcDirs = ['../test/android/assets']
|
sourceSets.androidTest.assets.srcDirs = ['../test/android/assets']
|
||||||
buildTypes.release { minifyEnabled false }
|
buildTypes.release { minifyEnabled = false }
|
||||||
packaging.jniLibs.useLegacyPackaging = false
|
packaging.jniLibs.useLegacyPackaging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="false" android:usesCleartextTraffic="false">
|
android:supportsRtl="false" android:usesCleartextTraffic="false">
|
||||||
<service android:name=".RemoteSessionService" android:exported="false"
|
<service android:name=".RemoteSessionService" android:exported="false"
|
||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse">
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private String serverKey(){return host+":"+port;}
|
private String serverKey(){return host+":"+port;}
|
||||||
private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();}
|
private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();}
|
||||||
private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();}
|
private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();}
|
||||||
|
// Legacy inset handling also includes the IME on non-session pages. The
|
||||||
|
// decor-fitting call below is restricted to Android 11–14.
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
private void installRoot(String nextPage,boolean dark){
|
private void installRoot(String nextPage,boolean dark){
|
||||||
PageHost.Motion motion=PageHost.Motion.NONE;
|
PageHost.Motion motion=PageHost.Motion.NONE;
|
||||||
if(canvas!=null&&foreground&&!page.equals(nextPage)){
|
if(canvas!=null&&foreground&&!page.equals(nextPage)){
|
||||||
@@ -147,6 +150,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||||
applySystemBars(dark);
|
applySystemBars(dark);
|
||||||
}
|
}
|
||||||
|
// Android 8–10 require system UI flags instead of WindowInsetsController.
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
private void applySystemBars(boolean dark){
|
private void applySystemBars(boolean dark){
|
||||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||||
android.view.WindowInsetsController controller=getWindow().getInsetsController();
|
android.view.WindowInsetsController controller=getWindow().getInsetsController();
|
||||||
@@ -302,7 +307,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||||
});connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48));
|
});connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48));
|
||||||
ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
|
ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
|
||||||
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
|
// This floating dialog uses window-managed IME resizing on all supported APIs.
|
||||||
|
@SuppressWarnings("deprecation") int resizeMode=WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
||||||
|
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(resizeMode);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
|
||||||
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
||||||
}
|
}
|
||||||
private void sizePasswordDialog(){
|
private void sizePasswordDialog(){
|
||||||
@@ -715,6 +722,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
|
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
|
||||||
private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||||
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
||||||
|
// API 26–32 use this callback; API 33+ registers OnBackInvokedCallback in onCreate.
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||||
@Override public void onConfigurationChanged(Configuration config){
|
@Override public void onConfigurationChanged(Configuration config){
|
||||||
super.onConfigurationChanged(config);
|
super.onConfigurationChanged(config);
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ final class MobileUi {
|
|||||||
}
|
}
|
||||||
@Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);invalidateSelf(); }
|
@Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);invalidateSelf(); }
|
||||||
@Override public void setColorFilter(ColorFilter filter) { bitmapPaint.setColorFilter(filter);invalidateSelf(); }
|
@Override public void setColorFilter(ColorFilter filter) { bitmapPaint.setColorFilter(filter);invalidateSelf(); }
|
||||||
|
// Drawable still requires this abstract method, including on API 26.
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
|
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
|
||||||
}
|
}
|
||||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
|
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
|
||||||
|
|||||||
@@ -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>
|
||||||
BIN
Binary file not shown.
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
distributionSha256Sum=7aaba33a6aa7d03e0c7890869da1f1b5e1765c4d71db981a9d5db5cc47a10a6d
|
||||||
distributionSha256Sum=17f277867f6914d61b1aa02efab1ba7bb439ad652ca485cd8ca6842fccec6e43
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
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:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
|||||||
Vendored
+52
-33
@@ -19,12 +19,39 @@
|
|||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||||
|
@rem This can be removed once we can be reasonably confident that few people
|
||||||
|
@rem will be migrating directly to this new wrapper.
|
||||||
|
goto afterSafetyNet
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
:afterSafetyNet
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation. 1>&2
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -59,35 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation. 1>&2
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||||
|
|
||||||
:end
|
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||||
@rem End local scope for the variables with windows NT shell
|
:exitWithErrorLevel
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
#include <android/native_window_jni.h>
|
#include <android/native_window_jni.h>
|
||||||
#include <libyuv.h>
|
#include <libyuv.h>
|
||||||
#include <minirtc.h>
|
#include <minirtc.h>
|
||||||
#include <log.h>
|
|
||||||
#include <remote_action.h>
|
#include <remote_action.h>
|
||||||
#include <app_version.h>
|
#include <app_version.h>
|
||||||
#include <stream_names.h>
|
#include <stream_names.h>
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include "controller_protocol.h"
|
#include "controller_protocol.h"
|
||||||
#include "file_transfer_session.h"
|
#include "file_transfer_session.h"
|
||||||
#include "android_codec_support.h"
|
|
||||||
#include "latest_frame_worker.h"
|
#include "latest_frame_worker.h"
|
||||||
#include "video_picture.h"
|
#include "video_picture.h"
|
||||||
#include "video_timing.h"
|
#include "video_timing.h"
|
||||||
@@ -52,8 +50,8 @@ struct CallbackContext { Session* owner; bool controller; };
|
|||||||
struct Session {
|
struct Session {
|
||||||
jobject owner = nullptr;
|
jobject owner = nullptr;
|
||||||
jmethodID event_method, audio_method, frame_method;
|
jmethodID event_method, audio_method, frame_method;
|
||||||
PeerPtr* identity = nullptr;
|
MiniRtcPeerPtr* identity = nullptr;
|
||||||
PeerPtr* controller = nullptr;
|
MiniRtcPeerPtr* controller = nullptr;
|
||||||
CallbackContext identity_context{this, false}, controller_context{this, true};
|
CallbackContext identity_context{this, false}, controller_context{this, true};
|
||||||
std::string host, log_path, identity_login, controller_login, remote;
|
std::string host, log_path, identity_login, controller_login, remote;
|
||||||
int port;
|
int port;
|
||||||
@@ -80,7 +78,7 @@ struct Session {
|
|||||||
android_controller::FileTransferSession files{
|
android_controller::FileTransferSession files{
|
||||||
[this](const char* stream, const char* data, size_t size) {
|
[this](const char* stream, const char* data, size_t size) {
|
||||||
return controller && !stopping
|
return controller && !stopping
|
||||||
? SendReliableDataFrame(controller, data, size, stream) : -1;
|
? MiniRtcSendReliableDataFrame(controller, data, size, stream) : -1;
|
||||||
},
|
},
|
||||||
[this](const std::string& name, double progress, bool sending, const std::string& path) {
|
[this](const std::string& name, double progress, bool sending, const std::string& path) {
|
||||||
Event(10, {{"name", name}, {"progress", progress}, {"sending", sending}, {"path", path}});
|
Event(10, {{"name", name}, {"progress", progress}, {"sending", sending}, {"path", path}});
|
||||||
@@ -102,7 +100,7 @@ struct Session {
|
|||||||
}
|
}
|
||||||
void Send(const char* stream, const std::string& data) {
|
void Send(const char* stream, const std::string& data) {
|
||||||
if (controller && !stopping && !data.empty())
|
if (controller && !stopping && !data.empty())
|
||||||
SendReliableDataFrame(controller, data.data(), data.size(), stream);
|
MiniRtcSendReliableDataFrame(controller, data.data(), data.size(), stream);
|
||||||
}
|
}
|
||||||
void Surface(JNIEnv* env, jobject surface) {
|
void Surface(JNIEnv* env, jobject surface) {
|
||||||
std::lock_guard<std::mutex> lock(render_mutex);
|
std::lock_guard<std::mutex> lock(render_mutex);
|
||||||
@@ -133,25 +131,25 @@ struct Session {
|
|||||||
stopping = true;
|
stopping = true;
|
||||||
renderer.Stop();
|
renderer.Stop();
|
||||||
if (controller) {
|
if (controller) {
|
||||||
LeaveConnection(controller, remote.c_str());
|
MiniRtcLeaveConnection(controller, remote.c_str());
|
||||||
DestroyPeer(&controller);
|
MiniRtcDestroyPeer(&controller);
|
||||||
}
|
}
|
||||||
if (identity) DestroyPeer(&identity);
|
if (identity) MiniRtcDestroyPeer(&identity);
|
||||||
Environment scope;
|
Environment scope;
|
||||||
Surface(scope.env, nullptr);
|
Surface(scope.env, nullptr);
|
||||||
if (scope.env) scope.env->DeleteGlobalRef(owner);
|
if (scope.env) scope.env->DeleteGlobalRef(owner);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void Signal(SignalStatus status, const char*, size_t, void* user) {
|
void Signal(MiniRtcSignalStatus status, const char*, size_t, void* user) {
|
||||||
auto* ctx = static_cast<CallbackContext*>(user);
|
auto* ctx = static_cast<CallbackContext*>(user);
|
||||||
if (!ctx->controller && status == SignalStatus::SignalConnected &&
|
if (!ctx->controller && status == MiniRtcSignalStatus::MiniRtcSignalConnected &&
|
||||||
!ctx->owner->stopping && ctx->owner->identity) {
|
!ctx->owner->stopping && ctx->owner->identity) {
|
||||||
// Report after every identity login, including reconnects. The server
|
// Report after every identity login, including reconnects. The server
|
||||||
// ignores client_info sent through the temporary C- controller peer.
|
// ignores client_info sent through the temporary C- controller peer.
|
||||||
constexpr char info[] =
|
constexpr char info[] =
|
||||||
R"({"type":"client_info","version":"android-native","platform":"android"})";
|
R"({"type":"client_info","version":"android-native","platform":"android"})";
|
||||||
SendSignalMessage(ctx->owner->identity, info, sizeof(info) - 1);
|
MiniRtcSendSignalMessage(ctx->owner->identity, info, sizeof(info) - 1);
|
||||||
}
|
}
|
||||||
if (!ctx->controller && ctx->owner->signal_status.exchange(status) != status)
|
if (!ctx->controller && ctx->owner->signal_status.exchange(status) != status)
|
||||||
++ctx->owner->signal_generation;
|
++ctx->owner->signal_generation;
|
||||||
@@ -171,14 +169,14 @@ void SignalMessage(const char* data, size_t size, void* user) {
|
|||||||
ctx->owner->Event(9, message);
|
ctx->owner->Event(9, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void Connection(ConnectionStatus status, const char*, size_t, void* user) {
|
void Connection(MiniRtcConnectionStatus status, const char*, size_t, void* user) {
|
||||||
auto* ctx = static_cast<CallbackContext*>(user);
|
auto* ctx = static_cast<CallbackContext*>(user);
|
||||||
if (ctx->controller) ctx->owner->Event(2, {{"status", status}});
|
if (ctx->controller) ctx->owner->Event(2, {{"status", status}});
|
||||||
}
|
}
|
||||||
void Network(const char* id, size_t size, TraversalMode mode,
|
void Network(const char* id, size_t size, MiniRtcTraversalMode mode,
|
||||||
const MiniRtcNetTrafficStats* stats, const char*, size_t, void* user) {
|
const MiniRtcNetTrafficStats* stats, const char*, size_t, void* user) {
|
||||||
auto* ctx = static_cast<CallbackContext*>(user);
|
auto* ctx = static_cast<CallbackContext*>(user);
|
||||||
if (!ctx->controller && mode == UnknownMode && id && size) {
|
if (!ctx->controller && mode == MiniRtcTraversalUnknown && id && size) {
|
||||||
ctx->owner->Event(3, id, size);
|
ctx->owner->Event(3, id, size);
|
||||||
} else if (ctx->controller && stats) {
|
} else if (ctx->controller && stats) {
|
||||||
ctx->owner->Event(7, android_controller::NetworkReport(*stats, mode));
|
ctx->owner->Event(7, android_controller::NetworkReport(*stats, mode));
|
||||||
@@ -252,7 +250,7 @@ void RenderVideo(Session* s, VideoPicture picture) {
|
|||||||
s->Event(5, {{"width", w}, {"height", h}});
|
s->Event(5, {{"width", w}, {"height", h}});
|
||||||
}
|
}
|
||||||
// Measure through display submission, using MiniRTC's calibrated capture clock.
|
// Measure through display submission, using MiniRTC's calibrated capture clock.
|
||||||
const auto now = GetSystemTimeMicros(s->controller);
|
const auto now = MiniRtcGetSystemTimeMicros(s->controller);
|
||||||
const double latency = android_controller::VideoLatencyMilliseconds(
|
const double latency = android_controller::VideoLatencyMilliseconds(
|
||||||
picture.captured_us, picture.received_us, now);
|
picture.captured_us, picture.received_us, now);
|
||||||
Environment scope;
|
Environment scope;
|
||||||
@@ -261,8 +259,8 @@ void RenderVideo(Session* s, VideoPicture picture) {
|
|||||||
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Params Parameters(Session* s, bool controller) {
|
MiniRtcParams Parameters(Session* s, bool controller) {
|
||||||
Params p{};
|
MiniRtcParams p{};
|
||||||
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s", s->host.c_str());
|
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s", s->host.c_str());
|
||||||
std::snprintf(p.log_path, sizeof(p.log_path), "%s", s->log_path.c_str());
|
std::snprintf(p.log_path, sizeof(p.log_path), "%s", s->log_path.c_str());
|
||||||
p.signal_server_port = s->port;
|
p.signal_server_port = s->port;
|
||||||
@@ -270,12 +268,12 @@ Params Parameters(Session* s, bool controller) {
|
|||||||
// MediaCodec returns retained Surface buffers; software fallback returns
|
// MediaCodec returns retained Surface buffers; software fallback returns
|
||||||
// pooled CPU NV12 with the same ownership contract.
|
// pooled CPU NV12 with the same ownership contract.
|
||||||
p.native_video_output = controller;
|
p.native_video_output = controller;
|
||||||
p.turn_mode = TurnAutoUdpTcp;
|
p.turn_mode = MiniRtcTurnAutoUdpTcp;
|
||||||
p.enable_srtp = true;
|
p.enable_srtp = true;
|
||||||
p.video_content_type = VideoContentType::ScreenContent;
|
p.video_content_type = MiniRtcVideoContentType::ScreenContent;
|
||||||
p.video_quality = QualityMedium;
|
p.video_quality = MiniRtcVideoQualityMedium;
|
||||||
p.video_frame_rate = 30;
|
p.video_frame_rate = 30;
|
||||||
p.video_degradation_preference = VideoDegradationPreference::Balanced;
|
p.video_degradation_preference = MiniRtcVideoDegradationPreference::Balanced;
|
||||||
p.on_signal_status = Signal; p.on_connection_status = Connection;
|
p.on_signal_status = Signal; p.on_connection_status = Connection;
|
||||||
p.on_signal_message = SignalMessage;
|
p.on_signal_message = SignalMessage;
|
||||||
p.on_net_status_report = Network; p.on_receive_data_buffer = Data;
|
p.on_net_status_report = Network; p.on_receive_data_buffer = Data;
|
||||||
@@ -289,7 +287,7 @@ Session* From(jlong handle) { return reinterpret_cast<Session*>(handle); }
|
|||||||
|
|
||||||
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) {
|
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) {
|
||||||
vm = java_vm;
|
vm = java_vm;
|
||||||
minirtc::android::SetJavaVm(java_vm);
|
if (MiniRtcSetAndroidJavaVm(java_vm) != 0) return JNI_ERR;
|
||||||
return JNI_VERSION_1_6;
|
return JNI_VERSION_1_6;
|
||||||
}
|
}
|
||||||
#define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name
|
#define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name
|
||||||
@@ -343,11 +341,11 @@ extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)(
|
|||||||
throw std::runtime_error("Unable to configure Android trust bundle");
|
throw std::runtime_error("Unable to configure Android trust bundle");
|
||||||
// MiniRTC's diagnostic log includes login identities. Keep credentials
|
// MiniRTC's diagnostic log includes login identities. Keep credentials
|
||||||
// solely in Keystore-backed storage; do not persist its session logging.
|
// solely in Keystore-backed storage; do not persist its session logging.
|
||||||
minirtc::InitLogger(s->log_path);
|
if (MiniRtcConfigureLogging(MiniRtcLogOff, nullptr, nullptr) != 0)
|
||||||
minirtc::get_logger()->set_level(spdlog::level::off);
|
throw std::runtime_error("Unable to configure MiniRTC logging");
|
||||||
auto params = Parameters(s.get(), false);
|
auto params = Parameters(s.get(), false);
|
||||||
s->identity = CreatePeer(¶ms);
|
s->identity = MiniRtcCreatePeer(¶ms);
|
||||||
if (!s->identity || Init(s->identity) != 0) return 0;
|
if (!s->identity || MiniRtcInit(s->identity) != 0) return 0;
|
||||||
return reinterpret_cast<jlong>(s.release());
|
return reinterpret_cast<jlong>(s.release());
|
||||||
} catch (const std::exception&) { return 0; }
|
} catch (const std::exception&) { return 0; }
|
||||||
}
|
}
|
||||||
@@ -358,15 +356,15 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
|
|||||||
s->controller_login = "C-" + String(env, identity);
|
s->controller_login = "C-" + String(env, identity);
|
||||||
s->remote = String(env, remote);
|
s->remote = String(env, remote);
|
||||||
auto params = Parameters(s, true);
|
auto params = Parameters(s, true);
|
||||||
s->controller = CreatePeer(¶ms);
|
s->controller = MiniRtcCreatePeer(¶ms);
|
||||||
if (!s->controller) return false;
|
if (!s->controller) return false;
|
||||||
AddAudioStream(s->controller, kAudioStream);
|
MiniRtcAddAudioStream(s->controller, kAudioStream);
|
||||||
AddDataStream(s->controller, kDataStream, false);
|
MiniRtcAddDataStream(s->controller, kDataStream, false);
|
||||||
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
|
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
|
||||||
kClipboardStream, kFileStream, kFileFeedbackStream}) AddDataStream(s->controller, name, true);
|
kClipboardStream, kFileStream, kFileFeedbackStream}) MiniRtcAddDataStream(s->controller, name, true);
|
||||||
if (Init(s->controller) != 0) return false;
|
if (MiniRtcInit(s->controller) != 0) return false;
|
||||||
const auto join = s->remote + "@" + String(env, password);
|
const auto join = s->remote + "@" + String(env, password);
|
||||||
return JoinConnection(s->controller, join.c_str()) == 0;
|
return MiniRtcJoinConnection(s->controller, join.c_str()) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
|
||||||
auto* s = From(handle); if (!s || !s->controller) return;
|
auto* s = From(handle); if (!s || !s->controller) return;
|
||||||
@@ -422,13 +420,13 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)(
|
|||||||
if (!s || !s->controller || s->stopping || request_id < 0 || request_id > UINT32_MAX) return false;
|
if (!s || !s->controller || s->stopping || request_id < 0 || request_id > UINT32_MAX) return false;
|
||||||
const auto data = android_controller::VideoSettingsMessage(quality, rate, preference, static_cast<uint32_t>(request_id));
|
const auto data = android_controller::VideoSettingsMessage(quality, rate, preference, static_cast<uint32_t>(request_id));
|
||||||
if (data.empty()) return false;
|
if (data.empty()) return false;
|
||||||
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
return MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) {
|
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) {
|
||||||
auto* s = From(handle);
|
auto* s = From(handle);
|
||||||
if (!s || !s->controller || s->stopping) return false;
|
if (!s || !s->controller || s->stopping) return false;
|
||||||
const auto data = android_controller::SecureAttentionMessage();
|
const auto data = android_controller::SecureAttentionMessage();
|
||||||
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
return MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
|
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
|
||||||
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
|
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
|
||||||
@@ -436,7 +434,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
|
|||||||
if (!s || !s->controller || s->stopping) return false;
|
if (!s || !s->controller || s->stopping) return false;
|
||||||
const auto data = android_controller::PrivacyMessage(flag, block_input);
|
const auto data = android_controller::PrivacyMessage(flag, block_input);
|
||||||
return !data.empty() &&
|
return !data.empty() &&
|
||||||
SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
|
||||||
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
|
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
|
||||||
@@ -468,7 +466,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)(
|
|||||||
auto message = json::parse(bytes, nullptr, false);
|
auto message = json::parse(bytes, nullptr, false);
|
||||||
if (!message.is_object() || !message.contains("type") ||
|
if (!message.is_object() || !message.contains("type") ||
|
||||||
!message["type"].is_string() || message["type"] != "announcements_list") return false;
|
!message["type"].is_string() || message["type"] != "announcements_list") return false;
|
||||||
return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
|
return MiniRtcSendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
|
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
|
||||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||||
@@ -485,7 +483,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)(
|
|||||||
!message.contains("user_id") || !message["user_id"].is_string() ||
|
!message.contains("user_id") || !message["user_id"].is_string() ||
|
||||||
!message.contains("devices") || !message["devices"].is_array() ||
|
!message.contains("devices") || !message["devices"].is_array() ||
|
||||||
!message.contains("subscribe") || message["subscribe"] != true) return false;
|
!message.contains("subscribe") || message["subscribe"] != true) return false;
|
||||||
return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
|
return MiniRtcSendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)(
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)(
|
||||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||||
@@ -500,7 +498,7 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nSurface)(JNIEnv* env, jclass, jlon
|
|||||||
if (auto* s = From(handle)) {
|
if (auto* s = From(handle)) {
|
||||||
s->Surface(env, surface);
|
s->Surface(env, surface);
|
||||||
if (surface && s->controller && !s->stopping)
|
if (surface && s->controller && !s->stopping)
|
||||||
RequestVideoKeyFrame(s->controller, MakeDisplayStreamId(s->display.load()).c_str());
|
MiniRtcRequestVideoKeyFrame(s->controller, MakeDisplayStreamId(s->display.load()).c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nDestroy)(JNIEnv*, jclass, jlong handle) { delete From(handle); }
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nDestroy)(JNIEnv*, jclass, jlong handle) { delete From(handle); }
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ includes("../../../deps/submodules/minirtc")
|
|||||||
target("crossdesk_android")
|
target("crossdesk_android")
|
||||||
set_kind("shared")
|
set_kind("shared")
|
||||||
add_deps("minirtc")
|
add_deps("minirtc")
|
||||||
add_packages("nlohmann_json", "libyuv", "spdlog")
|
add_packages("nlohmann_json", "libyuv")
|
||||||
add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp")
|
add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp")
|
||||||
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include")
|
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include")
|
||||||
-- This source-level JNI adapter is the only application layer using MiniRTC internals.
|
|
||||||
add_includedirs("../../../deps/submodules/minirtc/src/media/mediacodec")
|
|
||||||
add_syslinks("android", "log")
|
add_syslinks("android", "log")
|
||||||
add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true})
|
add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true})
|
||||||
set_targetdir("../app/build/native/arm64-v8a")
|
set_targetdir("../app/build/native/arm64-v8a")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Build the native controller in isolation from desktop and iOS caches."""
|
"""Build the native controller in isolation from desktop and iOS caches."""
|
||||||
|
import argparse
|
||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -26,7 +27,7 @@ def find_xmake():
|
|||||||
return xmake
|
return xmake
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(configure_only=False):
|
||||||
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
|
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
|
||||||
or Path.home() / 'Library/Android/sdk')
|
or Path.home() / 'Library/Android/sdk')
|
||||||
ndk = sdk / 'ndk/28.2.13676358'
|
ndk = sdk / 'ndk/28.2.13676358'
|
||||||
@@ -45,13 +46,20 @@ def main():
|
|||||||
# and updates. Only installed Android packages/build products are isolated.
|
# and updates. Only installed Android packages/build products are isolated.
|
||||||
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
|
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
|
||||||
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
|
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
|
||||||
'--runtimes=c++_static', '--policies=package.precompiled:n',
|
'--runtimes=c++_static',
|
||||||
|
'--policies=package.precompiled:n,package.librarydeps.strict_compatibility',
|
||||||
'-o', str(CACHE / 'build')], cwd=work, env=env, check=True)
|
'-o', str(CACHE / 'build')], cwd=work, env=env, check=True)
|
||||||
|
if configure_only:
|
||||||
|
return
|
||||||
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
|
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--configure-only', action='store_true',
|
||||||
|
help='Install native dependencies before compiling application targets.')
|
||||||
|
args = parser.parse_args()
|
||||||
CACHE.mkdir(parents=True, exist_ok=True)
|
CACHE.mkdir(parents=True, exist_ok=True)
|
||||||
with (CACHE / 'build.lock').open('w') as lock:
|
with (CACHE / 'build.lock').open('w') as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
main()
|
main(configure_only=args.configure_only)
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ int main() {
|
|||||||
stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000;
|
stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000;
|
||||||
stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000;
|
stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000;
|
||||||
stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true;
|
stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true;
|
||||||
auto json = android_controller::NetworkReport(stats, Relay);
|
auto json = android_controller::NetworkReport(stats, MiniRtcTraversalRelay);
|
||||||
assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40);
|
assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40);
|
||||||
assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000);
|
assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000);
|
||||||
assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000);
|
assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000);
|
||||||
assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX);
|
assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX);
|
||||||
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
|
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
|
||||||
stats.rtt_ms = -1; stats.srtp_active = false;
|
stats.rtt_ms = -1; stats.srtp_active = false;
|
||||||
json = android_controller::NetworkReport(stats, UnknownMode);
|
json = android_controller::NetworkReport(stats, MiniRtcTraversalUnknown);
|
||||||
assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1);
|
assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1);
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
|
||||||
- [私有桥接协议](docs/bridge-protocol.md)
|
- [私有桥接协议](docs/bridge-protocol.md)
|
||||||
- [实际验证记录](docs/validation.md)
|
- [实际验证记录](docs/validation.md)
|
||||||
@@ -87,4 +89,4 @@ crossdesk-cli 123456789 --display 1
|
|||||||
|
|
||||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
||||||
|
|
||||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/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` 包提供。
|
||||||
|
|||||||
+1227
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,324 @@
|
|||||||
|
#include "automation_protocol.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk::cli::automation {
|
||||||
|
void Fields(const json &j, std::initializer_list<const char *> names) {
|
||||||
|
if (!j.is_object())
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
for (auto it = j.begin(); it != j.end(); ++it)
|
||||||
|
if (std::none_of(names.begin(), names.end(),
|
||||||
|
[&](const char *s) { return it.key() == s; }))
|
||||||
|
throw std::invalid_argument("UNKNOWN_FIELD");
|
||||||
|
}
|
||||||
|
int Number(const json &j, const char *key, int low, int high, int fallback) {
|
||||||
|
if (!j.contains(key))
|
||||||
|
return fallback;
|
||||||
|
if (!j[key].is_number_integer())
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
const auto n = j[key].get<int64_t>();
|
||||||
|
if (n < low || n > high)
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
return int(n);
|
||||||
|
}
|
||||||
|
uint64_t Sequence(const json &j, const char *key, uint64_t fallback) {
|
||||||
|
if (!j.contains(key))
|
||||||
|
return fallback;
|
||||||
|
if (!j[key].is_number_unsigned())
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
return j[key].get<uint64_t>();
|
||||||
|
}
|
||||||
|
bool Flag(const json &j, const char *key, bool fallback) {
|
||||||
|
if (!j.contains(key))
|
||||||
|
return fallback;
|
||||||
|
if (!j[key].is_boolean())
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
return j[key].get<bool>();
|
||||||
|
}
|
||||||
|
std::string String(const json &j, const char *key, size_t limit) {
|
||||||
|
if (!j.contains(key) || !j[key].is_string())
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
auto s = j[key].get<std::string>();
|
||||||
|
if (s.empty() || s.size() > limit || s.find('\0') != std::string::npos)
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
int KeyCode(const std::string &key) {
|
||||||
|
if (key.size() == 1 &&
|
||||||
|
((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= '0' && key[0] <= '9')))
|
||||||
|
return key[0];
|
||||||
|
static const std::map<std::string, int> keys = {{"BACKSPACE", 8},
|
||||||
|
{"TAB", 9},
|
||||||
|
{"ENTER", 13},
|
||||||
|
{"SHIFT", 16},
|
||||||
|
{"CTRL", 17},
|
||||||
|
{"ALT", 18},
|
||||||
|
{"PAUSE", 19},
|
||||||
|
{"CAPS_LOCK", 20},
|
||||||
|
{"ESC", 27},
|
||||||
|
{"SPACE", 32},
|
||||||
|
{"PAGE_UP", 33},
|
||||||
|
{"PAGE_DOWN", 34},
|
||||||
|
{"END", 35},
|
||||||
|
{"HOME", 36},
|
||||||
|
{"LEFT", 37},
|
||||||
|
{"UP", 38},
|
||||||
|
{"RIGHT", 39},
|
||||||
|
{"DOWN", 40},
|
||||||
|
{"INSERT", 45},
|
||||||
|
{"DELETE", 46},
|
||||||
|
{"META", 91},
|
||||||
|
{"F1", 112},
|
||||||
|
{"F2", 113},
|
||||||
|
{"F3", 114},
|
||||||
|
{"F4", 115},
|
||||||
|
{"F5", 116},
|
||||||
|
{"F6", 117},
|
||||||
|
{"F7", 118},
|
||||||
|
{"F8", 119},
|
||||||
|
{"F9", 120},
|
||||||
|
{"F10", 121},
|
||||||
|
{"F11", 122},
|
||||||
|
{"F12", 123},
|
||||||
|
{"F13", 124},
|
||||||
|
{"F14", 125},
|
||||||
|
{"F15", 126},
|
||||||
|
{"F16", 127},
|
||||||
|
{"F17", 128},
|
||||||
|
{"F18", 129},
|
||||||
|
{"F19", 130},
|
||||||
|
{"F20", 131},
|
||||||
|
{"F21", 132},
|
||||||
|
{"F22", 133},
|
||||||
|
{"F23", 134},
|
||||||
|
{"F24", 135},
|
||||||
|
{"NUMPAD0", 96},
|
||||||
|
{"NUMPAD1", 97},
|
||||||
|
{"NUMPAD2", 98},
|
||||||
|
{"NUMPAD3", 99},
|
||||||
|
{"NUMPAD4", 100},
|
||||||
|
{"NUMPAD5", 101},
|
||||||
|
{"NUMPAD6", 102},
|
||||||
|
{"NUMPAD7", 103},
|
||||||
|
{"NUMPAD8", 104},
|
||||||
|
{"NUMPAD9", 105},
|
||||||
|
{"NUM_LOCK", 144},
|
||||||
|
{"SCROLL_LOCK", 145},
|
||||||
|
{"PRINT_SCREEN", 44},
|
||||||
|
{"MENU", 93},
|
||||||
|
{"MULTIPLY", 106},
|
||||||
|
{"ADD", 107},
|
||||||
|
{"SUBTRACT", 109},
|
||||||
|
{"DECIMAL", 110},
|
||||||
|
{"DIVIDE", 111},
|
||||||
|
{"VOLUME_MUTE", 173},
|
||||||
|
{"VOLUME_DOWN", 174},
|
||||||
|
{"VOLUME_UP", 175},
|
||||||
|
{"MEDIA_NEXT", 176},
|
||||||
|
{"MEDIA_PREVIOUS", 177},
|
||||||
|
{"MEDIA_STOP", 178},
|
||||||
|
{"MEDIA_PLAY_PAUSE", 179},
|
||||||
|
{"SEMICOLON", 186},
|
||||||
|
{"EQUAL", 187},
|
||||||
|
{"COMMA", 188},
|
||||||
|
{"MINUS", 189},
|
||||||
|
{"PERIOD", 190},
|
||||||
|
{"SLASH", 191},
|
||||||
|
{"BACKQUOTE", 192},
|
||||||
|
{"BRACKET_LEFT", 219},
|
||||||
|
{"BACKSLASH", 220},
|
||||||
|
{"BRACKET_RIGHT", 221},
|
||||||
|
{"QUOTE", 222}};
|
||||||
|
auto it = keys.find(key);
|
||||||
|
if (it == keys.end())
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Step> BuildInputSteps(const json &a, const InputGeometry &o) {
|
||||||
|
const auto type = String(a, "type", 32);
|
||||||
|
std::vector<Step> steps;
|
||||||
|
auto point = [&](const json &p, int delay, int flag = 0, int wheel = 0) {
|
||||||
|
auto coordinate = [&](const char *key, int size, int offset, int span,
|
||||||
|
int source) {
|
||||||
|
if (!p.contains(key) || !p[key].is_number())
|
||||||
|
throw std::invalid_argument("INVALID_COORDINATE");
|
||||||
|
const auto value = p[key].get<double>();
|
||||||
|
if (!std::isfinite(value) || value < 0 || value >= size)
|
||||||
|
throw std::invalid_argument("INVALID_COORDINATE");
|
||||||
|
const auto center = std::min(value + .5, double(size) - .5);
|
||||||
|
return float(std::clamp((offset + center * span / size) / source, 0.0, 1.0));
|
||||||
|
};
|
||||||
|
Step step;
|
||||||
|
step.delay = delay;
|
||||||
|
step.flag = flag;
|
||||||
|
step.wheel = wheel;
|
||||||
|
step.x =
|
||||||
|
coordinate("x", o.width, o.region.x, o.region.width, o.source_width);
|
||||||
|
step.y =
|
||||||
|
coordinate("y", o.height, o.region.y, o.region.height, o.source_height);
|
||||||
|
steps.push_back(std::move(step));
|
||||||
|
};
|
||||||
|
auto key = [&](int code, bool down, int delay) {
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Key;
|
||||||
|
step.key = code;
|
||||||
|
step.down = down;
|
||||||
|
step.delay = delay;
|
||||||
|
steps.push_back(std::move(step));
|
||||||
|
};
|
||||||
|
if (type == "mouse_down" || type == "mouse_up") {
|
||||||
|
Fields(a, {"type", "x", "y", "button"});
|
||||||
|
const auto button = a.contains("button") ? String(a, "button", 8) : "left";
|
||||||
|
const int flag = button == "left" ? 1 : button == "right" ? 3 : button == "middle" ? 5 : -1;
|
||||||
|
if (flag < 0) throw std::invalid_argument("INVALID_BUTTON");
|
||||||
|
point(a, 0);
|
||||||
|
point(a, 10, flag + (type == "mouse_up" ? 1 : 0));
|
||||||
|
} else if (type == "key_down" || type == "key_up") {
|
||||||
|
Fields(a, {"type", "key"});
|
||||||
|
key(KeyCode(String(a, "key", 16)), type == "key_down", 0);
|
||||||
|
} else if (type == "move" || type == "click" || type == "double_click") {
|
||||||
|
Fields(a, type == "move"
|
||||||
|
? std::initializer_list<const char *>{"type", "x", "y",
|
||||||
|
"modifiers"}
|
||||||
|
: std::initializer_list<const char *>{"type", "x", "y",
|
||||||
|
"button", "modifiers"});
|
||||||
|
point(a, 0);
|
||||||
|
if (type != "move") {
|
||||||
|
const auto button =
|
||||||
|
a.contains("button") ? String(a, "button", 8) : "left";
|
||||||
|
const int flag = button == "left" ? 1
|
||||||
|
: button == "right" ? 3
|
||||||
|
: button == "middle" ? 5
|
||||||
|
: -1;
|
||||||
|
if (flag < 0)
|
||||||
|
throw std::invalid_argument("INVALID_BUTTON");
|
||||||
|
point(a, 10, flag);
|
||||||
|
point(a, 40, flag + 1);
|
||||||
|
if (type == "double_click") {
|
||||||
|
point(a, 110, flag);
|
||||||
|
point(a, 140, flag + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type == "scroll") {
|
||||||
|
Fields(a, {"type", "x", "y", "direction", "ticks", "modifiers"});
|
||||||
|
const auto direction = String(a, "direction", 8);
|
||||||
|
if (direction != "up" && direction != "down" && direction != "left" &&
|
||||||
|
direction != "right")
|
||||||
|
throw std::invalid_argument("INVALID_DIRECTION");
|
||||||
|
const int ticks = Number(a, "ticks", 1, 10, 1);
|
||||||
|
point(a, 0);
|
||||||
|
point(a, 10, direction == "left" || direction == "right" ? 8 : 7,
|
||||||
|
(direction == "up" || direction == "right" ? 120 : -120) * ticks);
|
||||||
|
} else if (type == "drag") {
|
||||||
|
Fields(a, {"type", "path", "duration_ms", "button", "modifiers"});
|
||||||
|
const auto button = a.contains("button") ? String(a, "button", 8) : "left";
|
||||||
|
const int flag = button == "left" ? 1
|
||||||
|
: button == "right" ? 3
|
||||||
|
: button == "middle" ? 5
|
||||||
|
: -1;
|
||||||
|
if (flag < 0)
|
||||||
|
throw std::invalid_argument("INVALID_BUTTON");
|
||||||
|
const auto &path = a.at("path");
|
||||||
|
if (!path.is_array() || path.size() < 2 || path.size() > 128)
|
||||||
|
throw std::invalid_argument("INVALID_PATH");
|
||||||
|
const int duration = Number(a, "duration_ms", 100, 30000, 500);
|
||||||
|
for (const auto &p : path)
|
||||||
|
Fields(p, {"x", "y"});
|
||||||
|
point(path.front(), 0);
|
||||||
|
point(path.front(), 10, flag);
|
||||||
|
for (size_t i = 1; i < path.size(); ++i)
|
||||||
|
point(path[i], 10 + int(i * duration / (path.size() - 1)));
|
||||||
|
point(path.back(), duration + 30, flag + 1);
|
||||||
|
} else if (type == "key_press") {
|
||||||
|
Fields(a, {"type", "key", "modifiers", "duration_ms"});
|
||||||
|
const auto code = KeyCode(String(a, "key", 16));
|
||||||
|
key(code, true, 0);
|
||||||
|
key(code, false, Number(a, "duration_ms", 10, 30000, 40));
|
||||||
|
} else if (type == "hotkey") {
|
||||||
|
Fields(a, {"type", "keys", "duration_ms"});
|
||||||
|
const auto &keys = a.at("keys");
|
||||||
|
if (!keys.is_array() || keys.size() < 2 || keys.size() > 6)
|
||||||
|
throw std::invalid_argument("INVALID_KEYS");
|
||||||
|
std::set<int> seen;
|
||||||
|
std::vector<int> codes;
|
||||||
|
for (const auto &item : keys) {
|
||||||
|
if (!item.is_string())
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
int code = KeyCode(item.get<std::string>());
|
||||||
|
if (!seen.insert(code).second)
|
||||||
|
throw std::invalid_argument("DUPLICATE_KEY");
|
||||||
|
codes.push_back(code);
|
||||||
|
}
|
||||||
|
int delay = 0;
|
||||||
|
for (int code : codes) {
|
||||||
|
key(code, true, delay);
|
||||||
|
delay += 15;
|
||||||
|
}
|
||||||
|
delay += Number(a, "duration_ms", 10, 30000, 30);
|
||||||
|
for (auto it = codes.rbegin(); it != codes.rend(); ++it) {
|
||||||
|
key(*it, false, delay);
|
||||||
|
delay += 15;
|
||||||
|
}
|
||||||
|
} else if (type == "type_text") {
|
||||||
|
Fields(a, {"type", "text"});
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Text;
|
||||||
|
step.text = String(a, "text", 16384);
|
||||||
|
steps.push_back(std::move(step));
|
||||||
|
} else
|
||||||
|
throw std::invalid_argument("UNSUPPORTED_ACTION");
|
||||||
|
if (a.contains("modifiers")) {
|
||||||
|
const auto &modifiers = a["modifiers"];
|
||||||
|
if (!modifiers.is_array() || modifiers.size() > 4)
|
||||||
|
throw std::invalid_argument("INVALID_KEYS");
|
||||||
|
std::set<int> seen;
|
||||||
|
std::vector<Step> prefix;
|
||||||
|
for (const auto &item : modifiers) {
|
||||||
|
if (!item.is_string())
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
const auto name = item.get<std::string>();
|
||||||
|
if (name != "CTRL" && name != "ALT" && name != "SHIFT" && name != "META")
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
const int code = KeyCode(name);
|
||||||
|
if (!seen.insert(code).second ||
|
||||||
|
(type == "key_press" && String(a, "key", 16) == name))
|
||||||
|
throw std::invalid_argument("DUPLICATE_KEY");
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Key;
|
||||||
|
step.key = code;
|
||||||
|
step.down = true;
|
||||||
|
step.delay = int(prefix.size()) * 15;
|
||||||
|
prefix.push_back(step);
|
||||||
|
}
|
||||||
|
const int offset = int(prefix.size()) * 15;
|
||||||
|
for (auto &step : steps)
|
||||||
|
step.delay += offset;
|
||||||
|
steps.insert(steps.begin(), prefix.begin(), prefix.end());
|
||||||
|
int delay = steps.back().delay + 15;
|
||||||
|
for (auto it = prefix.rbegin(); it != prefix.rend(); ++it, delay += 15)
|
||||||
|
key(it->key, false, delay);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos) {
|
||||||
|
if (password.empty() || password.size() > 256 ||
|
||||||
|
password.find('\0') != std::string::npos || password.find_first_of("\r\n\t") != std::string::npos)
|
||||||
|
throw std::invalid_argument("INVALID_CREDENTIAL");
|
||||||
|
const InputGeometry geometry{};
|
||||||
|
auto steps = BuildInputSteps({{"type", "hotkey"}, {"keys", {macos ? "META" : "CTRL", "A"}}}, geometry);
|
||||||
|
auto text = BuildInputSteps({{"type", "type_text"}, {"text", password}}, geometry);
|
||||||
|
text.front().delay = 150;
|
||||||
|
steps.insert(steps.end(), text.begin(), text.end());
|
||||||
|
auto enter = BuildInputSteps({{"type", "key_press"}, {"key", "ENTER"}}, geometry);
|
||||||
|
for (auto &step : enter) step.delay += 350;
|
||||||
|
steps.insert(steps.end(), enter.begin(), enter.end());
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
} // namespace crossdesk::cli::automation
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-03
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _AUTOMATION_PROTOCOL_H_
|
||||||
|
#define _AUTOMATION_PROTOCOL_H_
|
||||||
|
|
||||||
|
#include <frame_image.h>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk::cli::automation {
|
||||||
|
using nlohmann::json;
|
||||||
|
void Fields(const json &, std::initializer_list<const char *> names);
|
||||||
|
int Number(const json &, const char *key, int low, int high, int fallback);
|
||||||
|
uint64_t Sequence(const json &, const char *key, uint64_t fallback = 0);
|
||||||
|
bool Flag(const json &, const char *key, bool fallback = false);
|
||||||
|
std::string String(const json &, const char *key, size_t limit = 128);
|
||||||
|
|
||||||
|
struct InputGeometry {
|
||||||
|
int width, height, source_width, source_height;
|
||||||
|
ImageRegion region;
|
||||||
|
};
|
||||||
|
enum class StepKind { Pointer, Key, Text };
|
||||||
|
struct Step {
|
||||||
|
StepKind kind = StepKind::Pointer;
|
||||||
|
int delay = 0, key = 0, flag = 0, wheel = 0;
|
||||||
|
float x = 0, y = 0;
|
||||||
|
bool down = false;
|
||||||
|
std::string text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure validation and translation: no session access or input side effects.
|
||||||
|
std::vector<Step> BuildInputSteps(const json &action,
|
||||||
|
const InputGeometry &geometry);
|
||||||
|
std::vector<Step> BuildUnlockSteps(const std::string &password, bool macos);
|
||||||
|
} // namespace crossdesk::cli::automation
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Native automation protocol
|
||||||
|
|
||||||
|
`crossdesk-cli agent --stdio --password-fd 3 --video-ws` is the private native adapter used by CrossDesk MCP. It is separate from the existing terminal-oriented `bridge` protocol. This is **not MCP on stdin**: the TypeScript service owns the public MCP transport and tool schemas.
|
||||||
|
|
||||||
|
Optional native arguments are `--server HOST`, `--port PORT`, `--quality PRESET` and `--log-dir DIRECTORY`. The caller creates the log directory. Diagnostic stdout is redirected to stderr; only protocol responses use the preserved output descriptor. Passwords travel over descriptor 3, terminated by newline or EOF, and never appear in JSON requests. `--video-ws` starts an authenticated, native loopback WebSocket listener on an ephemeral port. There is no video file descriptor. Agent mode selects the high-frame-rate preview tier at up to 1920 × 1080 by default; negotiated remote capabilities and network conditions determine the delivered rate. Each process accepts one connection attempt; retry authentication with a new process and credential pipe.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
One UTF-8 JSON object per line, maximum request 64 KiB and nesting depth 32. Use nonnegative integer request IDs. Responses may complete out of order; correlate by ID.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":1,"method":"hello","protocol":"crossdesk-automation/1"}
|
||||||
|
{"id":1,"ok":true,"result":{"protocol":"crossdesk-automation/1","image_formats":["image/png"],"action_completion":"submitted","human_pause":true,"max_request_bytes":65536,"max_response_bytes":4194304,"operation_cache_entries":128,"operation_cache_ms":300000}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown fields and malformed arguments are rejected. Error responses contain a stable code, retryability and whether input may already have been submitted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":2,"ok":false,"error":{"code":"CONTROL_DENIED","retryable":false,"side_effect_possible":false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The output queue is bounded to 64 messages and 8 MiB; a blocked output pipe terminates after 10 seconds without progress. EOF, signals and `exit` disconnect and release held input. A caller that needs responses must keep stdin open until they arrive.
|
||||||
|
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
| Method | Parameters in addition to `id`/`method` |
|
||||||
|
| --- | --- |
|
||||||
|
| `hello` | `protocol: "crossdesk-automation/1"` |
|
||||||
|
| `connect` | `device` (9 digits), `mode: "observe" | "control"`, `display` (0–63), optional `decode_path: "browser" / "minirtc"`, `decoder: "auto" / "software"` |
|
||||||
|
| `status` | none |
|
||||||
|
| `screenshot` | optional `region: {x,y,width,height}`, `max_edge` (64–2048), `after_frame_id`, `generation`, `timeout_ms` (0–5000) |
|
||||||
|
| `video_open` | `ticket`, `view`, `view_generation`, `session`, `origin`, `apps`; requires `--video-ws` and a connected session |
|
||||||
|
| `video_revoke` | optional `view`; omit to revoke all video grants and sockets |
|
||||||
|
| `preview` | optional `max_edge` (64–960), `after_frame_id`, `generation`, `timeout_ms` (0–5000); no region |
|
||||||
|
| `action` | `operation_id`, `observation_id`, `action` |
|
||||||
|
| `unlock_prepare` | `observation_id`; validates control, fresh frames and text-input support, returns `generation` and `geometry_epoch` |
|
||||||
|
| `unlock` | `operation_id`, `observation_id`, `password`; trusted local credential submission to the focused system password field |
|
||||||
|
| `control` | `enabled` boolean; cannot override observe-only permission or human pause |
|
||||||
|
| `viewer_control` | `operation: "pause" | "resume" | "disconnect"`; restricted to trusted UI by the MCP adapter |
|
||||||
|
| `terminal_frame` | `columns` (1–160), `rows` (1–60), `colors` (8/24/256), `cell_aspect` (0.2–2); private terminal viewer only |
|
||||||
|
| `display` | `index` (0–63) |
|
||||||
|
| `cancel` | `request_id`; cancels queued/in-flight captures or input sequences |
|
||||||
|
| `disconnect`, `exit` | none; `exit` also ends the process |
|
||||||
|
|
||||||
|
`connect` returns immediately after initiating transport. Poll `status` until `state == "ready"`; this requires a frame from the current display generation. Status includes `transport_state`, `geometry_epoch`, `frame_age_ms`, `source_size`, `control_allowed`, `human_paused`, host platform, displays and transport diagnostics.
|
||||||
|
|
||||||
|
During initial `registering`, `MiniRtcSignalReconnecting` keeps the existing signaling peer and its backoff worker alive within the original 30-second connection deadline. Expiry in that phase reports `signaling_timeout`; expiry after submitting a device join reports `connection_timeout`. Certificate validation and login rejection terminate the attempt immediately. A signaling interruption after join submission also terminates the attempt. Explicit cancellation closes the peer and its retry worker. The MCP adapter forwards recognized status errors as an optional `error.reason` alongside its public error category.
|
||||||
|
|
||||||
|
## Live video
|
||||||
|
|
||||||
|
`doctor --json` advertises `automation_video_transport: "loopback-websocket/1"`, `automation_video_protocols: ["h264-annexb/1", "nv12/1"]`, and `automation_video_decoders: ["auto", "software"]`. `hello` repeats these without the `automation_` prefix. The MCP adapter requires the transport capability, so an old pipe-only CLI is rejected. Node spawns only stdin, stdout, stderr, and the credential pipe; live video never enters Node.
|
||||||
|
|
||||||
|
The terminal viewer additionally requires `automation_terminal_protocol: "ansi/1"` in `doctor` and `terminal_protocol: "ansi/1"` in `hello`. `terminal_frame` asynchronously converts the current decoded frame using the existing ANSI quadrant renderer and returns `columns`, `rows`, `lines`, `generation`, `geometry_epoch`, and a nullable `cursor` with zero-based terminal `column`/`row`. Only one render may be in flight; obsolete geometry or frames older than two seconds are rejected. These rendered text rows travel through the private authenticated terminal IPC, never through public MCP tools or model content. The viewer requests at most 8 FPS and waits for terminal writes before requesting another frame. It shares the automation connection; no second media session is opened.
|
||||||
|
|
||||||
|
Status includes `cursor` with normalized full-frame `x`/`y`, `visible`, `generation`, `geometry_epoch`, `width`, and `height`. The native layer updates it after each successfully sent pointer step, including drag steps, using the same ROI-to-source mapping as remote input. It hides on pause/control disable and clears on geometry changes. It is an Agent position indicator, not remote cursor telemetry or execution confirmation; observation PNGs and source video remain unchanged.
|
||||||
|
|
||||||
|
Authenticated video sockets also receive small JSON text messages with `type: "cursor"` and these fields, independently of binary video frames. Pointer updates are coalesced to at most 30 Hz, retained for newly authorized viewers, and deferred under socket backpressure; they require no video ACK. Browser/Apps render a separate transparent overlay with the same aspect-ratio fitting as the video, including when no new video frame arrives.
|
||||||
|
|
||||||
|
`connect.decode_path` defaults to `browser` (compressed H.264), or selects `minirtc` (decoded NV12). `connect.decoder` defaults to `auto` (hardware preferred, software when unavailable); `software` selects the native software decoder. Both paths maintain native screenshot frames with the selected decoder. These choices are fixed for the connection. Status exposes `video` and the reported `native_video_decoder` backend name. PNG encoding runs only when requested. The legacy `preview` image method remains available to private protocol callers but is not used by MCP panels.
|
||||||
|
|
||||||
|
### Authorization and transport
|
||||||
|
|
||||||
|
The trusted adapter generates 24 random bytes as a 32-character base64url `ticket` and sends `video_open` over private JSON-RPC. `view` and `session` are also 32-character base64url IDs; `view_generation` is positive. `origin` is the Node panel's exact `http://127.0.0.1:PORT` origin. `apps` permits an opaque (`null`) or HTTPS MCP Apps origin, but still requires the same one-time ticket. No password is included.
|
||||||
|
|
||||||
|
The response contains `{url, transport, protocol, decoder, expires_in_ms:15000}`. Node supplies the ticket to the authorized UI. The browser opens the returned `ws://127.0.0.1:PORT/api/video` directly and sends `{"ticket":"..."}` as its first text message within 3 seconds. Tickets expire after 15 seconds, are consumed once, and are bound to a view/session plus the native display generation. The native service checks the exact Host and path, RFC 6455 version, and authorized Origin. It does not serve HTTP content or accept remote input commands. Logs are disabled for this listener; tickets never go in URLs or model-visible results.
|
||||||
|
|
||||||
|
The listener uses its own I/O thread, at most 16 WebSocket candidates and 8 authorized viewers. Client messages are limited to 1024 bytes. Revocation, disconnect, native generation changes, or shutdown cancel sockets and invalidate tickets, including partially sent frames. Pause only disables Agent control; viewing continues. The adapter explicitly revokes a removed or replaced view, and terminates the native session if revocation cannot be confirmed. Native processes own ephemeral ports, so panel CSP allows `ws://127.0.0.1:*`, restricted to the literal loopback address; the control HTTP origin remains exact.
|
||||||
|
|
||||||
|
### Binary messages and flow control
|
||||||
|
|
||||||
|
Each binary WebSocket message contains one complete frame, **without the old pipe length prefix**. Integers are big-endian. For `h264-annexb/1`:
|
||||||
|
|
||||||
|
| Offset | Type | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | u32 | `0x43445631` (`CDV1`) |
|
||||||
|
| 4 | u32 | Display/connection generation |
|
||||||
|
| 8 | u32 | Receive sequence; gaps require a new keyframe |
|
||||||
|
| 12 | u64 | Local monotonic receive timestamp in microseconds |
|
||||||
|
| 20 | u32 | Flags: 1 = IDR keyframe, 0 = delta |
|
||||||
|
| 24 | bytes | Annex B H.264; keyframes include SPS and PPS |
|
||||||
|
|
||||||
|
Access units are capped at 4 MiB. Both the receive tap and the I/O handoff are bounded to 60 frames / 8 MiB. Overflow discards dependent frames until an IDR. After receiving each message the browser sends `{"type":"ack","generation":N,"sequence":N}` directly to the native socket, in order. This acknowledges receipt, not decode, presentation, or remote execution. Per-viewer unacknowledged output is capped at 8 frames; at 1 MiB outstanding, further frames are skipped until space is available (a single keyframe may exceed that threshold). Dropping a dependent picture requires a new keyframe; `{"type":"keyframe"}` requests recovery, rate-limited to once per second.
|
||||||
|
|
||||||
|
For `nv12/1`, magic is `0x43444e31` (`CDN1`). Offsets 4, 8, 12 and 20 remain generation, decoded sequence, local monotonic decoded-frame queue time in microseconds, and flags (always 1). Offsets 24 and 28 are u32 width and height; offset 32 starts tightly packed Y followed by interleaved UV. Dimensions must be even, at most 8192 per axis / 16777216 pixels. Message size must equal `32 + width * height * 3 / 2` (at most 25165856 bytes). Color is limited-range BT.601, matching the existing PNG conversion. Sequence gaps are allowed.
|
||||||
|
|
||||||
|
NV12 retains one latest picture in the I/O handoff and at most one unacknowledged picture per viewer. New pictures are skipped for a slow viewer without blocking others. Each prepared WebSocket message is immutable and shared between recipients, avoiding per-viewer payload copies. There is still a native copy into the WebSocket payload and raw pixel transfer to the browser; this is not GPU zero-copy. A viewer whose oldest outstanding frame remains unacknowledged for 10 seconds is disconnected. Hidden/unloaded pages close their native socket. The browser replaces pending raw pictures and does not decode H.264 in this mode.
|
||||||
|
|
||||||
|
`status.video_output` includes `transport`, `viewers`, I/O handoff `queued` / `queued_bytes`, aggregate `unacknowledged_bytes`, and `dropped` (handoff replacements plus per-viewer congestion skips). These diagnostics contain no video payload. The original receive-tap SPS/PPS and keyframe state remain available for H.264.
|
||||||
|
|
||||||
|
The MiniRTC `MiniRtcParams` structure appends `on_receive_encoded_video`, changing its ABI size. Rebuild consumers and MiniRTC together; zero-initialize `MiniRtcParams`. The callback borrows frame bytes on the receive/decode queue and must not retain the pointer. Returning nonzero requests a remote keyframe. No remote wire-protocol change is required.
|
||||||
|
|
||||||
|
## Images and observations
|
||||||
|
|
||||||
|
Images return `{mime_type:"image/png",data:"<base64>",metadata:{...}}`. Metadata includes `generation`, `geometry_epoch`, `frame_id`, `display_index`, `source_size`, `source_rect`, `image_size` and `frame_age_ms`; only screenshots create `observation_id`. Preview images cannot authorize model input.
|
||||||
|
|
||||||
|
NV12 frames must be tightly packed, even dimensions, at most 8192 per axis and 16 million pixels. Conversion/PNG encoding runs on one worker with no `ControllerSession` calls. The owner loop continues processing cancellation and input release. Screenshots have priority over queued previews. An encoding from an obsolete generation or geometry epoch is discarded. Frames older than two seconds are not used for a new image. PNG limits are 2 MiB for observations and 256 KiB for previews; the encoder reduces dimensions when necessary.
|
||||||
|
|
||||||
|
`after_frame_id` requires `generation`. A screenshot waits up to `timeout_ms` for a newer frame; a preview can return `{unchanged:true, frame_age_ms, stale, generation, frame_id}`. Neither path treats an image as an input acknowledgement.
|
||||||
|
|
||||||
|
`timeout_ms` bounds frame/worker availability, not the subsequent PNG encoding. With `timeout_ms: 0`, a fresh eligible frame is encoded immediately if the worker and queue are idle; otherwise return `FRAME_TIMEOUT` or `BUSY`. Captures of the same priority retain arrival order. Encoding still rejects obsolete geometry and frames that become older than two seconds. Terminal rendering also honors cancellation without sending a second result when the worker finishes.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
All coordinates refer to the immutable returned observation image. The native layer maps them through its source rectangle to normalized remote coordinates, without terminal layout padding. Coordinates must satisfy `0 <= x < image_width` and `0 <= y < image_height`. Observations expire after 30 seconds, retain at most 32 entries, and are invalidated by geometry/display generation changes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":10,"method":"action","operation_id":"click-1","observation_id":"obs_2_1","action":{"type":"click","x":120,"y":80,"button":"left"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported payloads:
|
||||||
|
|
||||||
|
- `move`: `x`, `y`.
|
||||||
|
- `click`, `double_click`: `x`, `y`, optional `button` (`left`, `right`, `middle`).
|
||||||
|
- `scroll`: `x`, `y`, `direction` (`up`, `down`, `left`, `right`), optional `ticks` (1–10).
|
||||||
|
- `drag`: `path` of 2–128 `{x,y}` points, optional `duration_ms` (100–2000), optional `button` (`left`, `right`, `middle`; default `left`).
|
||||||
|
- `key_press`: `key`; `hotkey`: 2–6 unique `keys`.
|
||||||
|
- `type_text`: `text`, at most 16 KiB UTF-8; requires negotiated remote text-input capability.
|
||||||
|
|
||||||
|
All pointer actions and `key_press` accept optional `modifiers`, an array of at most four unique names from `CTRL`, `ALT`, `SHIFT`, `META`. They are pressed before the action and released afterwards in reverse order; a `key_press` cannot also list its own key as a modifier. `hotkey` already describes the complete chord and does not accept `modifiers`. Punctuation VK names are `SEMICOLON`, `EQUAL`, `COMMA`, `MINUS`, `PERIOD`, `SLASH`, `BACKQUOTE`, `BRACKET_LEFT`, `BACKSLASH`, `BRACKET_RIGHT`, `QUOTE`; these name keys, not layout-independent text.
|
||||||
|
|
||||||
|
Only one input sequence runs at once. Timed steps are advanced by the owner loop; cancellation, disconnect, display changes and pause release held buttons/keys. A failed send is conservatively considered potentially submitted.
|
||||||
|
|
||||||
|
New actions require a current frame no older than two seconds as well as a valid observation. Cached operation lookups remain available with a stalled feed or paused control. Failed key/button releases remain tracked for subsequent cleanup, including after control is disabled; a successful local send still does not prove remote execution. Invalid display indices leave existing observations and input sequences intact.
|
||||||
|
|
||||||
|
Results use `status: submitted | in_progress | failed_before_submit | outcome_unknown`, with `operation_id`, `retryable`, `side_effect_possible` and, for completed actions, `generation`/`after_frame_id`. The same operation ID and arguments retrieve the cached result; different arguments conflict. Retention is bounded to 128 entries, with entries older than five minutes eligible for removal on a new action. Never retry an uncertain effect with a new ID. `submitted` does not claim remote execution.
|
||||||
|
|
||||||
|
The native process trusts its parent. The parent must authenticate panel-only `viewer_control` calls and protect descriptor 3; this private pipe does not isolate arbitrary programs running as the same OS user.
|
||||||
|
|
||||||
|
## System unlock
|
||||||
|
|
||||||
|
`doctor` advertises `automation_unlock_protocol: "password/1"`; `hello` advertises `unlock_protocol: "password/1"`. The adapter enables the additional unlock flow when this capability is present.
|
||||||
|
|
||||||
|
After the Agent identifies the system lock screen and focuses its password field, `unlock_prepare` validates the supplied observation and returns its display generation. The trusted adapter may then ask the user in its local panel, or retrieve the separately configured system credential. Before submission it captures a fresh observation and verifies that the display generation and geometry still match.
|
||||||
|
|
||||||
|
`unlock` accepts a password of 1–256 UTF-8 bytes, rejecting NUL, newline, carriage return and tab. It uses the existing input scheduler to select the focused field contents, type the password and press Enter. Selection uses Command+A on macOS and Ctrl+A on other platforms. Control permission, human pause, observation freshness and cancellation follow the action rules. The operation cache stores the observation and unlock operation kind, with secret text confined to the transient input request and scheduled text step. Completed or cancelled steps clear their text buffers.
|
||||||
|
|
||||||
|
The result follows the existing action result format. Repeated operation IDs retrieve the first result, and the caller verifies the visible desktop using a subsequent screenshot. Password persistence and the five-minute user-input request lifetime belong to the TypeScript adapter.
|
||||||
|
|
||||||
|
## Input receipts and visual conditions
|
||||||
|
|
||||||
|
Updated desktop hosts answer the versioned `automation` capability handshake. When `capabilities.input_ack` is true, input travels through the host input dispatcher and the action result includes `remote_execution` with `applied`, `failed` or `pending`. A receipt confirms native input processing. The caller checks the target application's resulting state separately. Legacy hosts return `remote_execution` as `unsupported`. Missing receipts terminate the bounded wait with `REMOTE_ACK_TIMEOUT` and an uncertain outcome.
|
||||||
|
|
||||||
|
`wait_frame` accepts an `observation_id`, `condition` (`changed` or `stable`), `stable_ms` (100–5000, default 500), and `timeout_ms` (0–25000, default 5000). It watches the observation's source region, checks both luma and chroma, and returns `matched`, `wait_timed_out`, `generation`, and `frame_id`. Display changes, disconnection and explicit cancellation terminate pending waits. `crossdesk_wait` exposes this operation through MCP and includes a fresh screenshot when its condition matches.
|
||||||
|
|
||||||
|
## Clipboard
|
||||||
|
|
||||||
|
`clipboard` accepts `operation: get | set`. Writes additionally require `operation_id` and `text` (up to 16384 UTF-8 bytes, including an empty string to clear). Reads return up to 128 KiB of text. The existing chunked `web-clipboard` stream carries values and applied/rejected replies. Additive `read`, `empty`, and `clear` frames are enabled by the remote `clipboard` capability. Clipboard access requires control permission and respects human pause. Cancellation of an issued write reports an uncertain effect. Repeated write operation IDs retrieve the cached result.
|
||||||
|
|
||||||
|
## File operations
|
||||||
|
|
||||||
|
`files` supports `list`, `list_result`, `list_cancel`, `upload`, `download`, `status`, and `cancel`. Listing requests return a numeric `request_id`. Transfer requests require an `operation_id`, absolute `paths`, and an existing absolute `destination` directory. Progress and listing pages use `offset` and return `next_offset` with at most 256 entries.
|
||||||
|
|
||||||
|
The desktop and CLI share `FileBrowser` and the existing file transfer engine. Preparation and completion messages carry a transfer identifier to bind incoming files to the requested destination. A cancelled transfer cleans unfinished staging files. The capability handshake advertises `files` on compatible hosts. Queries and cancellation of existing tasks remain available during human pause.
|
||||||
|
|
||||||
|
## System control
|
||||||
|
|
||||||
|
`system` accepts `command: status | lock | send_sas`. Commands that change system state require an `operation_id` and control permission. Status queries are available during human pause. The host executes system operations through its application-thread queue, checks connection authorization again, and expires queued operations after two seconds. Windows secure attention uses the existing service integration.
|
||||||
|
|
||||||
|
`lock_state` is `locked`, `unlocked`, or `unknown`. macOS queries the current console session, Windows uses its session lock state, and Linux queries the current logind session's `LockedHint`. The MCP unlock workflow verifies this state on compatible hosts and returns `verified` when the system reports an unlocked session.
|
||||||
|
|
||||||
|
## Held input
|
||||||
|
|
||||||
|
`key_down`, `key_up`, `mouse_down`, and `mouse_up` extend the action union. Each successful action renews a fifteen-second lease for held input. Existing held modifiers survive subsequent clicks, movement and hotkeys. `release_inputs` releases the controller's tracked keys/buttons and cancels current input without requiring an observation. Errors, human pause, display changes and disconnection release held input.
|
||||||
|
|
||||||
|
`key_press` and `hotkey` accept `duration_ms` from 10 to 30000. Drags accept durations up to 30000 milliseconds. Extended key names include F13–F24, NUMPAD0–NUMPAD9, arithmetic keypad keys, lock/menu/print keys and media controls. Target platform key mapping determines availability. Text input requires held keys/buttons to be released first.
|
||||||
|
|
||||||
|
## Display management
|
||||||
|
|
||||||
|
`video_settings` accepts optional `fps`, even `width`/`height`, `quality`, and `max_bitrate_bps`. The controller checks negotiated preview capabilities and publishes the requested cadence and acceptance state. Supported MCP frame-rate tiers are 1, 8, 15, 30 and 60.
|
||||||
|
|
||||||
|
The trusted adapter may use `display` with `preserve_input: true` during a cross-display drag. It captures a fresh observation after each switch, maps the requested point from its source display image, and releases input in a final cleanup step. Ordinary display selection releases input. Multi-display capture returns sequential observations and restores the previous display.
|
||||||
|
|
||||||
|
## MCP connection recovery
|
||||||
|
|
||||||
|
The MCP adapter optionally monitors saved, persistently authorized devices. Eligible failures receive bounded backoff retries within sixty seconds. Each replacement has a new session ID, device authorization is checked again, and disabled control remains disabled. User pause and explicit disconnect stop recovery. Queries against an old session can discover its replacement, while mutations require the replacement's ID and a fresh observation. MCP observation IDs are namespaced by session and translated back to native observation IDs at the adapter boundary.
|
||||||
@@ -7,6 +7,8 @@ CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启
|
|||||||
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
||||||
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
||||||
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
|
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
|
||||||
|
- MCP 实时面板新增 MiniRTC 的 `on_receive_encoded_video` 回调,转发解码前的完整 H.264 Annex B 帧;回调及关键帧请求由当前子模块源码提供。`MiniRtcParams` 新增字段改变 ABI 大小,CLI 与 MiniRTC 必须一起重建;发布时同步记录包含该改动的子模块提交。
|
||||||
|
- 自动化图像编码复用 MiniRTC 已有的 libyuv 2025.8.14,并通过 Xmake 固定静态 libpng v1.6.59;libpng 的压缩依赖沿用平台 zlib。新增依赖的许可证和对应源码也应纳入正式发行的依赖材料。
|
||||||
|
|
||||||
MiniRTC 已在提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 中合入凭据日志脱敏:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 仅按主仓库记录初始化子模块,不再应用独立补丁;更新 MiniRTC 时只需更新子模块提交。共享子模块中的脱敏同样适用于桌面程序;该修改没有改变鉴权、TLS、TURN 或媒体协议。
|
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。
|
需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。
|
||||||
|
|
||||||
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
|
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。MiniRtcInit 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
|
||||||
|
|
||||||
交互界面区分 `Connecting to server`(连接信令)、`Connecting to device`(连接设备)、`Connected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `P2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。
|
交互界面区分 `MiniRtcConnectionConnecting to server`(连接信令)、`MiniRtcConnectionConnecting to device`(连接设备)、`MiniRtcConnectionConnected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `MiniRtcTraversalP2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。
|
||||||
|
|
||||||
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
|
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
|
||||||
|
|
||||||
@@ -183,6 +183,6 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
|
|||||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||||
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
||||||
|
|
||||||
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
|
终端模式无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker 或 IP 直连。终端宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端。Agent 的 PNG 截图和结构化操作由独立的 [自动化模式](automation-protocol.md) 和 [MCP 服务](../../mcp/README.md) 提供。
|
||||||
|
|
||||||
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
||||||
|
|||||||
+28
-10
@@ -1,4 +1,5 @@
|
|||||||
#include "terminal_io.h"
|
#include "terminal_io.h"
|
||||||
|
#include "agent.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
@@ -616,8 +617,8 @@ int View(SessionOptions options, const std::string &device,
|
|||||||
const auto bps = st["network"]["video_bps"].get<uint64_t>();
|
const auto bps = st["network"]["video_bps"].get<uint64_t>();
|
||||||
const auto traversal = st["network"]["traversal"].get<int>();
|
const auto traversal = st["network"]["traversal"].get<int>();
|
||||||
bar.network =
|
bar.network =
|
||||||
std::string(traversal == P2P ? "P2P "
|
std::string(traversal == MiniRtcTraversalP2P ? "P2P "
|
||||||
: traversal == Relay ? "RELAY "
|
: traversal == MiniRtcTraversalRelay ? "RELAY "
|
||||||
: "Video ") +
|
: "Video ") +
|
||||||
(bps < 1000 ? std::to_string(bps) + " bps"
|
(bps < 1000 ? std::to_string(bps) + " bps"
|
||||||
: bps < 1000000 ? std::to_string(bps / 1000) + " kbps"
|
: bps < 1000000 ? std::to_string(bps / 1000) + " kbps"
|
||||||
@@ -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 view <9-digit-device-id> [options] (compatibility alias)
|
||||||
crossdesk-cli doctor [--json]
|
crossdesk-cli doctor [--json]
|
||||||
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
|
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
|
||||||
|
crossdesk-cli agent --stdio [--password-fd 3] [--video-ws] [--server host] [--port 9099]
|
||||||
|
|
||||||
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
|
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
|
||||||
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
|
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
|
||||||
@@ -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.
|
Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged.
|
||||||
Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS.
|
Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS.
|
||||||
bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor.
|
bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor.
|
||||||
|
agent: crossdesk-automation/1, structured actions and PNG screenshots; no terminal required.
|
||||||
No IP direct connection, audio playback, clipboard or file transfer support.
|
No IP direct connection, audio playback, clipboard or file transfer support.
|
||||||
)";
|
)";
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -1087,12 +1090,19 @@ int main(int argc, char **argv) {
|
|||||||
{"kitty", "probe_on_view"},
|
{"kitty", "probe_on_view"},
|
||||||
{"sixel", "probe_on_view"},
|
{"sixel", "probe_on_view"},
|
||||||
{"audio_playback", false},
|
{"audio_playback", false},
|
||||||
{"bridge_version", 1}};
|
{"bridge_version", 1},
|
||||||
|
{"automation_terminal_protocol", "ansi/1"},
|
||||||
|
{"automation_protocol", "crossdesk-automation/1"},
|
||||||
|
{"automation_unlock_protocol", "password/1"},
|
||||||
|
{"automation_video", "h264-annexb/1"},
|
||||||
|
{"automation_video_transport", "loopback-websocket/1"},
|
||||||
|
{"automation_video_protocols", {"h264-annexb/1", "nv12/1"}},
|
||||||
|
{"automation_video_decoders", {"auto", "software"}}};
|
||||||
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
int i = 2;
|
int i = 2;
|
||||||
if (command != "view" && command != "bridge") {
|
if (command != "view" && command != "bridge" && command != "agent") {
|
||||||
if (command.find_first_not_of("0123456789") != std::string::npos)
|
if (command.find_first_not_of("0123456789") != std::string::npos)
|
||||||
throw std::invalid_argument(
|
throw std::invalid_argument(
|
||||||
"expected a 9-digit device ID or a command; use --help");
|
"expected a 9-digit device ID or a command; use --help");
|
||||||
@@ -1105,6 +1115,8 @@ int main(int argc, char **argv) {
|
|||||||
double zoom = 1, cell_aspect = 0;
|
double zoom = 1, cell_aspect = 0;
|
||||||
bool control = false, stdio = false, native = false, fps_explicit = false;
|
bool control = false, stdio = false, native = false, fps_explicit = false;
|
||||||
int secret_fd = 3;
|
int secret_fd = 3;
|
||||||
|
bool video_websocket = false;
|
||||||
|
if (command == "agent") options.fps = 30;
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
if (i >= argc)
|
if (i >= argc)
|
||||||
throw std::invalid_argument("device ID required");
|
throw std::invalid_argument("device ID required");
|
||||||
@@ -1124,8 +1136,10 @@ int main(int argc, char **argv) {
|
|||||||
std::string a = argv[i];
|
std::string a = argv[i];
|
||||||
if (a == "--control" && command == "view")
|
if (a == "--control" && command == "view")
|
||||||
control = true;
|
control = true;
|
||||||
else if (a == "--stdio" && command == "bridge")
|
else if (a == "--stdio" && command != "view")
|
||||||
stdio = true;
|
stdio = true;
|
||||||
|
else if (a == "--video-ws" && command == "agent")
|
||||||
|
video_websocket = true;
|
||||||
else {
|
else {
|
||||||
if (i + 1 >= argc)
|
if (i + 1 >= argc)
|
||||||
throw std::invalid_argument("option value missing");
|
throw std::invalid_argument("option value missing");
|
||||||
@@ -1134,6 +1148,8 @@ int main(int argc, char **argv) {
|
|||||||
options.host = value;
|
options.host = value;
|
||||||
else if (a == "--port")
|
else if (a == "--port")
|
||||||
options.port = number(value, 1, 65535);
|
options.port = number(value, 1, 65535);
|
||||||
|
else if (a == "--log-dir" && command == "agent")
|
||||||
|
options.log_directory = value;
|
||||||
else if (a == "--fps" && command == "view") {
|
else if (a == "--fps" && command == "view") {
|
||||||
options.fps = number(value, 1, 30);
|
options.fps = number(value, 1, 30);
|
||||||
fps_explicit = true;
|
fps_explicit = true;
|
||||||
@@ -1158,7 +1174,7 @@ int main(int argc, char **argv) {
|
|||||||
} else if (a == "--zoom" && command == "view") {
|
} else if (a == "--zoom" && command == "view") {
|
||||||
native = value == "native";
|
native = value == "native";
|
||||||
zoom = native ? 1 : number(value, 1, 32);
|
zoom = native ? 1 : number(value, 1, 32);
|
||||||
} else if (a == "--password-fd" && command == "bridge")
|
} else if (a == "--password-fd" && command != "view")
|
||||||
secret_fd = number(value, 3, 1024);
|
secret_fd = number(value, 3, 1024);
|
||||||
else
|
else
|
||||||
throw std::invalid_argument("unsupported option");
|
throw std::invalid_argument("unsupported option");
|
||||||
@@ -1167,14 +1183,14 @@ int main(int argc, char **argv) {
|
|||||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
||||||
renderer != "sixel")
|
renderer != "sixel")
|
||||||
throw std::invalid_argument("unknown renderer");
|
throw std::invalid_argument("unknown renderer");
|
||||||
if (command == "bridge" && !stdio)
|
if (command != "view" && !stdio)
|
||||||
throw std::invalid_argument("bridge requires --stdio");
|
throw std::invalid_argument("automation and bridge require --stdio");
|
||||||
Descriptor output(Duplicate(1));
|
Descriptor output(Duplicate(1));
|
||||||
if (output.value < 0)
|
if (output.value < 0)
|
||||||
throw std::runtime_error("cannot preserve output channel");
|
throw std::runtime_error("cannot preserve output channel");
|
||||||
// All library stdout writes go to diagnostics. Only Output uses the saved
|
// All library stdout writes go to diagnostics. Only Output uses the saved
|
||||||
// fd.
|
// fd.
|
||||||
options.log_directory = Logs();
|
if (options.log_directory.empty()) options.log_directory = Logs();
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
||||||
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
||||||
@@ -1193,7 +1209,9 @@ int main(int argc, char **argv) {
|
|||||||
return command == "view" ? View(options, device, renderer, control,
|
return command == "view" ? View(options, device, renderer, control,
|
||||||
output.value, detail, zoom, native,
|
output.value, detail, zoom, native,
|
||||||
cell_aspect, fps_explicit)
|
cell_aspect, fps_explicit)
|
||||||
: 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) {
|
} catch (const std::exception &e) {
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
|
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
|
||||||
|
|||||||
+13
-11
@@ -36,17 +36,19 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stdio = ['inherit', 'inherit', 'inherit'];
|
const stdio = ['inherit', 'inherit', 'inherit'];
|
||||||
if (args[0] === 'bridge') {
|
if (args[0] === 'bridge' || args[0] === 'agent') {
|
||||||
const index = args.lastIndexOf('--password-fd');
|
for (const [option, fallback] of [['--password-fd', 3]]) {
|
||||||
const fd = index === -1 ? 3 : Number(args[index + 1]);
|
const index = args.lastIndexOf(option);
|
||||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
const fd = index === -1 ? fallback : Number(args[index + 1]);
|
||||||
try {
|
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||||
const stat = fs.fstatSync(fd);
|
try {
|
||||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
const stat = fs.fstatSync(fd);
|
||||||
while (stdio.length <= fd) stdio.push('ignore');
|
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||||
stdio[fd] = fd;
|
while (stdio.length <= fd) stdio.push('ignore');
|
||||||
}
|
stdio[fd] = fd;
|
||||||
} catch { /* The native bridge reports a missing credential descriptor. */ }
|
}
|
||||||
|
} catch { /* Native code reports unavailable descriptors. */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const child = spawn(binary, args, { stdio, shell: false });
|
const child = spawn(binary, args, { stdio, shell: false });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chunkdash/crossdesk-cli",
|
"name": "@chunkdash/crossdesk-cli",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CrossDesk remote desktop controller for the terminal",
|
"description": "CrossDesk remote desktop controller for the terminal",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ function inspect(binary, target) {
|
|||||||
}
|
}
|
||||||
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
|
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
|
||||||
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
|
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
|
||||||
|
if (doctor.automation_protocol !== 'crossdesk-automation/1')
|
||||||
|
throw new Error('Native binary does not report the expected automation protocol.');
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
function pack(directory, output) {
|
function pack(directory, output) {
|
||||||
|
|||||||
@@ -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")
|
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
|
||||||
|
|
||||||
add_requires("spdlog 1.14.1", {system = false})
|
add_requires("spdlog 1.14.1", {system = false})
|
||||||
|
add_requires("asio 1.32.0", "websocketpp 0.8.2", {system = false})
|
||||||
|
add_requires("libpng v1.6.59", {system = false, configs = {shared = false}})
|
||||||
if is_plat("windows") then
|
if is_plat("windows") then
|
||||||
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
||||||
end
|
end
|
||||||
|
|
||||||
target("crossdesk_controller")
|
target("crossdesk_controller")
|
||||||
set_kind("static")
|
set_kind("static")
|
||||||
add_deps("minirtc", "crossdesk_wire")
|
add_deps("minirtc", "crossdesk_wire", "crossdesk_file_browser")
|
||||||
add_packages("spdlog")
|
add_packages("spdlog", "libpng", "libyuv")
|
||||||
add_includedirs(path.join(controller_dir, "include"),
|
add_includedirs(path.join(controller_dir, "include"),
|
||||||
path.join(minirtc_dir, "src/api"), {public = true})
|
path.join(minirtc_dir, "src/api"), {public = true})
|
||||||
add_includedirs(path.join(minirtc_dir, "src/log"))
|
|
||||||
add_files(path.join(controller_dir, "src/*.cpp"))
|
add_files(path.join(controller_dir, "src/*.cpp"))
|
||||||
|
remove_files(path.join(controller_dir, "src/file_browser.cpp"))
|
||||||
target_end()
|
target_end()
|
||||||
|
|
||||||
target("crossdesk_terminal")
|
target("crossdesk_terminal")
|
||||||
@@ -32,12 +34,14 @@ target_end()
|
|||||||
|
|
||||||
target("crossdesk-cli")
|
target("crossdesk-cli")
|
||||||
set_kind("binary")
|
set_kind("binary")
|
||||||
set_version("0.1.6")
|
set_version("0.2.0")
|
||||||
add_deps("crossdesk_terminal")
|
add_deps("crossdesk_terminal")
|
||||||
add_files("main.cpp")
|
add_files("main.cpp")
|
||||||
|
add_files("agent.cpp", "automation_protocol.cpp", "video_server.cpp")
|
||||||
|
add_packages("asio", "websocketpp")
|
||||||
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
|
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
|
||||||
if is_plat("windows") then
|
if is_plat("windows") then
|
||||||
add_syslinks("advapi32", "user32")
|
add_syslinks("advapi32", "user32", "ws2_32")
|
||||||
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
||||||
elseif is_plat("linux") then
|
elseif is_plat("linux") then
|
||||||
add_syslinks("rt")
|
add_syslinks("rt")
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
|
|||||||
VOL_NAME="Install ${APP_NAME_UPPER}"
|
VOL_NAME="Install ${APP_NAME_UPPER}"
|
||||||
|
|
||||||
echo "delete old files"
|
echo "delete old files"
|
||||||
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp CrossDesk_dmg_temp
|
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp build_pkg_scripts CrossDesk_dmg_temp
|
||||||
|
|
||||||
mkdir -p build_pkg_temp
|
mkdir -p build_pkg_temp
|
||||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||||
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
|
|||||||
|
|
||||||
echo ".app created successfully."
|
echo ".app created successfully."
|
||||||
|
|
||||||
mkdir -p build_pkg_scripts
|
PKG_SCRIPT_ARGS=()
|
||||||
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
|
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
|
||||||
chmod +x build_pkg_scripts/postinstall
|
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
|
mkdir -p build_pkg_resources
|
||||||
cat > build_pkg_resources/welcome.html <<EOF
|
cat > build_pkg_resources/welcome.html <<EOF
|
||||||
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
|
|||||||
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
||||||
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
||||||
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
||||||
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p>
|
<p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
|
||||||
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -177,7 +181,7 @@ pkgbuild \
|
|||||||
--version "${APP_VERSION}" \
|
--version "${APP_VERSION}" \
|
||||||
--install-location "/Applications" \
|
--install-location "/Applications" \
|
||||||
--component "${APP_BUNDLE}" \
|
--component "${APP_BUNDLE}" \
|
||||||
--scripts build_pkg_scripts \
|
${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
|
||||||
build_pkg_temp/${APP_NAME}-component.pkg
|
build_pkg_temp/${APP_NAME}-component.pkg
|
||||||
|
|
||||||
cat > build_pkg_temp/Distribution <<EOF
|
cat > build_pkg_temp/Distribution <<EOF
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ DMG_NAME="${APP_NAME}-${PLATFORM}-${ARCH}-${APP_VERSION}.dmg"
|
|||||||
VOL_NAME="Install ${APP_NAME_UPPER}"
|
VOL_NAME="Install ${APP_NAME_UPPER}"
|
||||||
|
|
||||||
echo "delete old files"
|
echo "delete old files"
|
||||||
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp CrossDesk_dmg_temp
|
rm -rf "${APP_BUNDLE}" "${PKG_NAME}" "${DMG_NAME}" build_pkg_temp build_pkg_scripts CrossDesk_dmg_temp
|
||||||
|
|
||||||
mkdir -p build_pkg_temp
|
mkdir -p build_pkg_temp
|
||||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||||
@@ -132,9 +132,13 @@ find "${APP_BUNDLE}" -name '._*' -delete
|
|||||||
|
|
||||||
echo ".app created successfully."
|
echo ".app created successfully."
|
||||||
|
|
||||||
mkdir -p build_pkg_scripts
|
PKG_SCRIPT_ARGS=()
|
||||||
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
|
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
|
||||||
chmod +x build_pkg_scripts/postinstall
|
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
|
mkdir -p build_pkg_resources
|
||||||
cat > build_pkg_resources/welcome.html <<EOF
|
cat > build_pkg_resources/welcome.html <<EOF
|
||||||
@@ -165,7 +169,7 @@ cat > build_pkg_resources/welcome.html <<EOF
|
|||||||
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
<h1>欢迎安装 ${INSTALLER_TITLE}</h1>
|
||||||
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
<p>CrossDesk 将安装到“应用程序”文件夹。</p>
|
||||||
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
<p>首次启动时,CrossDesk 会引导你在系统设置中授予必要权限,包括辅助功能、录屏与系统录音等。</p>
|
||||||
<p>为避免旧版本授权残留造成状态误判,安装后可能需要重新授权。</p>
|
<p>正式签名版本正常升级时会保留已有权限。从未签名或 ad-hoc 旧版升级后,请退出旧版并启动新版。新版会迁移当前用户的录屏与辅助功能授权,并引导你重新授权。遇到异常可在“设置 → 系统权限”中重试或稍后处理。</p>
|
||||||
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
<p>安装完成后,请从“应用程序”文件夹启动 CrossDesk。</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -177,7 +181,7 @@ pkgbuild \
|
|||||||
--version "${APP_VERSION}" \
|
--version "${APP_VERSION}" \
|
||||||
--install-location "/Applications" \
|
--install-location "/Applications" \
|
||||||
--component "${APP_BUNDLE}" \
|
--component "${APP_BUNDLE}" \
|
||||||
--scripts build_pkg_scripts \
|
${PKG_SCRIPT_ARGS[@]+"${PKG_SCRIPT_ARGS[@]}"} \
|
||||||
build_pkg_temp/${APP_NAME}-component.pkg
|
build_pkg_temp/${APP_NAME}-component.pkg
|
||||||
|
|
||||||
cat > build_pkg_temp/Distribution <<EOF
|
cat > build_pkg_temp/Distribution <<EOF
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
export LC_ALL=C
|
||||||
|
|
||||||
|
# Migration is best effort. Installing the application must remain possible
|
||||||
|
# even when its old privacy records or the migration directory are unavailable.
|
||||||
|
if [[ $# -lt 3 ]]; then
|
||||||
|
echo "CrossDesk permission migration skipped because installer arguments are missing." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_BUNDLE="${3%/}${2%/}/CrossDesk.app"
|
||||||
|
if [[ ! -d "$APP_BUNDLE" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \
|
||||||
|
"$APP_BUNDLE/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
case "$BUNDLE_ID" in
|
||||||
|
cn.crossdesk.app|cn.crossdesk.CrossDesk|com.crossdesk.app|com.crossdesk.CrossDesk|com.kunkundi.crossdesk|com.kunkundi.CrossDesk)
|
||||||
|
;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if SIGNATURE="$(/usr/bin/codesign --display --verbose=4 "$APP_BUNDLE" 2>&1)"; then
|
||||||
|
if ! /usr/bin/grep -Fxq 'Signature=adhoc' <<< "$SIGNATURE"; then
|
||||||
|
# Keep any unfinished migration for users who have not launched yet.
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
elif [[ "$SIGNATURE" != *": code object is not signed at all" ]]; then
|
||||||
|
echo "Could not identify the old CrossDesk signature. Preserving migration state." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATE_DIR="${3%/}/Library/Application Support/CrossDesk"
|
||||||
|
if [[ "$EUID" -ne 0 || -L "$STATE_DIR" ]]; then
|
||||||
|
echo "Could not safely record the CrossDesk permission migration." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! /bin/mkdir -p "$STATE_DIR" ||
|
||||||
|
[[ "$(/usr/bin/stat -f '%u' "$STATE_DIR")" != 0 ]] ||
|
||||||
|
! /bin/chmod 755 "$STATE_DIR"; then
|
||||||
|
echo "CrossDesk migration directory is unavailable. Use permission repair after installation." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TEMP_FILE="$(/usr/bin/mktemp "$STATE_DIR/.permission-migration.XXXXXX")" || exit 0
|
||||||
|
trap '/bin/rm -f "$TEMP_FILE"' EXIT
|
||||||
|
MIGRATION_ID="$(/usr/bin/uuidgen)" || exit 0
|
||||||
|
if /usr/bin/plutil -create xml1 "$TEMP_FILE" &&
|
||||||
|
/usr/bin/plutil -insert schema -integer 1 "$TEMP_FILE" &&
|
||||||
|
/usr/bin/plutil -insert migration_id -string "$MIGRATION_ID" "$TEMP_FILE" &&
|
||||||
|
/usr/bin/plutil -insert legacy_bundle_id -string "$BUNDLE_ID" "$TEMP_FILE" &&
|
||||||
|
/usr/bin/plutil -insert app_path -string "${2%/}/CrossDesk.app" "$TEMP_FILE" &&
|
||||||
|
/usr/bin/plutil -convert json "$TEMP_FILE" &&
|
||||||
|
/bin/chmod 644 "$TEMP_FILE" &&
|
||||||
|
/bin/mv -f "$TEMP_FILE" "$STATE_DIR/permission-migration.json"; then
|
||||||
|
echo "Recorded CrossDesk permission migration. Quit the old app and launch the new version to continue."
|
||||||
|
else
|
||||||
|
echo "Could not record CrossDesk permission migration. Use permission repair after installation." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
APP_IDENTIFIER="cn.crossdesk.app"
|
|
||||||
|
|
||||||
# Keep known historical identifiers here. tccutil only resets identifiers that
|
|
||||||
# Launch Services can currently resolve, so path/db cleanup below remains a
|
|
||||||
# best-effort fallback for stale entries from unsigned or removed builds.
|
|
||||||
BUNDLE_IDENTIFIERS=(
|
|
||||||
"cn.crossdesk.app"
|
|
||||||
"cn.crossdesk.CrossDesk"
|
|
||||||
"com.crossdesk.app"
|
|
||||||
"com.crossdesk.CrossDesk"
|
|
||||||
"com.kunkundi.crossdesk"
|
|
||||||
"com.kunkundi.CrossDesk"
|
|
||||||
)
|
|
||||||
|
|
||||||
TCC_SERVICES=(
|
|
||||||
"ScreenCapture"
|
|
||||||
"Accessibility"
|
|
||||||
"Microphone"
|
|
||||||
"AudioCapture"
|
|
||||||
)
|
|
||||||
|
|
||||||
run_tccutil() {
|
|
||||||
local user_name="$1"
|
|
||||||
local user_id="$2"
|
|
||||||
local service="$3"
|
|
||||||
local bundle_id="$4"
|
|
||||||
|
|
||||||
if [ -n "$user_name" ] && [ -n "$user_id" ]; then
|
|
||||||
/bin/launchctl asuser "$user_id" \
|
|
||||||
/usr/bin/sudo -u "$user_name" \
|
|
||||||
/usr/bin/tccutil reset "$service" "$bundle_id" >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
/usr/bin/tccutil reset "$service" "$bundle_id" >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
reset_bundle_tcc() {
|
|
||||||
local user_name="$1"
|
|
||||||
local user_id="$2"
|
|
||||||
local bundle_id
|
|
||||||
local service
|
|
||||||
|
|
||||||
for bundle_id in "${BUNDLE_IDENTIFIERS[@]}"; do
|
|
||||||
if run_tccutil "$user_name" "$user_id" "All" "$bundle_id"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
for service in "${TCC_SERVICES[@]}"; do
|
|
||||||
run_tccutil "$user_name" "$user_id" "$service" "$bundle_id" || true
|
|
||||||
done
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_tcc_db() {
|
|
||||||
local db_path="$1"
|
|
||||||
|
|
||||||
if [ ! -f "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
/usr/bin/sqlite3 "$db_path" <<'SQL' >/dev/null 2>&1 || true
|
|
||||||
DELETE FROM access
|
|
||||||
WHERE service IN (
|
|
||||||
'kTCCServiceScreenCapture',
|
|
||||||
'kTCCServiceAccessibility',
|
|
||||||
'kTCCServiceMicrophone',
|
|
||||||
'kTCCServiceAudioCapture'
|
|
||||||
)
|
|
||||||
AND (
|
|
||||||
client IN (
|
|
||||||
'cn.crossdesk.app',
|
|
||||||
'cn.crossdesk.CrossDesk',
|
|
||||||
'com.crossdesk.app',
|
|
||||||
'com.crossdesk.CrossDesk',
|
|
||||||
'com.kunkundi.crossdesk',
|
|
||||||
'com.kunkundi.CrossDesk'
|
|
||||||
)
|
|
||||||
OR lower(client) LIKE '%crossdesk%'
|
|
||||||
);
|
|
||||||
SQL
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_user_tcc_db() {
|
|
||||||
local user_name="$1"
|
|
||||||
local home_dir
|
|
||||||
|
|
||||||
home_dir=$(/usr/bin/dscl . -read "/Users/${user_name}" NFSHomeDirectory 2>/dev/null |
|
|
||||||
/usr/bin/awk '{print $2}')
|
|
||||||
if [ -z "$home_dir" ]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
cleanup_tcc_db "${home_dir}/Library/Application Support/com.apple.TCC/TCC.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
CONSOLE_USER=$(/usr/bin/stat -f "%Su" /dev/console 2>/dev/null || true)
|
|
||||||
if [ -n "$CONSOLE_USER" ] &&
|
|
||||||
[ "$CONSOLE_USER" != "root" ] &&
|
|
||||||
[ "$CONSOLE_USER" != "loginwindow" ]; then
|
|
||||||
CONSOLE_UID=$(/usr/bin/id -u "$CONSOLE_USER" 2>/dev/null || true)
|
|
||||||
reset_bundle_tcc "$CONSOLE_USER" "$CONSOLE_UID"
|
|
||||||
cleanup_user_tcc_db "$CONSOLE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also clear any system/root-scoped decisions as a harmless fallback.
|
|
||||||
reset_bundle_tcc "" ""
|
|
||||||
cleanup_tcc_db "/Library/Application Support/com.apple.TCC/TCC.db"
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include "desktop_automation.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
using nlohmann::json;
|
||||||
|
std::string DesktopText(std::string text, size_t limit) {
|
||||||
|
if (text.size() > limit) {
|
||||||
|
while (limit && (static_cast<unsigned char>(text[limit]) & 0xc0) == 0x80) --limit;
|
||||||
|
text.resize(limit);
|
||||||
|
}
|
||||||
|
for (auto& character : text) if (static_cast<unsigned char>(character) < 32 && character != '\n') character = ' ';
|
||||||
|
return json::parse(json(text).dump(-1, ' ', false, json::error_handler_t::replace)).get<std::string>();
|
||||||
|
}
|
||||||
|
json DesktopAutomation::Execute(const std::string& owner, const json& request) {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
while (!snapshots_.empty() && snapshots_.front().expires <= now) snapshots_.pop_front();
|
||||||
|
const auto operation = request.at("operation").get<std::string>();
|
||||||
|
if (operation == "capabilities") return DesktopCapabilities();
|
||||||
|
if (operation == "launch" || operation == "open_url") {
|
||||||
|
const auto value = request.at(operation == "launch" ? "application" : "url").get<std::string>();
|
||||||
|
if (value.empty() || value.size() > 4096 || value.find_first_of("\r\n") != std::string::npos ||
|
||||||
|
value.find('\0') != std::string::npos) throw std::invalid_argument("INVALID_ARGUMENT");
|
||||||
|
if (operation == "launch") return DesktopLaunch(value);
|
||||||
|
if (value.rfind("https://", 0) != 0 && value.rfind("http://", 0) != 0)
|
||||||
|
throw std::invalid_argument("INVALID_URL");
|
||||||
|
return DesktopOpenUrl(value);
|
||||||
|
}
|
||||||
|
DesktopItem item;
|
||||||
|
auto found = snapshots_.end();
|
||||||
|
if (request.contains("snapshot_id")) {
|
||||||
|
const auto id = request.at("snapshot_id").get<std::string>();
|
||||||
|
found = std::find_if(snapshots_.begin(), snapshots_.end(), [&](const auto& snapshot) {
|
||||||
|
return snapshot.id == id && snapshot.owner == owner;
|
||||||
|
});
|
||||||
|
if (found == snapshots_.end()) throw std::invalid_argument("STALE_UI_SNAPSHOT");
|
||||||
|
const int index = request.at("element_id").get<int>();
|
||||||
|
if (index < 0 || size_t(index) >= found->content.items.size()) throw std::invalid_argument("INVALID_ELEMENT");
|
||||||
|
item = found->content.items[size_t(index)];
|
||||||
|
}
|
||||||
|
if (operation == "windows" || operation == "tree") {
|
||||||
|
const int limit = request.value("max_nodes", 128), depth = request.value("max_depth", 8);
|
||||||
|
const int pid = request.value("pid", 0);
|
||||||
|
if (limit < 1 || limit > 128 || depth < 0 || depth > 16 || pid < 0)
|
||||||
|
throw std::invalid_argument("INVALID_ARGUMENT");
|
||||||
|
DesktopSnapshot content;
|
||||||
|
if (operation == "windows") content = DesktopWindows();
|
||||||
|
else if (found != snapshots_.end()) {
|
||||||
|
if (!item.inspect) throw std::invalid_argument("UNSUPPORTED_OPERATION");
|
||||||
|
content = item.inspect(limit, depth);
|
||||||
|
} else content = DesktopTree(pid, limit, depth);
|
||||||
|
json elements = json::array();
|
||||||
|
size_t bytes = 0;
|
||||||
|
for (size_t index = 0; index < content.items.size(); ++index) {
|
||||||
|
auto data = content.items[index].data;
|
||||||
|
data["element_id"] = index;
|
||||||
|
bytes += data.dump(-1, ' ', false, json::error_handler_t::replace).size();
|
||||||
|
if (bytes > 48000 || index >= 128) { content.truncated = true; content.items.resize(index); break; }
|
||||||
|
elements.push_back(std::move(data));
|
||||||
|
}
|
||||||
|
const auto id = "ui_" + std::to_string(++sequence_);
|
||||||
|
if (snapshots_.size() >= 8) snapshots_.pop_front();
|
||||||
|
const bool truncated = content.truncated;
|
||||||
|
snapshots_.push_back({id, owner, now + std::chrono::seconds(30), std::move(content)});
|
||||||
|
return {{"snapshot_id", id}, {"expires_in_ms", 30000}, {"elements", elements},
|
||||||
|
{"truncated", truncated}, {"coordinate_space", "desktop_native"}};
|
||||||
|
}
|
||||||
|
if (operation != "activate" && operation != "press" && operation != "focus" && operation != "set_value")
|
||||||
|
throw std::invalid_argument("UNSUPPORTED_OPERATION");
|
||||||
|
if (found == snapshots_.end() || !item.perform) throw std::invalid_argument("INVALID_ELEMENT");
|
||||||
|
const auto value = request.value("value", std::string{});
|
||||||
|
if (value.size() > 16384 || value.find('\0') != std::string::npos) throw std::invalid_argument("INVALID_ARGUMENT");
|
||||||
|
if (item.data.value("secure", false)) throw std::invalid_argument("SECURE_ELEMENT");
|
||||||
|
// The next operation must inspect the resulting UI again, even if an OS
|
||||||
|
// provider returns an uncertain result after accepting a mutation.
|
||||||
|
snapshots_.erase(found);
|
||||||
|
return item.perform(operation, value);
|
||||||
|
}
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
json DesktopCapabilities() { return {{"available", false}}; }
|
||||||
|
DesktopSnapshot DesktopWindows() { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
|
||||||
|
DesktopSnapshot DesktopTree(int, int, int) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
|
||||||
|
json DesktopLaunch(const std::string&) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
|
||||||
|
json DesktopOpenUrl(const std::string&) { throw std::runtime_error("UI_AUTOMATION_UNSUPPORTED"); }
|
||||||
|
#endif
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-06
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _DESKTOP_AUTOMATION_H_
|
||||||
|
#define _DESKTOP_AUTOMATION_H_
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
struct DesktopSnapshot;
|
||||||
|
struct DesktopItem {
|
||||||
|
nlohmann::json data;
|
||||||
|
std::function<nlohmann::json(const std::string&, const std::string&)> perform;
|
||||||
|
std::function<DesktopSnapshot(int, int)> inspect;
|
||||||
|
};
|
||||||
|
struct DesktopSnapshot {
|
||||||
|
std::vector<DesktopItem> items;
|
||||||
|
bool truncated = false;
|
||||||
|
};
|
||||||
|
// Platform functions execute on the desktop UI thread. References remain owned
|
||||||
|
// by a bounded, connection-scoped snapshot until expiry or a mutating action.
|
||||||
|
nlohmann::json DesktopCapabilities();
|
||||||
|
DesktopSnapshot DesktopWindows();
|
||||||
|
DesktopSnapshot DesktopTree(int pid, int limit, int depth);
|
||||||
|
nlohmann::json DesktopLaunch(const std::string& application);
|
||||||
|
nlohmann::json DesktopOpenUrl(const std::string& url);
|
||||||
|
std::string DesktopText(std::string text, size_t limit = 256);
|
||||||
|
class DesktopAutomation {
|
||||||
|
public:
|
||||||
|
nlohmann::json Execute(const std::string& owner, const nlohmann::json& request);
|
||||||
|
private:
|
||||||
|
struct Snapshot {
|
||||||
|
std::string id, owner;
|
||||||
|
std::chrono::steady_clock::time_point expires;
|
||||||
|
DesktopSnapshot content;
|
||||||
|
};
|
||||||
|
std::deque<Snapshot> snapshots_;
|
||||||
|
uint64_t sequence_ = 0;
|
||||||
|
};
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -230,7 +230,10 @@ std::string Read(const std::filesystem::path& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
|
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
|
#ifdef _WIN32
|
||||||
CreatePrivateDirectory(directory, protect_existing);
|
CreatePrivateDirectory(directory, protect_existing);
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ namespace crossdesk {
|
|||||||
// Called on the application thread. True means the OS accepted the request,
|
// Called on the application thread. True means the OS accepted the request,
|
||||||
// not that the asynchronous transition to the lock screen has finished.
|
// not that the asynchronous transition to the lock screen has finished.
|
||||||
bool LockScreen();
|
bool LockScreen();
|
||||||
|
enum class ScreenLockState { unknown, unlocked, locked };
|
||||||
|
ScreenLockState QueryScreenLock();
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
#include "SimpleIni.h"
|
#include "SimpleIni.h"
|
||||||
#include "autostart.h"
|
#include "autostart.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "signal_server_address.h"
|
#include <minirtc.h>
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
@@ -154,6 +154,8 @@ int ConfigCenter::Load() {
|
|||||||
ini_->GetBoolValue(section_, "trusted_devices_only", true));
|
ini_->GetBoolValue(section_, "trusted_devices_only", true));
|
||||||
save_remote_previews_.store(
|
save_remote_previews_.store(
|
||||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||||
|
pause_video_when_minimized_.store(
|
||||||
|
ini_->GetBoolValue(section_, "pause_video_when_minimized", true));
|
||||||
portable_service_prompt_suppressed_ =
|
portable_service_prompt_suppressed_ =
|
||||||
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
|
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||||
portable_service_prompt_suppressed_);
|
portable_service_prompt_suppressed_);
|
||||||
@@ -253,6 +255,8 @@ int ConfigCenter::Save() {
|
|||||||
ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
|
ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
|
||||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||||
save_remote_previews_.load());
|
save_remote_previews_.load());
|
||||||
|
ini_->SetBoolValue(section_, "pause_video_when_minimized",
|
||||||
|
pause_video_when_minimized_.load());
|
||||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||||
portable_service_prompt_suppressed_);
|
portable_service_prompt_suppressed_);
|
||||||
ini_->SetValue(section_, "sponsorship_prompt_dismissed_version",
|
ini_->SetValue(section_, "sponsorship_prompt_dismissed_version",
|
||||||
@@ -345,22 +349,24 @@ int ConfigCenter::SetForceRelay(bool force_relay) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetServerHost(const std::string& host) {
|
int ConfigCenter::SetServerHost(const std::string& host) {
|
||||||
const auto address = minirtc::ParseSignalServerAddress(host);
|
MiniRtcSignalServerAddress address{};
|
||||||
if (!address) return -1;
|
if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
|
||||||
if (address->port) return SetServerEndpoint(host, *address->port);
|
if (address.port) return SetServerEndpoint(host, address.port);
|
||||||
if (StoreValues({{"signal_server_host", address->Address()}}) != 0) return -1;
|
const auto normalized = std::string(address.host) + address.target;
|
||||||
signal_server_host_ = address->Address();
|
if (StoreValues({{"signal_server_host", normalized}}) != 0) return -1;
|
||||||
|
signal_server_host_ = normalized;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetServerEndpoint(const std::string& host, int port) {
|
int ConfigCenter::SetServerEndpoint(const std::string& host, int port) {
|
||||||
const auto address = minirtc::ParseSignalServerAddress(host);
|
MiniRtcSignalServerAddress address{};
|
||||||
if (!address) return -1;
|
if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return -1;
|
||||||
port = address->port.value_or(port);
|
if (address.port) port = address.port;
|
||||||
if (port < 1 || port > 65535) return -1;
|
if (port < 1 || port > 65535) return -1;
|
||||||
if (StoreValues({{"signal_server_host", address->Address()},
|
const auto normalized = std::string(address.host) + address.target;
|
||||||
|
if (StoreValues({{"signal_server_host", normalized},
|
||||||
{"signal_server_port", std::to_string(port)}}) != 0) return -1;
|
{"signal_server_port", std::to_string(port)}}) != 0) return -1;
|
||||||
signal_server_host_ = address->Address();
|
signal_server_host_ = normalized;
|
||||||
signal_server_port_ = port;
|
signal_server_port_ = port;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -467,6 +473,18 @@ bool ConfigCenter::IsSaveRemotePreviews() const {
|
|||||||
return save_remote_previews_.load();
|
return save_remote_previews_.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ConfigCenter::SetPauseVideoWhenMinimized(bool enabled) {
|
||||||
|
if (StoreValues({{"pause_video_when_minimized", enabled ? "true" : "false"}}) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
pause_video_when_minimized_.store(enabled);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigCenter::IsPauseVideoWhenMinimized() const {
|
||||||
|
return pause_video_when_minimized_.load();
|
||||||
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
||||||
portable_service_prompt_suppressed_ = suppressed;
|
portable_service_prompt_suppressed_ = suppressed;
|
||||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ConfigCenter {
|
|||||||
int SetLockOnDisconnect(bool enabled);
|
int SetLockOnDisconnect(bool enabled);
|
||||||
int SetTrustedDevicesOnly(bool enabled);
|
int SetTrustedDevicesOnly(bool enabled);
|
||||||
int SetSaveRemotePreviews(bool enabled);
|
int SetSaveRemotePreviews(bool enabled);
|
||||||
|
int SetPauseVideoWhenMinimized(bool enabled);
|
||||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||||
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
||||||
int SetFileTransferSavePath(const std::string& path);
|
int SetFileTransferSavePath(const std::string& path);
|
||||||
@@ -83,6 +84,7 @@ class ConfigCenter {
|
|||||||
bool IsLockOnDisconnect() const;
|
bool IsLockOnDisconnect() const;
|
||||||
bool IsTrustedDevicesOnly() const;
|
bool IsTrustedDevicesOnly() const;
|
||||||
bool IsSaveRemotePreviews() const;
|
bool IsSaveRemotePreviews() const;
|
||||||
|
bool IsPauseVideoWhenMinimized() const;
|
||||||
bool IsPortableServicePromptSuppressed() const;
|
bool IsPortableServicePromptSuppressed() const;
|
||||||
std::string GetSponsorshipPromptDismissedVersion() const;
|
std::string GetSponsorshipPromptDismissedVersion() const;
|
||||||
std::string GetFileTransferSavePath() const;
|
std::string GetFileTransferSavePath() const;
|
||||||
@@ -117,6 +119,7 @@ class ConfigCenter {
|
|||||||
std::atomic<bool> lock_on_disconnect_{true};
|
std::atomic<bool> lock_on_disconnect_{true};
|
||||||
std::atomic<bool> trusted_devices_only_{false};
|
std::atomic<bool> trusted_devices_only_{false};
|
||||||
std::atomic<bool> save_remote_previews_{true};
|
std::atomic<bool> save_remote_previews_{true};
|
||||||
|
std::atomic<bool> pause_video_when_minimized_{true};
|
||||||
bool portable_service_prompt_suppressed_ = false;
|
bool portable_service_prompt_suppressed_ = false;
|
||||||
std::string sponsorship_prompt_dismissed_version_;
|
std::string sponsorship_prompt_dismissed_version_;
|
||||||
std::string file_transfer_save_path_ = "";
|
std::string file_transfer_save_path_ = "";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,8 @@ private:
|
|||||||
void SyncServerWindow();
|
void SyncServerWindow();
|
||||||
void SyncTrustedDevices();
|
void SyncTrustedDevices();
|
||||||
void SyncFileTransferNotifications();
|
void SyncFileTransferNotifications();
|
||||||
|
void OpenFileManager(const std::string& remote);
|
||||||
|
std::string FileManagerPeerName(const std::string& remote);
|
||||||
void CancelFileTransfer(uint32_t file_id);
|
void CancelFileTransfer(uint32_t file_id);
|
||||||
void SetServerPanelExpanded(bool expanded);
|
void SetServerPanelExpanded(bool expanded);
|
||||||
void PositionServerWindows();
|
void PositionServerWindows();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <slint.h>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
// Winit creates hidden native windows on a later event-loop turn. Wait for
|
||||||
|
// that window before applying geometry that depends on its frame or DPI.
|
||||||
|
inline bool NativeWindowReady([[maybe_unused]] const slint::Window& window) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return window.win32_hwnd() != nullptr;
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
return window.appkit_view() != nullptr;
|
||||||
|
#else
|
||||||
|
// Other backends accept initial geometry before show(); Wayland placement
|
||||||
|
// is controlled by the compositor and must not wait for a mapped surface.
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -521,6 +521,17 @@ inline constexpr TranslationRow kTranslationRows[] = {
|
|||||||
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "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."}},
|
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "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"}},
|
{"重试更新", {"重试更新", "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."}},
|
{"更新未完成,请手动下载安装。", {"更新未完成,请手动下载安装。", "Update did not complete. Download and install manually.", "Обновление не завершено. Скачайте и установите вручную.", "更新未完成,請手動下載安裝。", "アップデートが完了しませんでした。手動でダウンロードしてインストールしてください。", "업데이트가 완료되지 않았습니다. 수동으로 다운로드하여 설치하세요.", "Das Update wurde nicht abgeschlossen. Laden Sie es herunter und installieren Sie es manuell.", "La mise à jour n'a pas été effectuée. Téléchargez-la et installez-la manuellement.", "La actualización no se completó. Descárguela e instálela manualmente.", "A atualização não foi concluída. Baixe e instale manualmente."}},
|
||||||
|
{"发送到远端", {"发送到远端", "Send to remote", "Отправить на удалённый ПК", "傳送到遠端", "リモートへ送信", "원격으로 보내기", "An Remote-PC senden", "Envoyer au PC distant", "Enviar al equipo remoto", "Enviar ao computador remoto"}},
|
||||||
|
{"发送到本机", {"发送到本机", "Send to local", "Отправить на этот ПК", "傳送到本機", "この端末へ送信", "이 기기로 보내기", "An diesen PC senden", "Envoyer à ce PC", "Enviar a este equipo", "Enviar a este computador"}},
|
||||||
|
{"清除已完成", {"清除已完成", "Clear completed", "Убрать завершённые", "清除已完成", "完了済みをクリア", "완료 항목 지우기", "Abgeschlossene entfernen", "Effacer les tâches terminées", "Limpiar completadas", "Limpar concluídas"}},
|
||||||
|
{"打开目录", {"打开目录", "Open folder", "Открыть папку", "開啟目錄", "フォルダーを開く", "폴더 열기", "Ordner öffnen", "Ouvrir le dossier", "Abrir carpeta", "Abrir pasta"}},
|
||||||
|
{"最小化时暂停视频", {"最小化时暂停视频", "Pause video when minimized", "Приостанавливать видео при сворачивании", "最小化時暫停視訊", "最小化時に映像を一時停止", "최소화 시 영상 일시 중지", "Video beim Minimieren pausieren", "Mettre la vidéo en pause à la réduction", "Pausar el vídeo al minimizar", "Pausar vídeo ao minimizar"}},
|
||||||
|
{"控制窗口最小化时暂停接收视频,降低本机解码资源占用。", {"控制窗口最小化时暂停接收视频,降低本机解码资源占用。", "Pause video reception when the control window is minimized to reduce local decoding resource usage.", "При сворачивании окна управления приём видео приостанавливается, снижая нагрузку от декодирования на этом устройстве.", "控制視窗最小化時暫停接收視訊,降低本機解碼資源占用。", "操作ウィンドウの最小化時に映像の受信を一時停止し、この端末のデコード処理による負荷を軽減します。", "제어 창을 최소화하면 영상 수신을 일시 중지하여 이 기기의 디코딩 리소스 사용량을 줄입니다.", "Pausiert den Videoempfang, wenn das Steuerungsfenster minimiert ist, und reduziert so den lokalen Ressourcenverbrauch beim Dekodieren.", "Met en pause la réception vidéo lorsque la fenêtre de contrôle est réduite afin de diminuer les ressources de décodage utilisées sur cet appareil.", "Pausa la recepción de vídeo al minimizar la ventana de control para reducir el uso de recursos de decodificación en este dispositivo.", "Pausa a recepção de vídeo ao minimizar a janela de controle para reduzir o uso de recursos de decodificação neste dispositivo."}},
|
||||||
|
{"系统权限", {"系统权限", "System permissions", "Системные разрешения", "系統權限", "システム権限", "시스템 권한", "Systemberechtigungen", "Autorisations système", "Permisos del sistema", "Permissões do sistema"}},
|
||||||
|
{"正在迁移旧版权限…", {"正在迁移旧版权限…", "Migrating permissions…", "Перенос разрешений…", "正在遷移舊版權限…", "権限を移行中…", "권한 이전 중…", "Berechtigungen werden migriert…", "Migration des autorisations…", "Migrando permisos…", "Migrando permissões…"}},
|
||||||
|
{"权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。", {"权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。", "Permission migration needs attention. Retry, or remove and re-add CrossDesk in System Settings.", "Не удалось завершить перенос разрешений. Повторите попытку или удалите и заново добавьте CrossDesk в системных настройках.", "權限遷移未完成。請重試,或在系統設定中移除舊 CrossDesk 後重新加入。", "権限の移行を完了できませんでした。再試行するか、システム設定でCrossDeskを削除して追加し直してください。", "권한 이전이 완료되지 않았습니다. 다시 시도하거나 시스템 설정에서 CrossDesk를 제거한 후 다시 추가하세요.", "Die Migration der Berechtigungen ist unvollständig. Wiederholen Sie den Vorgang oder entfernen Sie CrossDesk in den Systemeinstellungen und fügen Sie es erneut hinzu.", "La migration des autorisations est incomplète. Réessayez, ou supprimez puis ajoutez à nouveau CrossDesk dans les réglages système.", "La migración de permisos está incompleta. Reinténtelo o elimine y vuelva a añadir CrossDesk en Ajustes del Sistema.", "A migração de permissões está incompleta. Tente novamente ou remova e adicione o CrossDesk novamente nos Ajustes do Sistema."}},
|
||||||
|
{"重试权限迁移", {"重试权限迁移", "Retry migration", "Повторить перенос", "重試權限遷移", "移行を再試行", "권한 이전 재시도", "Migration wiederholen", "Réessayer la migration", "Reintentar migración", "Repetir migração"}},
|
||||||
|
{"稍后", {"稍后", "Later", "Позже", "稍後", "後で", "나중에", "Später", "Plus tard", "Más tarde", "Mais tarde"}},
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NamedTranslation { const char* name; const char* literal; };
|
struct NamedTranslation { const char* name; const char* literal; };
|
||||||
@@ -809,6 +820,13 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
|
|||||||
{"privacy_screen_unlock_hint", "按下快捷键,解除隐私屏"},
|
{"privacy_screen_unlock_hint", "按下快捷键,解除隐私屏"},
|
||||||
{"exit_program", "退出"},
|
{"exit_program", "退出"},
|
||||||
{"shutdown_reason", "远程控制连接中,保留连接以确认或取消关机。"},
|
{"shutdown_reason", "远程控制连接中,保留连接以确认或取消关机。"},
|
||||||
|
{"pause_video_when_minimized", "最小化时暂停视频"},
|
||||||
|
{"pause_video_when_minimized_hint", "控制窗口最小化时暂停接收视频,降低本机解码资源占用。"},
|
||||||
|
{"system_permissions", "系统权限"},
|
||||||
|
{"permission_migration_running", "正在迁移旧版权限…"},
|
||||||
|
{"permission_migration_failed", "权限迁移未完成。请重试,或在系统设置中移除旧 CrossDesk 后重新添加。"},
|
||||||
|
{"retry_permission_migration", "重试权限迁移"},
|
||||||
|
{"permission_later", "稍后"},
|
||||||
};
|
};
|
||||||
inline constexpr const char* kSlintLiterals[] = {
|
inline constexpr const char* kSlintLiterals[] = {
|
||||||
"此端口已被占用,",
|
"此端口已被占用,",
|
||||||
@@ -1019,6 +1037,8 @@ inline constexpr const char* kSlintLiterals[] = {
|
|||||||
"确认执行",
|
"确认执行",
|
||||||
"监听所有 IP 接口",
|
"监听所有 IP 接口",
|
||||||
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
|
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
|
||||||
|
"最小化时暂停视频",
|
||||||
|
"控制窗口最小化时暂停接收视频,降低本机解码资源占用。",
|
||||||
};
|
};
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
|
|
||||||
@@ -1306,7 +1326,14 @@ inline constexpr const char* kSlintLiterals[] = {
|
|||||||
X(privacy_unlock_lock) \
|
X(privacy_unlock_lock) \
|
||||||
X(privacy_screen_unlock_hint) \
|
X(privacy_screen_unlock_hint) \
|
||||||
X(exit_program) \
|
X(exit_program) \
|
||||||
X(shutdown_reason)
|
X(shutdown_reason) \
|
||||||
|
X(pause_video_when_minimized) \
|
||||||
|
X(pause_video_when_minimized_hint) \
|
||||||
|
X(system_permissions) \
|
||||||
|
X(permission_migration_running) \
|
||||||
|
X(permission_migration_failed) \
|
||||||
|
X(retry_permission_migration) \
|
||||||
|
X(permission_later)
|
||||||
|
|
||||||
} // namespace localization
|
} // namespace localization
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ void ClipboardController::Shutdown() {
|
|||||||
pending_remote_text_.reset();
|
pending_remote_text_.reset();
|
||||||
pending_complete_ = {};
|
pending_complete_ = {};
|
||||||
pending_allowed_ = {};
|
pending_allowed_ = {};
|
||||||
|
pending_reads_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ClipboardController::WebSessionActive(
|
bool ClipboardController::WebSessionActive(
|
||||||
@@ -107,7 +108,7 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size,
|
|||||||
if (found == web_sessions_.end() || found->second->events.lock() != events) return;
|
if (found == web_sessions_.end() || found->second->events.lock() != events) return;
|
||||||
session = found->second;
|
session = found->second;
|
||||||
}
|
}
|
||||||
if (!hello) {
|
if (!hello && kind != clip::kRead && kind != clip::kClear) {
|
||||||
if (!session->ready.load(std::memory_order_acquire) ||
|
if (!session->ready.load(std::memory_order_acquire) ||
|
||||||
kind != clip::kText || !request) return;
|
kind != clip::kText || !request) return;
|
||||||
message = session->assembler.Receive(std::string_view(data, size));
|
message = session->assembler.Receive(std::string_view(data, size));
|
||||||
@@ -121,6 +122,23 @@ void ClipboardController::ReceiveWeb(const char* data, size_t size,
|
|||||||
session->ready.store(true, std::memory_order_release);
|
session->ready.store(true, std::memory_order_release);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((kind == clip::kRead || kind == clip::kClear) &&
|
||||||
|
(!session->ready.load(std::memory_order_acquire) || !request ||
|
||||||
|
size != clip::kHeaderBytes)) return;
|
||||||
|
if (kind == clip::kRead) {
|
||||||
|
const auto send = [this, remote_id, session](const std::string& frame) {
|
||||||
|
return WebSessionActive(remote_id, session) &&
|
||||||
|
owner_.SendControllerData(remote_id, frame.data(), frame.size(), clip::kStream) == 0;
|
||||||
|
};
|
||||||
|
if (!QueueRead([send, request](std::optional<std::string> text) {
|
||||||
|
if (!text) send(clip::Frame(clip::kRejected, request));
|
||||||
|
else if (text->empty()) send(clip::Frame(clip::kEmpty, request));
|
||||||
|
else if (!clip::SendText(*text, request, 16 * 1024, send))
|
||||||
|
send(clip::Frame(clip::kRejected, request));
|
||||||
|
})) send(clip::Frame(clip::kRejected, request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind == clip::kClear) message = clip::Frame(clip::kText, request);
|
||||||
if (message.empty()) return;
|
if (message.empty()) return;
|
||||||
const std::weak_ptr<WebSession> weak = session;
|
const std::weak_ptr<WebSession> weak = session;
|
||||||
auto allowed = [this, remote_id, weak] {
|
auto allowed = [this, remote_id, weak] {
|
||||||
@@ -163,7 +181,7 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size,
|
|||||||
if (!events_enabled_.load(std::memory_order_acquire)) {
|
if (!events_enabled_.load(std::memory_order_acquire)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data || size == 0) {
|
if (!data || (size == 0 && !complete)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (size > kMaxClipboardTextBytes) {
|
if (size > kMaxClipboardTextBytes) {
|
||||||
@@ -207,11 +225,37 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ClipboardController::QueueRead(std::function<void(std::optional<std::string>)> complete) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(pending_mutex_);
|
||||||
|
if (!events_enabled_.load() || pending_reads_.size() >= 32) return false;
|
||||||
|
pending_reads_.push_back(std::move(complete));
|
||||||
|
}
|
||||||
|
SDL_Event event{};
|
||||||
|
event.type = event_type_;
|
||||||
|
if (event_type_) SDL_PushEvent(&event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void ClipboardController::ApplyPendingRemoteText() {
|
void ClipboardController::ApplyPendingRemoteText() {
|
||||||
if (!events_enabled_.load(std::memory_order_acquire)) {
|
if (!events_enabled_.load(std::memory_order_acquire)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ApplyPendingWrite();
|
||||||
|
std::deque<std::function<void(std::optional<std::string>)>> reads;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(pending_mutex_);
|
||||||
|
reads.swap(pending_reads_);
|
||||||
|
}
|
||||||
|
if (!reads.empty()) {
|
||||||
|
char* value = SDL_GetClipboardText();
|
||||||
|
const std::optional<std::string> text = value ? std::optional<std::string>{value} : std::nullopt;
|
||||||
|
SDL_free(value);
|
||||||
|
for (auto& complete : reads) complete(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClipboardController::ApplyPendingWrite() {
|
||||||
std::optional<std::string> pending_text;
|
std::optional<std::string> pending_text;
|
||||||
std::string source_id;
|
std::string source_id;
|
||||||
std::weak_ptr<gui_detail::RemoteSession> source_session;
|
std::weak_ptr<gui_detail::RemoteSession> source_session;
|
||||||
@@ -315,7 +359,7 @@ void ClipboardController::SendDeferredLocalText() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int ret = SendReliableDataFrame(
|
const int ret = MiniRtcSendReliableDataFrame(
|
||||||
session->peer_, last_text_.data(), last_text_.size(),
|
session->peer_, last_text_.data(), last_text_.size(),
|
||||||
session->clipboard_label_.c_str());
|
session->clipboard_label_.c_str());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
@@ -344,7 +388,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int ret =
|
const int ret =
|
||||||
SendReliableDataFrame(props->peer_, text.data(), text.size(),
|
MiniRtcSendReliableDataFrame(props->peer_, text.data(), text.size(),
|
||||||
props->clipboard_label_.c_str());
|
props->clipboard_label_.c_str());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
|
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <deque>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -42,6 +43,7 @@ public:
|
|||||||
std::function<void(bool)> complete = {},
|
std::function<void(bool)> complete = {},
|
||||||
std::function<bool()> allowed = {});
|
std::function<bool()> allowed = {});
|
||||||
void ApplyPendingRemoteText();
|
void ApplyPendingRemoteText();
|
||||||
|
bool QueueRead(std::function<void(std::optional<std::string>)> complete);
|
||||||
void HandleLocalUpdate();
|
void HandleLocalUpdate();
|
||||||
void ReceiveWeb(const char* data, size_t size, const std::string& remote_id,
|
void ReceiveWeb(const char* data, size_t size, const std::string& remote_id,
|
||||||
PeerEventHandler* source);
|
PeerEventHandler* source);
|
||||||
@@ -49,6 +51,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct WebSession;
|
struct WebSession;
|
||||||
|
void ApplyPendingWrite();
|
||||||
bool WebSessionActive(const std::string& remote_id,
|
bool WebSessionActive(const std::string& remote_id,
|
||||||
const std::shared_ptr<WebSession>& session);
|
const std::shared_ptr<WebSession>& session);
|
||||||
int SendToController(const std::string& remote_id, const std::string& text);
|
int SendToController(const std::string& remote_id, const std::string& text);
|
||||||
@@ -59,6 +62,7 @@ private:
|
|||||||
uint32_t event_type_ = 0;
|
uint32_t event_type_ = 0;
|
||||||
std::atomic<bool> events_enabled_{false};
|
std::atomic<bool> events_enabled_{false};
|
||||||
std::mutex pending_mutex_;
|
std::mutex pending_mutex_;
|
||||||
|
std::deque<std::function<void(std::optional<std::string>)>> pending_reads_;
|
||||||
std::optional<std::string> pending_remote_text_;
|
std::optional<std::string> pending_remote_text_;
|
||||||
std::string pending_remote_id_;
|
std::string pending_remote_id_;
|
||||||
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
|
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
|||||||
frame.size = size;
|
frame.size = size;
|
||||||
for (const auto& id : owner_.ConnectedControllers())
|
for (const auto& id : owner_.ConnectedControllers())
|
||||||
owner_.WithController(
|
owner_.WithController(
|
||||||
id, [&](PeerPtr* peer, const std::string& wire) {
|
id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
|
||||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
|
||||||
return SendAudioFrameToPeer(peer, &frame,
|
return MiniRtcSendAudioFrameToPeer(peer, &frame,
|
||||||
owner_.audio_label_.c_str(),
|
owner_.audio_label_.c_str(),
|
||||||
wire.data(), wire.size());
|
wire.data(), wire.size());
|
||||||
});
|
});
|
||||||
@@ -239,11 +239,11 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
|||||||
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
||||||
stream_id)) continue;
|
stream_id)) continue;
|
||||||
if (owner_.WithController(
|
if (owner_.WithController(
|
||||||
remote_id, [&](PeerPtr* peer, const std::string& wire) {
|
remote_id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
|
||||||
if (resumed_after_gap || stream_changed)
|
if (resumed_after_gap || stream_changed)
|
||||||
RequestVideoKeyFrame(peer, stream_id.c_str());
|
MiniRtcRequestVideoKeyFrame(peer, stream_id.c_str());
|
||||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer);
|
||||||
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
return MiniRtcSendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
||||||
wire.data(), wire.size());
|
wire.data(), wire.size());
|
||||||
}) != 0)
|
}) != 0)
|
||||||
++send_errors;
|
++send_errors;
|
||||||
@@ -285,7 +285,7 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
|
|||||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||||
return std::any_of(owner_.connection_status_.begin(),
|
return std::any_of(owner_.connection_status_.begin(),
|
||||||
owner_.connection_status_.end(), [](const auto& entry) {
|
owner_.connection_status_.end(), [](const auto& entry) {
|
||||||
return entry.second == ConnectionStatus::Connected &&
|
return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
|
||||||
entry.first.find("web") != std::string::npos;
|
entry.first.find("web") != std::string::npos;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ bool SessionDeviceManager::HasConnectedController() const {
|
|||||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||||
return std::any_of(owner_.connection_status_.begin(),
|
return std::any_of(owner_.connection_status_.begin(),
|
||||||
owner_.connection_status_.end(), [](const auto& entry) {
|
owner_.connection_status_.end(), [](const auto& entry) {
|
||||||
return entry.second == ConnectionStatus::Connected;
|
return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,637 @@
|
|||||||
|
#include "features/file_transfer/file_browser_window.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <any>
|
||||||
|
#include <cctype>
|
||||||
|
#include <climits>
|
||||||
|
#include <cstdio>
|
||||||
|
extern unsigned char fa_solid_900_ttf[];
|
||||||
|
extern unsigned int fa_solid_900_ttf_len;
|
||||||
|
#include "ui/model_update.h"
|
||||||
|
#include "localization.h"
|
||||||
|
#include "application/window_geometry.h"
|
||||||
|
#include "application/window_presentation.h"
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
#include "platform/window_drag.h"
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
slint::SharedString S(std::string_view value) { return slint::SharedString(value); }
|
||||||
|
std::string Size(uint64_t bytes) {
|
||||||
|
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
|
||||||
|
double value = bytes; int unit = 0;
|
||||||
|
while (value >= 1024 && unit < 4) { value /= 1024; ++unit; }
|
||||||
|
char text[64]; std::snprintf(text, sizeof(text), unit ? "%.1f %s" : "%.0f %s", value, units[unit]);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
std::string Join(const std::string& parent, const std::string& name) {
|
||||||
|
const bool windows = (parent.size() > 1 && parent[1] == ':') || parent.rfind("\\\\", 0) == 0;
|
||||||
|
return parent + (parent.empty() || parent.back() == '/' || parent.back() == '\\'
|
||||||
|
? "" : windows ? "\\" : "/") + name;
|
||||||
|
}
|
||||||
|
std::string Name(const std::string& path) {
|
||||||
|
const auto slash = path.find_last_of("/\\");
|
||||||
|
return slash == std::string::npos || slash == path.size() - 1 ? path : path.substr(slash + 1);
|
||||||
|
}
|
||||||
|
bool Matches(const std::string& text, const std::string& filter) {
|
||||||
|
return std::search(text.begin(), text.end(), filter.begin(), filter.end(),
|
||||||
|
[](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }) != text.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string FileBrowserWindow::Tr(const char* chinese, const char* english) const { return chinese_ ? chinese : english; }
|
||||||
|
FileBrowserWindow::FileBrowserWindow(FileTransferManager& transfers, std::string remote,
|
||||||
|
bool controller, int language)
|
||||||
|
: transfers_(transfers), remote_(std::move(remote)),
|
||||||
|
connection_generation_(transfers_.browser().Generation(remote_)),
|
||||||
|
chinese_(language == 0 || language == 3) {
|
||||||
|
transfers_.browser().TakeEvents(remote_);
|
||||||
|
window_->window().window_handle().register_font_from_data(
|
||||||
|
fa_solid_900_ttf, fa_solid_900_ttf_len);
|
||||||
|
// Resolve the native frame at its final size while it is still hidden.
|
||||||
|
// Otherwise show() applies the preferred size after we have centered it.
|
||||||
|
window_->window().set_size(slint::LogicalSize({900, 620}));
|
||||||
|
window_->set_chinese(chinese_);
|
||||||
|
UpdateActionLabels(language);
|
||||||
|
SetPeerName("");
|
||||||
|
window_->set_local_role(S(controller ? Tr("控制端", "Controller") : Tr("被控端", "Host")));
|
||||||
|
window_->set_remote_role(S(controller ? Tr("被控端", "Host") : Tr("控制端", "Controller")));
|
||||||
|
window_->set_local_entries(panes_[0].rows); window_->set_local_tree(panes_[0].tree);
|
||||||
|
window_->set_remote_entries(panes_[1].rows); window_->set_remote_tree(panes_[1].tree);
|
||||||
|
window_->set_tasks(tasks_);
|
||||||
|
window_->on_navigate([this](bool remote, slint::SharedString path) { Navigate(remote, std::string(path)); });
|
||||||
|
window_->on_refresh([this](bool remote) { Navigate(remote, panes_[remote].path); });
|
||||||
|
window_->on_up([this](bool remote) { Navigate(remote, panes_[remote].parent); });
|
||||||
|
window_->on_filter([this](bool remote, slint::SharedString filter) {
|
||||||
|
auto& pane = panes_[remote]; pane.filter = std::string(filter); pane.selected.clear(); UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_sort([this](bool remote, int column) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (column < 0 || column > 2 || pane.busy || !pane.loaded ||
|
||||||
|
!pane.error.empty() || window_->get_drag_active()) return;
|
||||||
|
const auto sort_column = static_cast<SortColumn>(column);
|
||||||
|
pane.sort_descending = pane.sort_column == sort_column ? !pane.sort_descending
|
||||||
|
: sort_column != SortColumn::Name;
|
||||||
|
pane.sort_column = sort_column;
|
||||||
|
SortEntries(pane);
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_toggle_hidden([this](bool remote) {
|
||||||
|
auto& pane = panes_[remote]; pane.show_hidden = !pane.show_hidden;
|
||||||
|
pane.selected.clear(); UpdatePane(remote); UpdateTree(remote);
|
||||||
|
});
|
||||||
|
window_->on_select_entry([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
|
||||||
|
auto row = *pane.rows->row_data(index);
|
||||||
|
if (row.link) return;
|
||||||
|
SelectSource(remote);
|
||||||
|
const std::string path(row.path);
|
||||||
|
if (!pane.selected.erase(path)) pane.selected.insert(path);
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_select_all([this](bool remote) {
|
||||||
|
SelectSource(remote);
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
bool all = true;
|
||||||
|
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
|
||||||
|
const auto row = *pane.rows->row_data(i);
|
||||||
|
if (!row.link && !pane.selected.count(std::string(row.path))) all = false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
|
||||||
|
const auto row = *pane.rows->row_data(i);
|
||||||
|
if (all) pane.selected.erase(std::string(row.path));
|
||||||
|
else if (!row.link) pane.selected.insert(std::string(row.path));
|
||||||
|
}
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_clear_selection([this] {
|
||||||
|
if (window_->get_drag_active()) return;
|
||||||
|
for (int side = 0; side < 2; ++side) {
|
||||||
|
panes_[side].selected.clear();
|
||||||
|
UpdatePane(side);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window_->on_open_entry([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
|
||||||
|
const auto row = *pane.rows->row_data(index);
|
||||||
|
if (row.directory) OpenDirectory(remote, std::string(row.path));
|
||||||
|
});
|
||||||
|
window_->on_open_directory([this](bool remote, slint::SharedString path) {
|
||||||
|
OpenDirectory(remote, std::string(path));
|
||||||
|
});
|
||||||
|
window_->on_tree_open([this](bool remote, int index) {
|
||||||
|
const auto& pane = panes_[remote];
|
||||||
|
if (index >= 0 && index < static_cast<int>(pane.tree->row_count()))
|
||||||
|
Navigate(remote, std::string(pane.tree->row_data(index)->path));
|
||||||
|
});
|
||||||
|
window_->on_tree_toggle([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.tree->row_count())) return;
|
||||||
|
const std::string path(pane.tree->row_data(index)->path);
|
||||||
|
if (!pane.expanded.erase(path)) {
|
||||||
|
pane.expanded.insert(path);
|
||||||
|
if (!pane.directories.count(path)) Navigate(remote, path, true);
|
||||||
|
}
|
||||||
|
UpdateTree(remote);
|
||||||
|
});
|
||||||
|
window_->on_drag_data([this](bool remote, slint::SharedString path, int) {
|
||||||
|
return MakeDragData(remote, std::string(path));
|
||||||
|
});
|
||||||
|
window_->on_drag_state([this](const slint::DataTransfer& data, bool active) {
|
||||||
|
if (!active) { CancelDrag(); return; }
|
||||||
|
const auto value = data.user_data();
|
||||||
|
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
|
||||||
|
if (!stored || !*stored) return;
|
||||||
|
const auto payload = DropPayload(!(*stored)->remote, data);
|
||||||
|
if (!payload) return;
|
||||||
|
window_->set_drag_active(true);
|
||||||
|
window_->set_drag_count(static_cast<int>(payload->paths->size()));
|
||||||
|
SelectSource(payload->remote);
|
||||||
|
panes_[payload->remote].selected = {payload->paths->begin(), payload->paths->end()};
|
||||||
|
UpdatePane(payload->remote);
|
||||||
|
});
|
||||||
|
window_->on_can_drop([this](bool remote, const slint::DataTransfer& data) {
|
||||||
|
return static_cast<bool>(DropPayload(remote, data));
|
||||||
|
});
|
||||||
|
window_->on_dropped([this](bool remote, const slint::DataTransfer& data) {
|
||||||
|
const auto payload = DropPayload(remote, data);
|
||||||
|
if (!payload) return false;
|
||||||
|
// The drag owns a snapshot of both directories and its selected paths.
|
||||||
|
// Consume before queueing so repeated release events cannot send twice.
|
||||||
|
payload->consumed = true;
|
||||||
|
return QueueTransfer(payload->remote, *payload->paths, payload->target_directory);
|
||||||
|
});
|
||||||
|
window_->on_cancel_drag([this] { CancelDrag(); });
|
||||||
|
window_->on_transfer([this](bool download) { Transfer(download); });
|
||||||
|
window_->on_cancel_task([this](int id) { transfers_.CancelBrowserTransfer(remote_, static_cast<uint32_t>(id)); });
|
||||||
|
window_->on_clear_finished([this] {
|
||||||
|
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
|
||||||
|
for (const auto& [id, task] : history_)
|
||||||
|
if (task.status == Status::Completed || task.status == Status::Failed || task.status == Status::Cancelled)
|
||||||
|
dismissed_.insert(id);
|
||||||
|
next_sync_ = {};
|
||||||
|
});
|
||||||
|
window_->window().on_close_requested([this] {
|
||||||
|
show_pending_ = false;
|
||||||
|
CancelDrag();
|
||||||
|
window_->hide(); return slint::CloseRequestResponse::KeepWindowShown;
|
||||||
|
});
|
||||||
|
Navigate(false, ""); Navigate(true, "");
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::SetPeerName(const std::string& name) {
|
||||||
|
const auto text = S(name.empty() ? Tr("远端设备", "Remote device") : name);
|
||||||
|
if (window_->get_peer_name() != text) window_->set_peer_name(text);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::UpdateActionLabels(int language) {
|
||||||
|
window_->set_send_local_text(S(localization::Literal("发送到本机", language)));
|
||||||
|
window_->set_send_remote_text(S(localization::Literal("发送到远端", language)));
|
||||||
|
window_->set_clear_completed_text(S(localization::Literal("清除已完成", language)));
|
||||||
|
window_->set_open_directory_text(S(localization::Literal("打开目录", language)));
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::PresentsResult(uint32_t id,
|
||||||
|
FileTransferManager::FileTransferState::FileTransferStatus status) const {
|
||||||
|
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
|
||||||
|
if (!PresentsTransfers() ||
|
||||||
|
(status != Status::Completed && status != Status::Failed && status != Status::Cancelled))
|
||||||
|
return false;
|
||||||
|
const auto task = history_.find(id);
|
||||||
|
return task != history_.end() && !task->second.receiving && task->second.status == status &&
|
||||||
|
(window_->get_tasks_expanded() || dismissed_.count(id));
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::CenterOnScreen() {
|
||||||
|
auto& window = window_->window();
|
||||||
|
if (window.is_fullscreen() || window.is_maximized()) return true;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
return CenterNativeWindowOnScreen(window.appkit_view());
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
const HWND hwnd = window.win32_hwnd();
|
||||||
|
if (!hwnd) return false;
|
||||||
|
MONITORINFO monitor{sizeof(MONITORINFO)};
|
||||||
|
RECT frame{};
|
||||||
|
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitor) ||
|
||||||
|
!GetWindowRect(hwnd, &frame)) return false;
|
||||||
|
const auto& bounds = monitor.rcWork;
|
||||||
|
const auto target = window_geometry::CenteredPosition(
|
||||||
|
{static_cast<int>(bounds.left), static_cast<int>(bounds.top),
|
||||||
|
static_cast<int>(bounds.right - bounds.left),
|
||||||
|
static_cast<int>(bounds.bottom - bounds.top)},
|
||||||
|
{static_cast<int>(frame.right - frame.left),
|
||||||
|
static_cast<int>(frame.bottom - frame.top)});
|
||||||
|
return SetWindowPos(hwnd, nullptr, target.x, target.y, 0, 0,
|
||||||
|
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) != 0;
|
||||||
|
#else
|
||||||
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
|
// Native Wayland placement belongs to the compositor.
|
||||||
|
if (window.wayland_surface()) return true;
|
||||||
|
#endif
|
||||||
|
const auto position = window.position();
|
||||||
|
const auto size = window.size();
|
||||||
|
if (!size.width || !size.height) return false;
|
||||||
|
const SDL_Point center{position.x + static_cast<int>(size.width / 2),
|
||||||
|
position.y + static_cast<int>(size.height / 2)};
|
||||||
|
SDL_DisplayID display = SDL_GetDisplayForPoint(¢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 {
|
namespace crossdesk {
|
||||||
|
|
||||||
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
|
FileTransferManager::FileTransferManager(GuiRuntime& owner)
|
||||||
|
: owner_(owner), browser_(
|
||||||
|
[this](const auto& remote, const auto& message) { return SendBrowser(remote, message); },
|
||||||
|
[this](const auto& remote, const auto& path, uint32_t id) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
return ProcessSelectedFile(path, props, owner_.file_label_, remote, id);
|
||||||
|
},
|
||||||
|
[this](const auto& remote, uint32_t id, const auto& directory) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
|
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
|
||||||
|
const auto receiver = ReceiverFor(events.get(), remote);
|
||||||
|
return receiver && receiver->ReserveDirectory(id, directory);
|
||||||
|
},
|
||||||
|
[this](const auto& remote, uint32_t id) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
for (const auto& info : BrowserTransfers(remote))
|
||||||
|
if (!info.receiving && info.file_id == id) return CancelSend(id);
|
||||||
|
return false;
|
||||||
|
}) {}
|
||||||
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
||||||
|
|
||||||
void FileTransferManager::Shutdown() {
|
void FileTransferManager::Shutdown() {
|
||||||
|
browser_.Stop();
|
||||||
std::vector<std::future<void>> workers;
|
std::vector<std::future<void>> workers;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(workers_mutex_);
|
std::lock_guard lock(workers_mutex_);
|
||||||
@@ -87,7 +108,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
|||||||
workers_.push_back(std::async(std::launch::async, [this, props, file] {
|
workers_.push_back(std::async(std::launch::async, [this, props, file] {
|
||||||
auto& state = state_for(props);
|
auto& state = state_for(props);
|
||||||
const auto id = file.file_id;
|
const auto id = file.file_id;
|
||||||
PeerPtr* peer;
|
MiniRtcPeerPtr* peer;
|
||||||
std::shared_ptr<PeerEventHandler> events;
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
{
|
{
|
||||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||||
@@ -105,7 +126,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
|||||||
auto callback = events->EnterCallback();
|
auto callback = events->EnterCallback();
|
||||||
if (!callback || !events->DataAllowed()) return -1;
|
if (!callback || !events->DataAllowed()) return -1;
|
||||||
const auto wire_id = events->WireId(file.remote_id);
|
const auto wire_id = events->WireId(file.remote_id);
|
||||||
return SendReliableDataFrameToPeer(
|
return MiniRtcSendReliableDataFrameToPeer(
|
||||||
peer, data, size, file.file_label.c_str(),
|
peer, data, size, file.file_label.c_str(),
|
||||||
wire_id.data(), wire_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
}, file_transfer::Session::Progress{});
|
}, file_transfer::Session::Progress{});
|
||||||
@@ -147,6 +168,18 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
|||||||
uint64_t previous = UINT64_MAX;
|
uint64_t previous = UINT64_MAX;
|
||||||
auto progress = file_transfer::SendState{id, 0, 0,
|
auto progress = file_transfer::SendState{id, 0, 0,
|
||||||
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
|
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
|
||||||
|
if (cancelled && peer && events && (!props || !props->closing_) &&
|
||||||
|
(props || owner_.IsAuthorizedController(file.remote_id))) {
|
||||||
|
auto callback = events->EnterCallback();
|
||||||
|
if (callback && events->DataAllowed()) {
|
||||||
|
// A queued browser file already reserved a destination on the peer.
|
||||||
|
// Release that reservation even though no data chunk was ever sent.
|
||||||
|
const auto packet = EncodeFileCancel(id, 0);
|
||||||
|
const auto wire_id = events->WireId(file.remote_id);
|
||||||
|
MiniRtcSendReliableDataFrameToPeer(peer, packet.data(), packet.size(),
|
||||||
|
file.file_label.c_str(), wire_id.data(), wire_id.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
do {
|
do {
|
||||||
{
|
{
|
||||||
@@ -288,6 +321,7 @@ void FileTransferManager::UpdateReceiveProgress(
|
|||||||
info.file_id = file_transfer::Session::NextFileId();
|
info.file_id = file_transfer::Session::NextFileId();
|
||||||
info.remote_id = remote_id;
|
info.remote_id = remote_id;
|
||||||
info.receiving = true;
|
info.receiving = true;
|
||||||
|
info.wire_file_id = progress.id;
|
||||||
receive_rate_samples_.emplace(info.file_id,
|
receive_rate_samples_.emplace(info.file_id,
|
||||||
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
|
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
|
||||||
found = notifications.emplace(progress.id, files.size()).first;
|
found = notifications.emplace(progress.id, files.size()).first;
|
||||||
@@ -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) {
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
const auto props = owner_.FindRemoteSession(remote);
|
const auto props = owner_.FindRemoteSession(remote);
|
||||||
PeerPtr* peer;
|
MiniRtcPeerPtr* peer;
|
||||||
std::shared_ptr<PeerEventHandler> events;
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
{
|
{
|
||||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||||
@@ -324,14 +358,14 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
events = owner_.ControllerEvents(remote);
|
events = owner_.ControllerEvents(remote);
|
||||||
peer = events ? events->DirectPeer() : nullptr;
|
peer = events ? events->DirectPeer() : nullptr;
|
||||||
}
|
}
|
||||||
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
|
if (!peer || !events || events.get() != handler || (props && props->closing_)) return {};
|
||||||
std::shared_ptr<FileReceiver> receiver;
|
std::shared_ptr<FileReceiver> receiver;
|
||||||
std::filesystem::path default_directory;
|
std::filesystem::path default_directory;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(sessions_mutex_);
|
std::lock_guard lock(sessions_mutex_);
|
||||||
const auto closed = disconnected_.find(handler);
|
const auto closed = disconnected_.find(handler);
|
||||||
if (stopping_ || (closed != disconnected_.end() &&
|
if (stopping_ || (closed != disconnected_.end() &&
|
||||||
(closed->second.count("") || closed->second.count(remote)))) return;
|
(closed->second.count("") || closed->second.count(remote)))) return {};
|
||||||
auto& entry = receivers_[handler][remote];
|
auto& entry = receivers_[handler][remote];
|
||||||
if (!entry.session) {
|
if (!entry.session) {
|
||||||
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
|
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
|
||||||
@@ -348,7 +382,7 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
if (!callback || !events->DataAllowed()) return -1;
|
if (!callback || !events->DataAllowed()) return -1;
|
||||||
const auto bytes = EncodeFileTransferAck(ack);
|
const auto bytes = EncodeFileTransferAck(ack);
|
||||||
const auto wire_id = events->WireId(remote);
|
const auto wire_id = events->WireId(remote);
|
||||||
return SendReliableDataFrameToPeer(
|
return MiniRtcSendReliableDataFrameToPeer(
|
||||||
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
||||||
wire_id.data(), wire_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
});
|
});
|
||||||
@@ -358,7 +392,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
}
|
}
|
||||||
const auto configured = owner_.config_center_->GetFileTransferSavePath();
|
const auto configured = owner_.config_center_->GetFileTransferSavePath();
|
||||||
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
|
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
|
||||||
receiver->OnData(data, size);
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileTransferManager::Receive(const char* data, size_t size,
|
||||||
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
if (auto receiver = ReceiverFor(handler, remote)) receiver->OnData(data, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
|
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
|
||||||
@@ -371,6 +410,17 @@ void FileTransferManager::Connected(PeerEventHandler* handler, const std::string
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
|
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
for (auto it = browser_peers_.begin(); it != browser_peers_.end();) {
|
||||||
|
if (it->second.get() == handler && (remote.empty() || it->first == remote)) {
|
||||||
|
browser_.Disconnect(it->first);
|
||||||
|
browser_opened_.erase(it->first);
|
||||||
|
browser_supported_.erase(it->first);
|
||||||
|
it = browser_peers_.erase(it);
|
||||||
|
} else ++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
std::lock_guard lock(sessions_mutex_);
|
std::lock_guard lock(sessions_mutex_);
|
||||||
disconnected_[handler].insert(remote);
|
disconnected_[handler].insert(remote);
|
||||||
for (auto& [id, sender] : senders_)
|
for (auto& [id, sender] : senders_)
|
||||||
@@ -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 MiniRtcSendReliableDataFrameToPeer(peer, data.data(), data.size(),
|
||||||
|
owner_.control_data_label_.c_str(), wire_id.data(), wire_id.size()) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::HandleBrowser(const char* data, size_t size,
|
||||||
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
if (!data || size > 60000 || std::string_view(data, size).find("file_browser") == std::string_view::npos)
|
||||||
|
return false;
|
||||||
|
auto message = FileBrowser::Json::parse(data, data + size, nullptr, false);
|
||||||
|
if (!message.is_object() || message.value("type", FileBrowser::Json{}) != "file_browser") return false;
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
|
||||||
|
if (owner_.unattended_host_ || !events || events.get() != handler || !events->DataAllowed() ||
|
||||||
|
(props ? props->closing_ || !props->connection_established_
|
||||||
|
: !owner_.IsAuthorizedController(remote))) return true;
|
||||||
|
bool opened;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
browser_peers_[remote] = events;
|
||||||
|
if (message.value("op", FileBrowser::Json{}) == "presentation") {
|
||||||
|
// Older browser builds synchronized window visibility. Ignore that
|
||||||
|
// optional event: progress now belongs exclusively to the local sender.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
opened = browser_opened_.count(remote);
|
||||||
|
}
|
||||||
|
// A controlled computer cannot browse the controller's disk until that
|
||||||
|
// controller explicitly opens this peer's file manager in this connection.
|
||||||
|
const auto op = message.value("op", FileBrowser::Json{});
|
||||||
|
if (props && !opened && op != "reply") {
|
||||||
|
if (message.contains("id")) SendBrowser(remote,
|
||||||
|
{{"op", "reply"}, {"id", message["id"]}, {"type", "file_browser"},
|
||||||
|
{"version", 1}, {"ok", false}, {"error", "browser_not_open"}});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
browser_.Handle(remote, std::move(message));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<FileTransferManager::FileTransferState::FileTransferInfo>
|
||||||
|
FileTransferManager::BrowserTransfers(const std::string& remote) {
|
||||||
|
std::vector<FileTransferState::FileTransferInfo> result;
|
||||||
|
const auto collect = [&](auto& state) {
|
||||||
|
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||||
|
for (const auto& info : state.file_transfer_list_)
|
||||||
|
if (info.remote_id == remote) result.push_back(info);
|
||||||
|
};
|
||||||
|
collect(global_state_); collect(received_state_);
|
||||||
|
if (auto props = owner_.FindRemoteSession(remote)) collect(props->file_transfer_);
|
||||||
|
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { return a.file_id > b.file_id; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "file_transfer.h"
|
#include "file_transfer.h"
|
||||||
|
#include <file_browser.h>
|
||||||
#include "runtime/remote_session.h"
|
#include "runtime/remote_session.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
@@ -40,6 +41,15 @@ class FileTransferManager {
|
|||||||
bool CancelSend(uint32_t file_id);
|
bool CancelSend(uint32_t file_id);
|
||||||
void Poll();
|
void Poll();
|
||||||
void Shutdown();
|
void Shutdown();
|
||||||
|
FileBrowser& browser() { return browser_; }
|
||||||
|
bool OpenBrowser(const std::string& remote);
|
||||||
|
void SetBrowserSupport(PeerEventHandler* handler, const std::string& remote, bool supported);
|
||||||
|
bool BrowserSupported(const std::string& remote);
|
||||||
|
bool BrowserConnected(const std::string& remote);
|
||||||
|
bool HandleBrowser(const char* data, size_t size, PeerEventHandler* handler,
|
||||||
|
const std::string& remote);
|
||||||
|
std::vector<FileTransferState::FileTransferInfo> BrowserTransfers(const std::string& remote);
|
||||||
|
void CancelBrowserTransfer(const std::string& remote, uint32_t file_id);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Sender {
|
struct Sender {
|
||||||
@@ -53,6 +63,8 @@ class FileTransferManager {
|
|||||||
std::filesystem::path default_directory;
|
std::filesystem::path default_directory;
|
||||||
};
|
};
|
||||||
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
||||||
|
std::shared_ptr<FileReceiver> ReceiverFor(PeerEventHandler* handler, const std::string& remote);
|
||||||
|
bool SendBrowser(const std::string& remote, const FileBrowser::Json& message);
|
||||||
void UpdateProgress(FileTransferState& state,
|
void UpdateProgress(FileTransferState& state,
|
||||||
const file_transfer::SendState& progress,
|
const file_transfer::SendState& progress,
|
||||||
std::chrono::steady_clock::time_point now);
|
std::chrono::steady_clock::time_point now);
|
||||||
@@ -76,6 +88,11 @@ class FileTransferManager {
|
|||||||
FileTransferState received_state_;
|
FileTransferState received_state_;
|
||||||
// Guarded by received_state_.file_transfer_list_mutex_.
|
// Guarded by received_state_.file_transfer_list_mutex_.
|
||||||
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
|
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
|
||||||
|
std::mutex browser_mutex_;
|
||||||
|
std::map<std::string, std::shared_ptr<PeerEventHandler>> browser_peers_;
|
||||||
|
std::set<std::string> browser_opened_;
|
||||||
|
std::set<std::string> browser_supported_;
|
||||||
|
FileBrowser browser_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-05
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _TRANSFER_NOTIFICATION_STATE_H_
|
||||||
|
#define _TRANSFER_NOTIFICATION_STATE_H_
|
||||||
|
|
||||||
|
namespace crossdesk::transfer_notification {
|
||||||
|
|
||||||
|
struct Presentation {
|
||||||
|
bool dismissed = false;
|
||||||
|
bool result_seen = false;
|
||||||
|
|
||||||
|
bool ShowNotification(bool browser_visible, bool result_presented) {
|
||||||
|
// Hiding an active transfer is temporary. A result already presented in
|
||||||
|
// the browser must not pop up again when that window closes.
|
||||||
|
result_seen = result_seen || result_presented;
|
||||||
|
return !dismissed && !result_seen && !browser_visible;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk::transfer_notification
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -39,14 +39,14 @@ void RemoteInputDispatcher::Connect(const std::string& id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
|
RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
|
||||||
const std::string& id, const RemoteAction& action) {
|
const std::string& id, const RemoteAction& action, Completion completion) {
|
||||||
// HostInfo owns raw arrays; it must never enter this value-copy queue.
|
// HostInfo owns raw arrays; it must never enter this value-copy queue.
|
||||||
if (!Accepts(action)) return Result::rejected;
|
if (!Accepts(action)) return Result::rejected;
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
if (stopping_ || !connected_.count(id)) return Result::rejected;
|
if (stopping_ || !connected_.count(id)) return Result::rejected;
|
||||||
if (IsMove(action) && !pending_.empty()) {
|
if (!completion && IsMove(action) && !pending_.empty()) {
|
||||||
auto& last = pending_.back();
|
auto& last = pending_.back();
|
||||||
if (last.id == id && last.IsInput() && IsMove(*last.action)) {
|
if (!last.completion && last.id == id && last.IsInput() && IsMove(*last.action)) {
|
||||||
last.action = action;
|
last.action = action;
|
||||||
last.queued_at = Clock::now();
|
last.queued_at = Clock::now();
|
||||||
return Result::coalesced;
|
return Result::coalesced;
|
||||||
@@ -56,7 +56,7 @@ RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
|
|||||||
QueueReleaseLocked(id);
|
QueueReleaseLocked(id);
|
||||||
return Result::overflow;
|
return Result::overflow;
|
||||||
}
|
}
|
||||||
pending_.push_back({id, action, Clock::now(), ItemKind::input});
|
pending_.push_back({id, action, Clock::now(), ItemKind::input, 0, std::move(completion)});
|
||||||
++pending_counts_[id];
|
++pending_counts_[id];
|
||||||
ready_.notify_one();
|
ready_.notify_one();
|
||||||
return Result::queued;
|
return Result::queued;
|
||||||
@@ -328,6 +328,9 @@ void RemoteInputDispatcher::Run() {
|
|||||||
!pending_counts_.count(item.id))
|
!pending_counts_.count(item.id))
|
||||||
logs_.erase(item.id);
|
logs_.erase(item.id);
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
|
if (item.completion) {
|
||||||
|
try { item.completion(outcome); } catch (...) {}
|
||||||
|
}
|
||||||
if (report) {
|
if (report) {
|
||||||
try {
|
try {
|
||||||
report_(item.id, queued_ms, execution_ms, outcome);
|
report_(item.id, queued_ms, execution_ms, outcome);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class RemoteInputDispatcher {
|
|||||||
// Optional targeted adapter; legacy callers fall back to full Release.
|
// Optional targeted adapter; legacy callers fall back to full Release.
|
||||||
using ReleaseMouse =
|
using ReleaseMouse =
|
||||||
std::function<InputOutcome(const std::string&, unsigned)>;
|
std::function<InputOutcome(const std::string&, unsigned)>;
|
||||||
|
using Completion = std::function<void(InputOutcome)>;
|
||||||
enum class Result { queued, coalesced, rejected, overflow };
|
enum class Result { queued, coalesced, rejected, overflow };
|
||||||
static constexpr size_t kMaxPendingPerController = 128;
|
static constexpr size_t kMaxPendingPerController = 128;
|
||||||
|
|
||||||
@@ -47,7 +48,8 @@ class RemoteInputDispatcher {
|
|||||||
~RemoteInputDispatcher();
|
~RemoteInputDispatcher();
|
||||||
static bool Accepts(const RemoteAction& action);
|
static bool Accepts(const RemoteAction& action);
|
||||||
void Connect(const std::string& id);
|
void Connect(const std::string& id);
|
||||||
Result Push(const std::string& id, const RemoteAction& action);
|
Result Push(const std::string& id, const RemoteAction& action,
|
||||||
|
Completion completion = {});
|
||||||
void Disconnect(const std::string& id);
|
void Disconnect(const std::string& id);
|
||||||
|
|
||||||
// Scheduled recovery is not idle work. Check HasPendingReleases() as well,
|
// Scheduled recovery is not idle work. Check HasPendingReleases() as well,
|
||||||
@@ -68,6 +70,7 @@ class RemoteInputDispatcher {
|
|||||||
// Zero requests full controller cleanup; otherwise only these mouse
|
// Zero requests full controller cleanup; otherwise only these mouse
|
||||||
// buttons.
|
// buttons.
|
||||||
unsigned mouse_mask = 0;
|
unsigned mouse_mask = 0;
|
||||||
|
Completion completion;
|
||||||
bool IsReset() const { return kind == ItemKind::reset_display; }
|
bool IsReset() const { return kind == ItemKind::reset_display; }
|
||||||
bool IsInput() const { return kind == ItemKind::input; }
|
bool IsInput() const { return kind == ItemKind::input; }
|
||||||
bool IsCleanup() const { return kind == ItemKind::cleanup; }
|
bool IsCleanup() const { return kind == ItemKind::cleanup; }
|
||||||
@@ -120,4 +123,4 @@ class RemoteInputDispatcher {
|
|||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ struct SlintVideoPresenter::Impl {
|
|||||||
VideoRenderSize gl_pending_size;
|
VideoRenderSize gl_pending_size;
|
||||||
bool gl_pending_dirty = false;
|
bool gl_pending_dirty = false;
|
||||||
VideoLatencyFrame gl_pending_timing;
|
VideoLatencyFrame gl_pending_timing;
|
||||||
|
bool gl_use_pixel_buffers = false;
|
||||||
|
VideoRenderer::RenderOutcome gl_last_outcome;
|
||||||
|
std::string gl_rendered_stream;
|
||||||
|
std::string gl_requested_stream;
|
||||||
|
uint64_t gl_requested_sequence = 0;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
SurfaceState CurrentState() const {
|
SurfaceState CurrentState() const {
|
||||||
@@ -125,6 +130,13 @@ struct SlintVideoPresenter::Impl {
|
|||||||
auto* opengl_renderer =
|
auto* opengl_renderer =
|
||||||
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
||||||
if (state == slint::RenderingState::RenderingSetup) {
|
if (state == slint::RenderingState::RenderingSetup) {
|
||||||
|
gl_use_pixel_buffers = false;
|
||||||
|
gl_last_outcome = {};
|
||||||
|
gl_rendered_stream.clear();
|
||||||
|
gl_requested_stream.clear();
|
||||||
|
gl_requested_sequence = 0;
|
||||||
|
displayed_frame_sequence.clear();
|
||||||
|
seeded_frame_sequence.clear();
|
||||||
GLuint texture = 0;
|
GLuint texture = 0;
|
||||||
glGenTextures(1, &texture);
|
glGenTextures(1, &texture);
|
||||||
if (texture == 0) {
|
if (texture == 0) {
|
||||||
@@ -165,9 +177,28 @@ struct SlintVideoPresenter::Impl {
|
|||||||
rounded_window ? static_cast<int>(std::lround(
|
rounded_window ? static_cast<int>(std::lround(
|
||||||
kStreamWindowCornerRadius * scale_factor))
|
kStreamWindowCornerRadius * scale_factor))
|
||||||
: 0;
|
: 0;
|
||||||
opengl_renderer->RenderLatest(
|
const auto outcome = opengl_renderer->RenderLatest(
|
||||||
surface.selected_stream, static_cast<int>(window_size.width),
|
surface.selected_stream, static_cast<int>(window_size.width),
|
||||||
static_cast<int>(window_size.height), top_inset, corner_radius);
|
static_cast<int>(window_size.height), top_inset, corner_radius);
|
||||||
|
if (outcome.result == VideoRenderer::RenderResult::idle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.result == VideoRenderer::RenderResult::failed) {
|
||||||
|
// RenderLatest stops native submissions. Apply the UI fallback
|
||||||
|
// on the next UI tick, outside Slint's rendering callback.
|
||||||
|
gl_use_pixel_buffers = true;
|
||||||
|
LOG_WARN("Switching Slint video presentation to pixel buffers");
|
||||||
|
}
|
||||||
|
const bool changed = gl_rendered_stream != surface.selected_stream ||
|
||||||
|
gl_last_outcome.result != outcome.result ||
|
||||||
|
gl_last_outcome.sequence != outcome.sequence ||
|
||||||
|
gl_last_outcome.width != outcome.width ||
|
||||||
|
gl_last_outcome.height != outcome.height;
|
||||||
|
gl_last_outcome = outcome;
|
||||||
|
gl_rendered_stream = surface.selected_stream;
|
||||||
|
if (changed) {
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +242,8 @@ struct SlintVideoPresenter::Impl {
|
|||||||
gl_pending_size = {};
|
gl_pending_size = {};
|
||||||
gl_pending_dirty = false;
|
gl_pending_dirty = false;
|
||||||
gl_pending_frame.clear();
|
gl_pending_frame.clear();
|
||||||
|
gl_last_outcome = {};
|
||||||
|
gl_rendered_stream.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,6 +272,7 @@ void SlintVideoPresenter::PrepareWindow(
|
|||||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||||
impl_->skip_attachment_once = true;
|
impl_->skip_attachment_once = true;
|
||||||
#elif !defined(__OHOS__)
|
#elif !defined(__OHOS__)
|
||||||
|
stream->set_native_video_enabled(false);
|
||||||
impl_->ConfigureOpenGlNotifier();
|
impl_->ConfigureOpenGlNotifier();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -313,7 +347,12 @@ bool SlintVideoPresenter::SelectStream(std::string remote_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||||
const Frame& frame) {
|
const Frame& incoming_frame) {
|
||||||
|
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||||
|
Frame frame = incoming_frame;
|
||||||
|
#else
|
||||||
|
const Frame& frame = incoming_frame;
|
||||||
|
#endif
|
||||||
PresentResult result;
|
PresentResult result;
|
||||||
if (!impl_->stream || frame.remote_id.empty()) {
|
if (!impl_->stream || frame.remote_id.empty()) {
|
||||||
return result;
|
return result;
|
||||||
@@ -355,25 +394,54 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
auto* opengl_renderer =
|
auto* opengl_renderer =
|
||||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||||
if (opengl_renderer && opengl_renderer->IsReady()) {
|
if (opengl_renderer && opengl_renderer->IsReady()) {
|
||||||
opengl_renderer->SetSelectedStream(frame.remote_id);
|
bool needs_redraw = opengl_renderer->SetSelectedStream(frame.remote_id);
|
||||||
if (!(*impl_->stream)->get_native_video_enabled()) {
|
if (!(*impl_->stream)->get_native_video_enabled()) {
|
||||||
(*impl_->stream)->set_native_video_enabled(true);
|
(*impl_->stream)->set_native_video_enabled(true);
|
||||||
|
needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoRenderer::RenderOutcome outcome;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl_->gl_mutex);
|
||||||
|
if (impl_->gl_rendered_stream == frame.remote_id) {
|
||||||
|
outcome = impl_->gl_last_outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
const bool has_frame =
|
const bool has_frame =
|
||||||
frame.width > 0 && frame.height > 0 && frame.sequence > 0;
|
outcome.result == VideoRenderer::RenderResult::rendered;
|
||||||
(*impl_->stream)->set_has_frame(has_frame);
|
(*impl_->stream)->set_has_frame(has_frame);
|
||||||
if (has_frame) {
|
if (has_frame) {
|
||||||
(*impl_->stream)->set_receiving_text("");
|
(*impl_->stream)->set_receiving_text("");
|
||||||
result.width = frame.width;
|
result.width = outcome.width;
|
||||||
result.height = frame.height;
|
result.height = outcome.height;
|
||||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
result.new_frame = impl_->MarkPresented(frame.remote_id, outcome.sequence);
|
||||||
|
}
|
||||||
|
// A rendering acknowledgement also schedules Present(). Do not draw the
|
||||||
|
// same video again solely to acknowledge it, especially for low-FPS peers.
|
||||||
|
if (needs_redraw || impl_->gl_requested_stream != frame.remote_id ||
|
||||||
|
impl_->gl_requested_sequence != frame.sequence) {
|
||||||
|
impl_->gl_requested_stream = frame.remote_id;
|
||||||
|
impl_->gl_requested_sequence = frame.sequence;
|
||||||
|
(*impl_->stream)->window().request_redraw();
|
||||||
}
|
}
|
||||||
(*impl_->stream)->window().request_redraw();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
if ((*impl_->stream)->get_native_video_enabled()) {
|
if ((*impl_->stream)->get_native_video_enabled()) {
|
||||||
(*impl_->stream)->set_native_video_enabled(false);
|
(*impl_->stream)->set_native_video_enabled(false);
|
||||||
|
(*impl_->stream)->set_has_frame(false);
|
||||||
|
(*impl_->stream)->set_frame(slint::Image{});
|
||||||
|
impl_->displayed_frame_sequence.clear();
|
||||||
|
impl_->seeded_frame_sequence.clear();
|
||||||
|
}
|
||||||
|
if (!frame.nv12 && impl_->gl_use_pixel_buffers) {
|
||||||
|
// Native submissions intentionally release the session's CPU buffers.
|
||||||
|
// Recover the retained frame so even a static remote desktop can switch
|
||||||
|
// immediately; subsequent decode callbacks will supply CPU frames.
|
||||||
|
auto pixels = std::make_shared<std::vector<unsigned char>>();
|
||||||
|
if (impl_->renderer.CopyLatestNv12(frame.remote_id, pixels.get(),
|
||||||
|
&frame.width, &frame.height)) {
|
||||||
|
frame.nv12 = std::move(pixels);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -423,7 +491,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
uint32_t texture_id = 0;
|
uint32_t texture_id = 0;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(impl_->gl_mutex);
|
std::lock_guard lock(impl_->gl_mutex);
|
||||||
texture_id = impl_->gl_texture;
|
texture_id = impl_->gl_use_pixel_buffers ? 0 : impl_->gl_texture;
|
||||||
}
|
}
|
||||||
if (texture_id != 0) {
|
if (texture_id != 0) {
|
||||||
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ namespace crossdesk {
|
|||||||
class VideoLatencyStats;
|
class VideoLatencyStats;
|
||||||
|
|
||||||
// Travels with the exact queued pixels, including native frames. Its clock is
|
// Travels with the exact queued pixels, including native frames. Its clock is
|
||||||
// translated once at the receive callback; rendering needs no live PeerPtr.
|
// translated once at the receive callback; rendering needs no live MiniRtcPeerPtr.
|
||||||
struct VideoLatencyFrame {
|
struct VideoLatencyFrame {
|
||||||
using Clock = std::chrono::steady_clock;
|
using Clock = std::chrono::steady_clock;
|
||||||
std::shared_ptr<VideoLatencyStats> stats;
|
std::shared_ptr<VideoLatencyStats> stats;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#include <set>
|
#include <set>
|
||||||
#include <system_error>
|
#include <system_error>
|
||||||
|
|
||||||
#include "signal_server_address.h"
|
#include <minirtc.h>
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -53,12 +53,13 @@ void AnnouncementInbox::Configure(std::string host, int port,
|
|||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
|
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
|
||||||
return;
|
return;
|
||||||
auto address = minirtc::ParseSignalServerAddress(host);
|
MiniRtcSignalServerAddress address{};
|
||||||
if (!address) return;
|
if (MiniRtcParseSignalServerAddress(host.data(), host.size(), &address) != 0) return;
|
||||||
for (auto& ch : address->host)
|
std::string normalized_host(address.host);
|
||||||
|
for (auto& ch : normalized_host)
|
||||||
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
|
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
|
||||||
if (address->host.back() == '.') address->host.pop_back();
|
if (normalized_host.back() == '.') normalized_host.pop_back();
|
||||||
host = address->Address();
|
host = normalized_host + address.target;
|
||||||
const auto scope = json::array({host, port, device_id}).dump();
|
const auto scope = json::array({host, port, device_id}).dump();
|
||||||
if (scope_ == scope) return;
|
if (scope_ == scope) return;
|
||||||
scope_ = scope;
|
scope_ = scope;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#include "runtime/gui_runtime.h"
|
||||||
|
#include <stream_names.h>
|
||||||
|
#include "screen_lock.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
void GuiRuntime::HandleAutomation(const std::string& remote_id,
|
||||||
|
const nlohmann::json& request) {
|
||||||
|
using nlohmann::json;
|
||||||
|
if (request.value("version", json{}) != 1 || !request.contains("id") ||
|
||||||
|
!request["id"].is_number_unsigned() || request["id"].get<uint64_t>() == 0)
|
||||||
|
return;
|
||||||
|
const auto source = ControllerEvents(remote_id);
|
||||||
|
if (!source || !source->DataAllowed()) return;
|
||||||
|
const auto id = request["id"].get<uint64_t>();
|
||||||
|
const auto reply = [this, remote_id, id, source](bool ok, json result) {
|
||||||
|
const auto current = ControllerEvents(remote_id);
|
||||||
|
if (current != source || !current->DataAllowed()) return;
|
||||||
|
const auto message = json{{"type", "automation_result"}, {"version", 1},
|
||||||
|
{"id", id}, {"ok", ok}, {ok ? "result" : "error", std::move(result)}}.dump();
|
||||||
|
SendControllerData(remote_id, message.data(), message.size(), kControlStream);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const auto method = request.value("method", std::string{});
|
||||||
|
if (method == "capabilities") {
|
||||||
|
json capabilities{{"input_ack", true}, {"clipboard", true}, {"files", true}};
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
|
capabilities["system"] = true;
|
||||||
|
capabilities["desktop"] = true;
|
||||||
|
#endif
|
||||||
|
reply(true, std::move(capabilities));
|
||||||
|
} else if (method == "input") {
|
||||||
|
RemoteAction action{};
|
||||||
|
if (!request.contains("arguments") ||
|
||||||
|
!action.from_json(request["arguments"].dump())) {
|
||||||
|
reply(false, "INVALID_ARGUMENT");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto result = remote_input_.Push(remote_id, action,
|
||||||
|
[reply](InputOutcome outcome) {
|
||||||
|
reply(true, {{"state", InputOutcomeName(outcome)}});
|
||||||
|
});
|
||||||
|
FreeRemoteAction(action);
|
||||||
|
if (result == RemoteInputDispatcher::Result::rejected ||
|
||||||
|
result == RemoteInputDispatcher::Result::overflow)
|
||||||
|
reply(false, "INPUT_REJECTED");
|
||||||
|
} else if (method == "desktop") {
|
||||||
|
const auto arguments = request.at("arguments");
|
||||||
|
if (!arguments.is_object()) { reply(false, "INVALID_ARGUMENT"); return; }
|
||||||
|
std::lock_guard lock(automation_mutex_);
|
||||||
|
if (automation_closing_ || automation_tasks_.size() >= 32) { reply(false, "BUSY"); return; }
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||||
|
automation_tasks_.push_back([this, source, remote_id, arguments, reply, deadline] {
|
||||||
|
if (std::chrono::steady_clock::now() > deadline) { reply(false, "REQUEST_EXPIRED"); return; }
|
||||||
|
if (ControllerEvents(remote_id) != source || !IsAuthorizedController(remote_id)) return;
|
||||||
|
try { reply(true, desktop_automation_.Execute(remote_id, arguments)); }
|
||||||
|
catch (const std::exception& error) {
|
||||||
|
const std::string code = error.what();
|
||||||
|
reply(false, !code.empty() && code.size() <= 64 && code.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ_") == std::string::npos
|
||||||
|
? code : "UI_AUTOMATION_FAILED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (method == "system") {
|
||||||
|
const auto operation = request.at("arguments").at("operation").get<std::string>();
|
||||||
|
if (operation != "status" && operation != "lock" && operation != "send_sas") {
|
||||||
|
reply(false, "INVALID_ARGUMENT");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::lock_guard lock(automation_mutex_);
|
||||||
|
if (automation_closing_ || automation_tasks_.size() >= 32) { reply(false, "BUSY"); return; }
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||||
|
automation_tasks_.push_back([this, source, remote_id, operation, reply, deadline] {
|
||||||
|
if (std::chrono::steady_clock::now() > deadline) { reply(false, "REQUEST_EXPIRED"); return; }
|
||||||
|
if (ControllerEvents(remote_id) != source || !IsAuthorizedController(remote_id)) return;
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
|
if (operation == "status") {
|
||||||
|
const auto state = QueryScreenLock();
|
||||||
|
json result{{"lock_state", state == ScreenLockState::locked ? "locked"
|
||||||
|
: state == ScreenLockState::unlocked ? "unlocked" : "unknown"}};
|
||||||
|
#ifdef _WIN32
|
||||||
|
result["interactive_stage"] = WindowsInputStage();
|
||||||
|
result["service_available"] = local_service_available_.load();
|
||||||
|
result["send_sas_available"] = local_service_available_.load();
|
||||||
|
#else
|
||||||
|
result["send_sas_available"] = false;
|
||||||
|
#endif
|
||||||
|
reply(true, std::move(result));
|
||||||
|
} else if (operation == "lock") {
|
||||||
|
reply(true, {{"accepted", LockScreen()}});
|
||||||
|
} else {
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (!local_service_available_.load()) { reply(false, "SERVICE_UNAVAILABLE"); return; }
|
||||||
|
pending_windows_service_sas_.store(true);
|
||||||
|
reply(true, {{"accepted", true}, {"state", "queued"}});
|
||||||
|
#else
|
||||||
|
reply(false, "UNSUPPORTED_OPERATION");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
reply(false, "UNSUPPORTED_OPERATION");
|
||||||
|
#endif
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reply(false, "UNSUPPORTED_OPERATION");
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
reply(false, "INVALID_ARGUMENT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GuiRuntime::ProcessAutomationRequests() {
|
||||||
|
std::function<void()> task;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(automation_mutex_);
|
||||||
|
if (automation_tasks_.empty() || automation_closing_) return;
|
||||||
|
task = std::move(automation_tasks_.front());
|
||||||
|
automation_tasks_.pop_front();
|
||||||
|
}
|
||||||
|
try { task(); } catch (...) {}
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
#include "join_failure.h"
|
#include <minirtc.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class ConnectionAuthState {
|
|||||||
bool password_required = false;
|
bool password_required = false;
|
||||||
bool validating = false;
|
bool validating = false;
|
||||||
bool incorrect_password = false;
|
bool incorrect_password = false;
|
||||||
minirtc::JoinFailureKind error = minirtc::JoinFailureKind::Unknown;
|
MiniRtcJoinFailureKind error = MiniRtcJoinFailureKind::Unknown;
|
||||||
int retry_after = 0;
|
int retry_after = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,15 +35,16 @@ class ConnectionAuthState {
|
|||||||
|
|
||||||
void Rejected(const nlohmann::json& reply,
|
void Rejected(const nlohmann::json& reply,
|
||||||
Clock::time_point now = Clock::now()) {
|
Clock::time_point now = Clock::now()) {
|
||||||
const auto failure = minirtc::ParseJoinFailure(reply);
|
const auto message = reply.dump();
|
||||||
|
const auto failure = MiniRtcParseJoinFailure(message.data(), message.size());
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
state_ = {};
|
state_ = {};
|
||||||
state_.error = failure.kind;
|
state_.error = failure.kind;
|
||||||
state_.incorrect_password =
|
state_.incorrect_password =
|
||||||
failure.kind == minirtc::JoinFailureKind::AuthenticationFailed;
|
failure.kind == MiniRtcJoinFailureKind::AuthenticationFailed;
|
||||||
state_.password_required =
|
state_.password_required =
|
||||||
state_.incorrect_password ||
|
state_.incorrect_password ||
|
||||||
failure.kind == minirtc::JoinFailureKind::PasswordRequired;
|
failure.kind == MiniRtcJoinFailureKind::PasswordRequired;
|
||||||
retry_at_ = now + std::chrono::seconds(failure.retry_after);
|
retry_at_ = now + std::chrono::seconds(failure.retry_after);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ bool GuiRuntime::HasActiveSession() {
|
|||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
||||||
[](const auto& entry) {
|
[](const auto& entry) {
|
||||||
return entry.second == ConnectionStatus::Connecting ||
|
return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnecting ||
|
||||||
entry.second == ConnectionStatus::Connected;
|
entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props)
|
|||||||
action.type = ControlType::audio_capture;
|
action.type = ControlType::audio_capture;
|
||||||
action.a = true;
|
action.a = true;
|
||||||
const auto message = action.to_json();
|
const auto message = action.to_json();
|
||||||
SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
MiniRtcSendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||||
props->control_data_label_.c_str());
|
props->control_data_label_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ void GuiRuntime::HandleConnectionStatusChange() {
|
|||||||
const auto message =
|
const auto message =
|
||||||
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
|
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
|
||||||
gallery_presence_ids_).dump();
|
gallery_presence_ids_).dump();
|
||||||
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
|
if (MiniRtcSendSignalMessage(peer_, message.data(), message.size()) == 0) {
|
||||||
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
|
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
|
||||||
} else {
|
} else {
|
||||||
need_to_send_recent_connections_ = true;
|
need_to_send_recent_connections_ = true;
|
||||||
@@ -207,7 +207,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
|||||||
codec_status_controllers_.erase(remote_id);
|
codec_status_controllers_.erase(remote_id);
|
||||||
privacy_sessions_.Disconnected(remote_id);
|
privacy_sessions_.Disconnected(remote_id);
|
||||||
for (const auto& [id, status] : connection_status_) {
|
for (const auto& [id, status] : connection_status_) {
|
||||||
if (status != ConnectionStatus::Connected) {
|
if (status != MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
has_connected_controller = true;
|
has_connected_controller = true;
|
||||||
@@ -296,7 +296,7 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
|
|||||||
gallery_presence_ids_)
|
gallery_presence_ids_)
|
||||||
.dump();
|
.dump();
|
||||||
|
|
||||||
int ret = SendSignalMessage(peer_, s.data(), s.size());
|
int ret = MiniRtcSendSignalMessage(peer_, s.data(), s.size());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
||||||
pending_presence_probe_ = false;
|
pending_presence_probe_ = false;
|
||||||
@@ -315,7 +315,7 @@ int GuiRuntime::ForceSignalReconnect() {
|
|||||||
LOG_WARN("Signal reconnect requested with no connection peer");
|
LOG_WARN("Signal reconnect requested with no connection peer");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return ReconnectSignal(peer_);
|
return MiniRtcReconnectSignal(peer_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||||
@@ -332,7 +332,7 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
}
|
}
|
||||||
props->connection_established_ = false;
|
props->connection_established_ = false;
|
||||||
props->streaming_ = false;
|
props->streaming_ = false;
|
||||||
props->connection_status_.store(ConnectionStatus::Closed);
|
props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionClosed);
|
||||||
std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
|
std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
|
||||||
int video_width = 0;
|
int video_width = 0;
|
||||||
int video_height = 0;
|
int video_height = 0;
|
||||||
@@ -365,7 +365,7 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
video_frame_dirty_.store(true, std::memory_order_release);
|
video_frame_dirty_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
PeerPtr* peer = nullptr;
|
MiniRtcPeerPtr* peer = nullptr;
|
||||||
{
|
{
|
||||||
// Audio/clipboard senders hold a shared map lock while using this pointer.
|
// Audio/clipboard senders hold a shared map lock while using this pointer.
|
||||||
std::unique_lock lock(remote_sessions_mutex_);
|
std::unique_lock lock(remote_sessions_mutex_);
|
||||||
@@ -384,8 +384,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
if (peer && !props->lan_session_) {
|
if (peer && !props->lan_session_) {
|
||||||
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
||||||
props->remote_id_);
|
props->remote_id_);
|
||||||
LeaveConnection(peer, props->remote_id_.c_str());
|
MiniRtcLeaveConnection(peer, props->remote_id_.c_str());
|
||||||
DestroyPeer(&peer);
|
MiniRtcDestroyPeer(&peer);
|
||||||
}
|
}
|
||||||
if (thumbnail && props->was_connected_) {
|
if (thumbnail && props->was_connected_) {
|
||||||
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
||||||
@@ -430,7 +430,7 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
|||||||
return;
|
return;
|
||||||
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
||||||
[peer = peer_, transmission_id = std::string(client_id_), remote_id] {
|
[peer = peer_, transmission_id = std::string(client_id_), remote_id] {
|
||||||
if (DisconnectPeerConnection(peer, transmission_id.c_str(),
|
if (MiniRtcDisconnectPeerConnection(peer, transmission_id.c_str(),
|
||||||
remote_id.c_str()) != 0) {
|
remote_id.c_str()) != 0) {
|
||||||
LOG_WARN("[{}] Could not queue controller disconnect notification",
|
LOG_WARN("[{}] Could not queue controller disconnect notification",
|
||||||
remote_id);
|
remote_id);
|
||||||
@@ -462,8 +462,8 @@ void GuiRuntime::CloseConnectionPeer() {
|
|||||||
HandleServerControllerDisconnected(id, "peer_closed");
|
HandleServerControllerDisconnected(id, "peer_closed");
|
||||||
signal_connected_ = false;
|
signal_connected_ = false;
|
||||||
device_presence_cache_.SetSignalConnected(false);
|
device_presence_cache_.SetSignalConnected(false);
|
||||||
signal_status_ = SignalStatus::SignalClosed;
|
signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
|
||||||
PeerPtr* peer;
|
MiniRtcPeerPtr* peer;
|
||||||
{
|
{
|
||||||
std::unique_lock lock(remote_sessions_mutex_);
|
std::unique_lock lock(remote_sessions_mutex_);
|
||||||
peer = std::exchange(peer_, nullptr);
|
peer = std::exchange(peer_, nullptr);
|
||||||
@@ -474,8 +474,8 @@ void GuiRuntime::CloseConnectionPeer() {
|
|||||||
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
||||||
handler->Deactivate();
|
handler->Deactivate();
|
||||||
LOG_INFO("[{}] Background connection peer cleanup", id);
|
LOG_INFO("[{}] Background connection peer cleanup", id);
|
||||||
LeaveConnection(peer, id.c_str());
|
MiniRtcLeaveConnection(peer, id.c_str());
|
||||||
DestroyPeer(&peer);
|
MiniRtcDestroyPeer(&peer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,9 +673,9 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
if (exists && !stale->closing_.load() &&
|
if (exists && !stale->closing_.load() &&
|
||||||
!stale->connection_established_) {
|
!stale->connection_established_) {
|
||||||
const auto status = stale->connection_status_.load();
|
const auto status = stale->connection_status_.load();
|
||||||
if (status == ConnectionStatus::Disconnected ||
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected ||
|
||||||
status == ConnectionStatus::Failed ||
|
status == MiniRtcConnectionStatus::MiniRtcConnectionFailed ||
|
||||||
status == ConnectionStatus::Closed) {
|
status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) {
|
||||||
// The recreated session starts with an empty password; callers that
|
// The recreated session starts with an empty password; callers that
|
||||||
// pass none expect the one stored by the previous attempt to be reused.
|
// pass none expect the one stored by the previous attempt to be reused.
|
||||||
const bool keep_stored_password = !password || !*password;
|
const bool keep_stored_password = !password || !*password;
|
||||||
@@ -692,7 +692,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
PeerPtr* peer_to_init = nullptr;
|
MiniRtcPeerPtr* peer_to_init = nullptr;
|
||||||
std::string local_id;
|
std::string local_id;
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -707,13 +707,13 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
props->control_mouse_ = !background;
|
props->control_mouse_ = !background;
|
||||||
props->enable_mouse_control_ = !background;
|
props->enable_mouse_control_ = !background;
|
||||||
props->audio_capture_button_pressed_ = !background;
|
props->audio_capture_button_pressed_ = !background;
|
||||||
memcpy(&props->params_, ¶ms_, sizeof(Params));
|
memcpy(&props->params_, ¶ms_, sizeof(MiniRtcParams));
|
||||||
props->params_.user_id = props->local_id_.c_str();
|
props->params_.user_id = props->local_id_.c_str();
|
||||||
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
|
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
|
||||||
props->params_.user_data = props->peer_events_.get();
|
props->params_.user_data = props->peer_events_.get();
|
||||||
props->peer_ = CreatePeer(&props->params_);
|
props->peer_ = MiniRtcCreatePeer(&props->params_);
|
||||||
|
|
||||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
|
||||||
if (!background) show_connection_status_window_ = true;
|
if (!background) show_connection_status_window_ = true;
|
||||||
|
|
||||||
if (!props->peer_) {
|
if (!props->peer_) {
|
||||||
@@ -724,18 +724,18 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
const auto& displays = devices_.display_info_list();
|
const auto& displays = devices_.display_info_list();
|
||||||
for (size_t index = 0; index < displays.size(); ++index) {
|
for (size_t index = 0; index < displays.size(); ++index) {
|
||||||
const std::string stream_id = MakeDisplayStreamId(index);
|
const std::string stream_id = MakeDisplayStreamId(index);
|
||||||
AddVideoStream(props->peer_, stream_id.c_str());
|
MiniRtcAddVideoStream(props->peer_, stream_id.c_str());
|
||||||
}
|
}
|
||||||
AddAudioStream(props->peer_, props->audio_label_.c_str());
|
MiniRtcAddAudioStream(props->peer_, props->audio_label_.c_str());
|
||||||
AddDataStream(props->peer_, props->data_label_.c_str(), false);
|
MiniRtcAddDataStream(props->peer_, props->data_label_.c_str(), false);
|
||||||
AddDataStream(props->peer_, props->mouse_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->mouse_label_.c_str(), true);
|
||||||
AddDataStream(props->peer_, props->keyboard_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->keyboard_label_.c_str(), true);
|
||||||
AddDataStream(props->peer_, props->control_data_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->control_data_label_.c_str(), true);
|
||||||
AddDataStream(props->peer_, props->file_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->file_label_.c_str(), true);
|
||||||
AddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
|
||||||
AddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
|
MiniRtcAddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
|
||||||
|
|
||||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
|
||||||
|
|
||||||
peer_to_init = props->peer_;
|
peer_to_init = props->peer_;
|
||||||
local_id = props->local_id_;
|
local_id = props->local_id_;
|
||||||
@@ -744,7 +744,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
|
|
||||||
if (peer_to_init) {
|
if (peer_to_init) {
|
||||||
LOG_INFO("[{}] Create peer instance successful", local_id);
|
LOG_INFO("[{}] Create peer instance successful", local_id);
|
||||||
Init(peer_to_init);
|
MiniRtcInit(peer_to_init);
|
||||||
LOG_INFO("[{}] Peer init finish", local_id);
|
LOG_INFO("[{}] Peer init finish", local_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,10 +773,10 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
|||||||
props->rejoin_ = false;
|
props->rejoin_ = false;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
props->connection_status_.store(MiniRtcConnectionStatus::MiniRtcConnectionConnecting);
|
||||||
std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_;
|
std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_;
|
||||||
if (props->peer_) {
|
if (props->peer_) {
|
||||||
ret = JoinConnection(props->peer_, remote_id_with_pwd.c_str());
|
ret = MiniRtcJoinConnection(props->peer_, remote_id_with_pwd.c_str());
|
||||||
if (0 == ret) {
|
if (0 == ret) {
|
||||||
props->rejoin_ = false;
|
props->rejoin_ = false;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
#include "runtime/trusted_devices.h"
|
#include "runtime/trusted_devices.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
int DeviceLogin::Exchange(SignalExchangeStage stage, const nlohmann::json& message,
|
int DeviceLogin::Exchange(MiniRtcSignalExchangeStage stage, const nlohmann::json& message,
|
||||||
const TrustedDevices* identity, char* response, size_t capacity) {
|
const TrustedDevices* identity, char* response, size_t capacity) {
|
||||||
auto output = [&](const nlohmann::json& reply) {
|
auto output = [&](const nlohmann::json& reply) {
|
||||||
const auto text = reply.dump();
|
const auto text = reply.dump();
|
||||||
@@ -14,7 +14,7 @@ int DeviceLogin::Exchange(SignalExchangeStage stage, const nlohmann::json& messa
|
|||||||
std::memcpy(response, text.data(), text.size());
|
std::memcpy(response, text.data(), text.size());
|
||||||
return static_cast<int>(text.size());
|
return static_cast<int>(text.size());
|
||||||
};
|
};
|
||||||
if (stage == SignalExchangeStage::PrepareLogin) {
|
if (stage == MiniRtcSignalExchangeStage::PrepareLogin) {
|
||||||
phase_ = Phase::Idle;
|
phase_ = Phase::Idle;
|
||||||
used_password_ = false;
|
used_password_ = false;
|
||||||
password_.clear();
|
password_.clear();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class TrustedDevices;
|
|||||||
// One signaling connection's application authentication, owned by its callbacks.
|
// One signaling connection's application authentication, owned by its callbacks.
|
||||||
class DeviceLogin {
|
class DeviceLogin {
|
||||||
public:
|
public:
|
||||||
int Exchange(SignalExchangeStage stage, const nlohmann::json& message,
|
int Exchange(MiniRtcSignalExchangeStage stage, const nlohmann::json& message,
|
||||||
const TrustedDevices* identity, char* response, size_t capacity);
|
const TrustedDevices* identity, char* response, size_t capacity);
|
||||||
bool Pending() const { return phase_ != Phase::Idle; }
|
bool Pending() const { return phase_ != Phase::Idle; }
|
||||||
bool UsedPassword() const { return used_password_; }
|
bool UsedPassword() const { return used_password_; }
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ void GuiRuntime::ForgetControllerTrust(const std::string& id, PeerEventHandler*
|
|||||||
|
|
||||||
int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) {
|
int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) {
|
||||||
const auto text = message.dump();
|
const auto text = message.dump();
|
||||||
return WithController(id, [&](PeerPtr* peer, const std::string& wire) {
|
return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
|
||||||
return SendReliableDataFrameToPeer(peer, text.data(), text.size(),
|
return MiniRtcSendReliableDataFrameToPeer(peer, text.data(), text.size(),
|
||||||
control_data_label_.c_str(), wire.data(), wire.size());
|
control_data_label_.c_str(), wire.data(), wire.size());
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandle
|
|||||||
const auto wire = events->WireId(id);
|
const auto wire = events->WireId(id);
|
||||||
const auto proof = trusted_devices_->Prove(message.at("nonce"),
|
const auto proof = trusted_devices_->Prove(message.at("nonce"),
|
||||||
props->local_id_, GetHostName()).dump();
|
props->local_id_, GetHostName()).dump();
|
||||||
SendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(),
|
MiniRtcSendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(),
|
||||||
control_data_label_.c_str(), wire.data(), wire.size());
|
control_data_label_.c_str(), wire.data(), wire.size());
|
||||||
} else if (type == "device_trust_proof") {
|
} else if (type == "device_trust_proof") {
|
||||||
std::lock_guard lock(controller_trust_mutex_);
|
std::lock_guard lock(controller_trust_mutex_);
|
||||||
@@ -156,7 +156,7 @@ void GuiRuntime::TickControllerTrust() {
|
|||||||
remote_input_.Disconnect(action.id);
|
remote_input_.Disconnect(action.id);
|
||||||
CloseServerController(action.id);
|
CloseServerController(action.id);
|
||||||
} else if (action.kind > 0) {
|
} else if (action.kind > 0) {
|
||||||
events->ApplyConnectionStatus(ConnectionStatus::Connected, action.id);
|
events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionConnected, action.id);
|
||||||
if (!action.host_info.empty()) {
|
if (!action.host_info.empty()) {
|
||||||
const auto wire = events->WireId(action.id);
|
const auto wire = events->WireId(action.id);
|
||||||
PeerEventHandler::OnReceiveDataBuffer(action.host_info.data(), action.host_info.size(),
|
PeerEventHandler::OnReceiveDataBuffer(action.host_info.data(), action.host_info.size(),
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ GuiRuntime::GuiRuntime()
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
GuiRuntime::~GuiRuntime() {
|
GuiRuntime::~GuiRuntime() {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(automation_mutex_);
|
||||||
|
automation_closing_ = true;
|
||||||
|
automation_tasks_.clear();
|
||||||
|
}
|
||||||
remote_input_.Stop();
|
remote_input_.Stop();
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||||
@@ -94,7 +99,7 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
|||||||
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
|
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
|
||||||
const auto available = [](const auto& props) {
|
const auto available = [](const auto& props) {
|
||||||
return props && props->peer_ && !props->closing_ &&
|
return props && props->peer_ && !props->closing_ &&
|
||||||
props->connection_status_ == ConnectionStatus::Connected;
|
props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||||
};
|
};
|
||||||
std::shared_lock lock(remote_sessions_mutex_);
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
const auto source = remote_sessions_.find(target_id);
|
const auto source = remote_sessions_.find(target_id);
|
||||||
@@ -116,9 +121,9 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
|||||||
// Use the negotiated unreliable stream; clicks and wheel events remain
|
// Use the negotiated unreliable stream; clicks and wheel events remain
|
||||||
// on the reliable mouse stream, with their own absolute coordinates.
|
// on the reliable mouse stream, with their own absolute coordinates.
|
||||||
const int result = mouse_move
|
const int result = mouse_move
|
||||||
? SendDataFrame(props->peer_, message.data(), message.size(),
|
? MiniRtcSendDataFrame(props->peer_, message.data(), message.size(),
|
||||||
props->data_label_.c_str())
|
props->data_label_.c_str())
|
||||||
: SendReliableDataFrame(
|
: MiniRtcSendReliableDataFrame(
|
||||||
props->peer_, message.data(), message.size(),
|
props->peer_, message.data(), message.size(),
|
||||||
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
|
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
|
||||||
if (result != 0)
|
if (result != 0)
|
||||||
@@ -251,15 +256,15 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1
|
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1
|
||||||
? true
|
? true
|
||||||
: false;
|
: false;
|
||||||
params_.turn_mode = static_cast<TurnMode>(config_center_->GetTurnMode());
|
params_.turn_mode = static_cast<MiniRtcTurnMode>(config_center_->GetTurnMode());
|
||||||
params_.enable_srtp = config_center_->IsEnableSrtp();
|
params_.enable_srtp = config_center_->IsEnableSrtp();
|
||||||
params_.video_content_type = VideoContentType::ScreenContent;
|
params_.video_content_type = MiniRtcVideoContentType::ScreenContent;
|
||||||
// Bootstrap outgoing media with fixed defaults. Live overrides come only
|
// Bootstrap outgoing media with fixed defaults. Live overrides come only
|
||||||
// from the authorized controller and apply to its connection on this host.
|
// from the authorized controller and apply to its connection on this host.
|
||||||
params_.video_quality = VideoQuality::QualityHigh;
|
params_.video_quality = MiniRtcVideoQuality::MiniRtcVideoQualityHigh;
|
||||||
params_.video_frame_rate = 60;
|
params_.video_frame_rate = 60;
|
||||||
params_.video_degradation_preference =
|
params_.video_degradation_preference =
|
||||||
VideoDegradationPreference::MaintainResolution;
|
MiniRtcVideoDegradationPreference::MaintainResolution;
|
||||||
params_.on_receive_video_buffer = nullptr;
|
params_.on_receive_video_buffer = nullptr;
|
||||||
params_.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
params_.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
||||||
params_.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
params_.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
||||||
@@ -279,15 +284,15 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
signal_connected_ = false;
|
signal_connected_ = false;
|
||||||
device_presence_cache_.SetSignalConnected(false);
|
device_presence_cache_.SetSignalConnected(false);
|
||||||
need_to_send_recent_connections_ = true;
|
need_to_send_recent_connections_ = true;
|
||||||
signal_status_ = SignalStatus::SignalConnecting;
|
signal_status_ = MiniRtcSignalStatus::MiniRtcSignalConnecting;
|
||||||
|
|
||||||
{
|
{
|
||||||
std::unique_lock lock(remote_sessions_mutex_);
|
std::unique_lock lock(remote_sessions_mutex_);
|
||||||
peer_ = CreatePeer(¶ms_);
|
peer_ = MiniRtcCreatePeer(¶ms_);
|
||||||
}
|
}
|
||||||
if (peer_) {
|
if (peer_) {
|
||||||
LOG_INFO("Create peer instance [{}] successful", client_id_);
|
LOG_INFO("Create peer instance [{}] successful", client_id_);
|
||||||
Init(peer_);
|
MiniRtcInit(peer_);
|
||||||
LOG_INFO("Peer [{}] init finish", client_id_);
|
LOG_INFO("Peer [{}] init finish", client_id_);
|
||||||
} else {
|
} else {
|
||||||
LOG_INFO("Create peer [{}] instance failed", client_id_);
|
LOG_INFO("Create peer [{}] instance failed", client_id_);
|
||||||
@@ -297,19 +302,19 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
const auto &displays = devices_.display_info_list();
|
const auto &displays = devices_.display_info_list();
|
||||||
for (size_t index = 0; index < displays.size(); ++index) {
|
for (size_t index = 0; index < displays.size(); ++index) {
|
||||||
const std::string stream_id = MakeDisplayStreamId(index);
|
const std::string stream_id = MakeDisplayStreamId(index);
|
||||||
AddVideoStream(peer_, stream_id.c_str());
|
MiniRtcAddVideoStream(peer_, stream_id.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
AddAudioStream(peer_, audio_label_.c_str());
|
MiniRtcAddAudioStream(peer_, audio_label_.c_str());
|
||||||
AddDataStream(peer_, data_label_.c_str(), false);
|
MiniRtcAddDataStream(peer_, data_label_.c_str(), false);
|
||||||
AddDataStream(peer_, mouse_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, mouse_label_.c_str(), true);
|
||||||
AddDataStream(peer_, keyboard_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, keyboard_label_.c_str(), true);
|
||||||
AddDataStream(peer_, control_data_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, control_data_label_.c_str(), true);
|
||||||
AddDataStream(peer_, file_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, file_label_.c_str(), true);
|
||||||
AddDataStream(peer_, file_feedback_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, file_feedback_label_.c_str(), true);
|
||||||
AddDataStream(peer_, clipboard_label_.c_str(), true);
|
MiniRtcAddDataStream(peer_, clipboard_label_.c_str(), true);
|
||||||
if (!unattended_host_)
|
if (!unattended_host_)
|
||||||
AddDataStream(peer_, web_clipboard::kStream, true);
|
MiniRtcAddDataStream(peer_, web_clipboard::kStream, true);
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -385,10 +390,10 @@ void GuiRuntime::SdlCaptureAudioIn(void *userdata, Uint8 *stream, int len) {
|
|||||||
uint64_t captured_timestamp = 0;
|
uint64_t captured_timestamp = 0;
|
||||||
for (const auto &it : runtime->remote_sessions_) {
|
for (const auto &it : runtime->remote_sessions_) {
|
||||||
auto props = it.second;
|
auto props = it.second;
|
||||||
if (props->connection_status_.load() == ConnectionStatus::Connected) {
|
if (props->connection_status_.load() == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
if (props->peer_) {
|
if (props->peer_) {
|
||||||
if (captured_timestamp == 0) {
|
if (captured_timestamp == 0) {
|
||||||
const int64_t timestamp_us = GetSystemTimeMicros(props->peer_);
|
const int64_t timestamp_us = MiniRtcGetSystemTimeMicros(props->peer_);
|
||||||
if (timestamp_us > 0) {
|
if (timestamp_us > 0) {
|
||||||
captured_timestamp = static_cast<uint64_t>(timestamp_us);
|
captured_timestamp = static_cast<uint64_t>(timestamp_us);
|
||||||
}
|
}
|
||||||
@@ -397,7 +402,7 @@ void GuiRuntime::SdlCaptureAudioIn(void *userdata, Uint8 *stream, int len) {
|
|||||||
frame.data = reinterpret_cast<const char *>(stream);
|
frame.data = reinterpret_cast<const char *>(stream);
|
||||||
frame.size = static_cast<size_t>(len);
|
frame.size = static_cast<size_t>(len);
|
||||||
frame.captured_timestamp = captured_timestamp;
|
frame.captured_timestamp = captured_timestamp;
|
||||||
SendAudioFrame(props->peer_, &frame,
|
MiniRtcSendAudioFrame(props->peer_, &frame,
|
||||||
runtime->audio_label_.c_str());
|
runtime->audio_label_.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
|
#include <future>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "desktop_automation.h"
|
||||||
#include "features/clipboard/clipboard_controller.h"
|
#include "features/clipboard/clipboard_controller.h"
|
||||||
#include "features/devices/session_device_manager.h"
|
#include "features/devices/session_device_manager.h"
|
||||||
#include "features/file_transfer/file_transfer_manager.h"
|
#include "features/file_transfer/file_transfer_manager.h"
|
||||||
@@ -27,6 +29,9 @@
|
|||||||
#include "runtime/peer_event_handler.h"
|
#include "runtime/peer_event_handler.h"
|
||||||
#include "runtime/privacy_session_policy.h"
|
#include "runtime/privacy_session_policy.h"
|
||||||
#include "runtime/trusted_devices.h"
|
#include "runtime/trusted_devices.h"
|
||||||
|
#ifdef __APPLE__
|
||||||
|
#include "platform/macos/gui/runtime/mac_permission_migration.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
@@ -59,12 +64,12 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
void TickLan();
|
void TickLan();
|
||||||
void BeginShutdown();
|
void BeginShutdown();
|
||||||
bool ShutdownDrained();
|
bool ShutdownDrained();
|
||||||
void RegisterStreams(PeerPtr* peer, size_t display_count);
|
void RegisterStreams(MiniRtcPeerPtr* peer, size_t display_count);
|
||||||
void ConnectLan(const std::string& address, unsigned port,
|
void ConnectLan(const std::string& address, unsigned port,
|
||||||
std::string password, bool remember_password = false);
|
std::string password, bool remember_password = false);
|
||||||
int WithController(
|
int WithController(
|
||||||
const std::string& id,
|
const std::string& id,
|
||||||
const std::function<int(PeerPtr*, const std::string&)>& operation,
|
const std::function<int(MiniRtcPeerPtr*, const std::string&)>& operation,
|
||||||
bool require_trusted = true);
|
bool require_trusted = true);
|
||||||
int SendControllerData(const std::string& id, const char* data, size_t size,
|
int SendControllerData(const std::string& id, const char* data, size_t size,
|
||||||
const char* label);
|
const char* label);
|
||||||
@@ -104,6 +109,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
void HandleServerControllerDisconnected(const std::string& remote_id,
|
void HandleServerControllerDisconnected(const std::string& remote_id,
|
||||||
const char* reason);
|
const char* reason);
|
||||||
void HandleWindowsServiceIntegration();
|
void HandleWindowsServiceIntegration();
|
||||||
|
void ProcessAutomationRequests();
|
||||||
void HandlePrivacy();
|
void HandlePrivacy();
|
||||||
void HandleScreenLock();
|
void HandleScreenLock();
|
||||||
bool IsAuthorizedController(const std::string& remote_id);
|
bool IsAuthorizedController(const std::string& remote_id);
|
||||||
@@ -152,6 +158,11 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
// or every connected session while that target leads DeskHub group control.
|
// or every connected session while that target leads DeskHub group control.
|
||||||
void SendRemoteInput(const RemoteAction& action);
|
void SendRemoteInput(const RemoteAction& action);
|
||||||
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
|
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
|
||||||
|
void HandleAutomation(const std::string& remote_id, const nlohmann::json& request);
|
||||||
|
DesktopAutomation desktop_automation_;
|
||||||
|
std::mutex automation_mutex_;
|
||||||
|
std::deque<std::function<void()>> automation_tasks_;
|
||||||
|
bool automation_closing_ = false;
|
||||||
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
|
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
|
||||||
std::string gallery_group_control_id_;
|
std::string gallery_group_control_id_;
|
||||||
|
|
||||||
@@ -173,6 +184,11 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef __APPLE__
|
#ifdef __APPLE__
|
||||||
|
void StartMacPermissionMigration(bool retry);
|
||||||
|
std::future<MacPermissionMigrationResult> mac_permission_migration_task_;
|
||||||
|
bool mac_permission_migration_failed_ = false;
|
||||||
|
bool mac_permission_dialog_dismissed_ = false;
|
||||||
|
bool mac_permission_dialog_requested_ = false;
|
||||||
bool CheckScreenRecordingPermission();
|
bool CheckScreenRecordingPermission();
|
||||||
bool CheckAccessibilityPermission();
|
bool CheckAccessibilityPermission();
|
||||||
void OpenScreenRecordingPreferences();
|
void OpenScreenRecordingPreferences();
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) {
|
void GuiRuntime::RegisterStreams(MiniRtcPeerPtr* peer, size_t displays) {
|
||||||
for (size_t i = 0; i < displays; ++i) {
|
for (size_t i = 0; i < displays; ++i) {
|
||||||
const auto id = MakeDisplayStreamId(i);
|
const auto id = MakeDisplayStreamId(i);
|
||||||
AddVideoStream(peer, id.c_str());
|
MiniRtcAddVideoStream(peer, id.c_str());
|
||||||
}
|
}
|
||||||
AddAudioStream(peer, audio_label_.c_str());
|
MiniRtcAddAudioStream(peer, audio_label_.c_str());
|
||||||
AddDataStream(peer, data_label_.c_str(), false);
|
MiniRtcAddDataStream(peer, data_label_.c_str(), false);
|
||||||
for (const auto& label :
|
for (const auto& label :
|
||||||
{mouse_label_, keyboard_label_, control_data_label_, file_label_,
|
{mouse_label_, keyboard_label_, control_data_label_, file_label_,
|
||||||
file_feedback_label_, clipboard_label_})
|
file_feedback_label_, clipboard_label_})
|
||||||
AddDataStream(peer, label.c_str(), true);
|
MiniRtcAddDataStream(peer, label.c_str(), true);
|
||||||
}
|
}
|
||||||
void GuiRuntime::InitializeLan() {
|
void GuiRuntime::InitializeLan() {
|
||||||
if (unattended_host_ || stopping_ || lan_ ||
|
if (unattended_host_ || stopping_ || lan_ ||
|
||||||
@@ -37,8 +37,8 @@ void GuiRuntime::InitializeLan() {
|
|||||||
auto events = std::make_shared<PeerEventHandler>(*this);
|
auto events = std::make_shared<PeerEventHandler>(*this);
|
||||||
events->BindDirect(session);
|
events->BindDirect(session);
|
||||||
// Direct initialization must not depend on cloud login/configuration having
|
// Direct initialization must not depend on cloud login/configuration having
|
||||||
// successfully populated the managed peer's Params snapshot.
|
// successfully populated the managed peer's MiniRtcParams snapshot.
|
||||||
Params params{};
|
MiniRtcParams params{};
|
||||||
std::snprintf(params.log_path, sizeof(params.log_path), "%s",
|
std::snprintf(params.log_path, sizeof(params.log_path), "%s",
|
||||||
dll_log_path_.c_str());
|
dll_log_path_.c_str());
|
||||||
params.hardware_acceleration = true;
|
params.hardware_acceleration = true;
|
||||||
@@ -46,12 +46,12 @@ void GuiRuntime::InitializeLan() {
|
|||||||
params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
|
params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
|
||||||
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
|
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
|
||||||
params.enable_srtp = true;
|
params.enable_srtp = true;
|
||||||
params.turn_mode = TurnMode::TurnDisabled;
|
params.turn_mode = MiniRtcTurnMode::MiniRtcTurnDisabled;
|
||||||
params.video_content_type = VideoContentType::ScreenContent;
|
params.video_content_type = MiniRtcVideoContentType::ScreenContent;
|
||||||
params.video_quality = VideoQuality::QualityHigh;
|
params.video_quality = MiniRtcVideoQuality::MiniRtcVideoQualityHigh;
|
||||||
params.video_frame_rate = 60;
|
params.video_frame_rate = 60;
|
||||||
params.video_degradation_preference =
|
params.video_degradation_preference =
|
||||||
VideoDegradationPreference::MaintainResolution;
|
MiniRtcVideoDegradationPreference::MaintainResolution;
|
||||||
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
|
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
|
||||||
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
||||||
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
||||||
@@ -69,7 +69,7 @@ void GuiRuntime::InitializeLan() {
|
|||||||
lan::SessionManager::Prepared result;
|
lan::SessionManager::Prepared result;
|
||||||
result.callback_owner = events;
|
result.callback_owner = events;
|
||||||
result.drain_callbacks = [events] { events->Deactivate(); };
|
result.drain_callbacks = [events] { events->Deactivate(); };
|
||||||
result.peer = CreatePeer(¶ms);
|
result.peer = MiniRtcCreatePeer(¶ms);
|
||||||
events->SetDirectPeer(result.peer);
|
events->SetDirectPeer(result.peer);
|
||||||
if (result.peer) RegisterStreams(result.peer, displays);
|
if (result.peer) RegisterStreams(result.peer, displays);
|
||||||
return result;
|
return result;
|
||||||
@@ -88,7 +88,7 @@ void GuiRuntime::InitializeLan() {
|
|||||||
hooks.connected = [this](const std::shared_ptr<lan::Session>& session) {
|
hooks.connected = [this](const std::shared_ptr<lan::Session>& session) {
|
||||||
auto events =
|
auto events =
|
||||||
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
|
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
|
||||||
events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key);
|
events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionConnected, session->key);
|
||||||
events->FlushEarlyData();
|
events->FlushEarlyData();
|
||||||
};
|
};
|
||||||
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
|
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
|
||||||
@@ -101,7 +101,7 @@ void GuiRuntime::InitializeLan() {
|
|||||||
}
|
}
|
||||||
if (events) {
|
if (events) {
|
||||||
if (attached)
|
if (attached)
|
||||||
events->ApplyConnectionStatus(ConnectionStatus::Closed, session->key);
|
events->ApplyConnectionStatus(MiniRtcConnectionStatus::MiniRtcConnectionClosed, session->key);
|
||||||
events->StopAccepting();
|
events->StopAccepting();
|
||||||
transfers_.Cancel(events.get());
|
transfers_.Cancel(events.get());
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ void GuiRuntime::ConnectLan(const std::string& address, unsigned port,
|
|||||||
lan_connecting_ = session;
|
lan_connecting_ = session;
|
||||||
props->lan_session_ = session;
|
props->lan_session_ = session;
|
||||||
props->remote_id_ = session->key;
|
props->remote_id_ = session->key;
|
||||||
props->connection_status_ = ConnectionStatus::Connecting;
|
props->connection_status_ = MiniRtcConnectionStatus::MiniRtcConnectionConnecting;
|
||||||
props->connection_auth_.Complete();
|
props->connection_auth_.Complete();
|
||||||
{
|
{
|
||||||
std::unique_lock lock(remote_sessions_mutex_);
|
std::unique_lock lock(remote_sessions_mutex_);
|
||||||
@@ -180,14 +180,14 @@ std::shared_ptr<PeerEventHandler> GuiRuntime::ControllerEvents(
|
|||||||
}
|
}
|
||||||
int GuiRuntime::WithController(
|
int GuiRuntime::WithController(
|
||||||
const std::string& id,
|
const std::string& id,
|
||||||
const std::function<int(PeerPtr*, const std::string&)>& operation,
|
const std::function<int(MiniRtcPeerPtr*, const std::string&)>& operation,
|
||||||
bool require_trusted) {
|
bool require_trusted) {
|
||||||
if (require_trusted && !TrustedControllerAllowed(id)) return -1;
|
if (require_trusted && !TrustedControllerAllowed(id)) return -1;
|
||||||
std::shared_lock lock(remote_sessions_mutex_);
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
auto it = lan_controllers_.find(id);
|
auto it = lan_controllers_.find(id);
|
||||||
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
|
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
|
||||||
if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1;
|
if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1;
|
||||||
PeerPtr* peer =
|
MiniRtcPeerPtr* peer =
|
||||||
it == lan_controllers_.end() ? peer_ : it->second.session->peer;
|
it == lan_controllers_.end() ? peer_ : it->second.session->peer;
|
||||||
if (!peer || !events || !events->DataAllowed()) return -1;
|
if (!peer || !events || !events->DataAllowed()) return -1;
|
||||||
auto callback = events->EnterCallback();
|
auto callback = events->EnterCallback();
|
||||||
@@ -198,8 +198,8 @@ int GuiRuntime::WithController(
|
|||||||
}
|
}
|
||||||
int GuiRuntime::SendControllerData(const std::string& id, const char* data,
|
int GuiRuntime::SendControllerData(const std::string& id, const char* data,
|
||||||
size_t size, const char* label) {
|
size_t size, const char* label) {
|
||||||
return WithController(id, [&](PeerPtr* peer, const std::string& wire) {
|
return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) {
|
||||||
return SendReliableDataFrameToPeer(peer, data, size, label, wire.data(),
|
return MiniRtcSendReliableDataFrameToPeer(peer, data, size, label, wire.data(),
|
||||||
wire.size());
|
wire.size());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ std::vector<std::string> GuiRuntime::ConnectedControllers() {
|
|||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
ids.reserve(connection_status_.size());
|
ids.reserve(connection_status_.size());
|
||||||
for (const auto& [id, status] : connection_status_)
|
for (const auto& [id, status] : connection_status_)
|
||||||
if (status == ConnectionStatus::Connected) ids.push_back(id);
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) ids.push_back(id);
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
void GuiRuntime::BeginShutdown() {
|
void GuiRuntime::BeginShutdown() {
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
source_id == runtime->clipboard_label_ ||
|
source_id == runtime->clipboard_label_ ||
|
||||||
source_id == web_clipboard::kStream))
|
source_id == web_clipboard::kStream))
|
||||||
return;
|
return;
|
||||||
|
if (source_id == runtime->control_data_label_ &&
|
||||||
|
runtime->transfers_.HandleBrowser(data, size, handler, remote_id)) return;
|
||||||
if (source_id == web_clipboard::kStream) {
|
if (source_id == web_clipboard::kStream) {
|
||||||
runtime->clipboard_.ReceiveWeb(
|
runtime->clipboard_.ReceiveWeb(
|
||||||
data, size, remote_id, handler);
|
data, size, remote_id, handler);
|
||||||
@@ -174,9 +176,17 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::string wire_id = handler->WireId(remote_id);
|
const std::string wire_id = handler->WireId(remote_id);
|
||||||
PeerPtr* bound_peer =
|
MiniRtcPeerPtr* bound_peer =
|
||||||
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
|
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
|
||||||
std::string json_str(data, size);
|
std::string json_str(data, size);
|
||||||
|
if (json_str.find("\"automation\"") != std::string::npos) {
|
||||||
|
const auto request = nlohmann::json::parse(json_str, nullptr, false);
|
||||||
|
if (request.is_object() && request.value("type", nlohmann::json{}) == "automation") {
|
||||||
|
if (source_id == runtime->control_data_label_ && runtime->IsAuthorizedController(remote_id))
|
||||||
|
runtime->HandleAutomation(remote_id, request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const auto preview_json =
|
const auto preview_json =
|
||||||
json_str.find("\"deskhub_preview") != std::string::npos
|
json_str.find("\"deskhub_preview") != std::string::npos
|
||||||
? nlohmann::json::parse(json_str, nullptr, false)
|
? nlohmann::json::parse(json_str, nullptr, false)
|
||||||
@@ -194,31 +204,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
// their own demand. MiniRTC owns per-peer video/audio pause state.
|
// their own demand. MiniRTC owns per-peer video/audio pause state.
|
||||||
if (!previous || subscription->IsNewerThan(*previous)) {
|
if (!previous || subscription->IsNewerThan(*previous)) {
|
||||||
if (subscription->fps > 0)
|
if (subscription->fps > 0)
|
||||||
accepted = UpdateVideoSettings(
|
accepted = MiniRtcUpdateVideoSettings(
|
||||||
bound_peer, wire_id.data(), wire_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
static_cast<VideoQuality>(subscription->quality),
|
static_cast<MiniRtcVideoQuality>(subscription->quality),
|
||||||
subscription->frame_rate,
|
subscription->frame_rate,
|
||||||
static_cast<VideoDegradationPreference>(
|
static_cast<MiniRtcVideoDegradationPreference>(
|
||||||
subscription->EncoderPreference())) == 0;
|
subscription->EncoderPreference())) == 0;
|
||||||
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
||||||
subscription->max_bitrate_bps.value_or(0)))
|
subscription->max_bitrate_bps.value_or(0)))
|
||||||
accepted = UpdateVideoBitrateLimit(
|
accepted = MiniRtcUpdateVideoBitrateLimit(
|
||||||
bound_peer, wire_id.data(), wire_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
subscription->max_bitrate_bps.value_or(0)) == 0;
|
subscription->max_bitrate_bps.value_or(0)) == 0;
|
||||||
// Rebalancing bandwidth alone must not force every device to send a
|
// Rebalancing bandwidth alone must not force every device to send a
|
||||||
// keyframe. The pause API requests its own keyframes on resume.
|
// keyframe. The pause API requests its own keyframes on resume.
|
||||||
if (accepted && (!previous || previous->width != subscription->width ||
|
if (accepted && (!previous || previous->width != subscription->width ||
|
||||||
previous->height != subscription->height))
|
previous->height != subscription->height))
|
||||||
accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
|
accepted = MiniRtcUpdateVideoResolution(bound_peer, wire_id.data(),
|
||||||
wire_id.size(), subscription->width,
|
wire_id.size(), subscription->width,
|
||||||
subscription->height) == 0;
|
subscription->height) == 0;
|
||||||
if (accepted)
|
if (accepted)
|
||||||
accepted =
|
accepted =
|
||||||
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
MiniRtcSetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||||
subscription->fps == 0) == 0;
|
subscription->fps == 0) == 0;
|
||||||
if (accepted)
|
if (accepted)
|
||||||
accepted =
|
accepted =
|
||||||
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
MiniRtcSetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||||
!subscription->audio) == 0;
|
!subscription->audio) == 0;
|
||||||
if (accepted && subscription->audio)
|
if (accepted && subscription->audio)
|
||||||
runtime->devices_.StartSpeakerCapturer();
|
runtime->devices_.StartSpeakerCapturer();
|
||||||
@@ -230,7 +240,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
{"request_id", subscription->request_id},
|
{"request_id", subscription->request_id},
|
||||||
{"accepted",
|
{"accepted",
|
||||||
accepted}}.dump();
|
accepted}}.dump();
|
||||||
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
|
MiniRtcSendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
|
||||||
runtime->control_data_label_.c_str(),
|
runtime->control_data_label_.c_str(),
|
||||||
wire_id.data(), wire_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
return;
|
return;
|
||||||
@@ -265,7 +275,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
if (auto props = runtime->FindRemoteSession(remote_id);
|
if (auto props = runtime->FindRemoteSession(remote_id);
|
||||||
props && source_id == props->control_data_label_ &&
|
props && source_id == props->control_data_label_ &&
|
||||||
!props->closing_ &&
|
!props->closing_ &&
|
||||||
props->connection_status_ == ConnectionStatus::Connected) {
|
props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
|
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -304,17 +314,17 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
return;
|
return;
|
||||||
const auto& settings = remote_action.vs;
|
const auto& settings = remote_action.vs;
|
||||||
remote_action.vs.accepted =
|
remote_action.vs.accepted =
|
||||||
UpdateVideoSettings(
|
MiniRtcUpdateVideoSettings(
|
||||||
bound_peer, wire_id.data(), wire_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
static_cast<MiniRtcVideoQuality>(settings.quality), settings.frame_rate,
|
||||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
static_cast<MiniRtcVideoDegradationPreference>(settings.preference)) == 0;
|
||||||
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
||||||
"preference={} accepted={}",
|
"preference={} accepted={}",
|
||||||
remote_id, source_id, settings.request_id, settings.quality,
|
remote_id, source_id, settings.request_id, settings.quality,
|
||||||
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
||||||
remote_action.type = ControlType::video_settings_status;
|
remote_action.type = ControlType::video_settings_status;
|
||||||
const auto message = remote_action.to_json();
|
const auto message = remote_action.to_json();
|
||||||
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
|
MiniRtcSendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
|
||||||
runtime->control_data_label_.c_str(),
|
runtime->control_data_label_.c_str(),
|
||||||
wire_id.data(), wire_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
return;
|
return;
|
||||||
@@ -424,6 +434,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (remote_action.type == ControlType::host_infomation) {
|
if (remote_action.type == ControlType::host_infomation) {
|
||||||
|
if (source_id == runtime->control_data_label_) {
|
||||||
|
runtime->transfers_.SetBrowserSupport(handler, remote_id,
|
||||||
|
remote_action.i.supports_file_browser);
|
||||||
|
}
|
||||||
bool is_client_mode = false;
|
bool is_client_mode = false;
|
||||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||||
{
|
{
|
||||||
@@ -465,7 +479,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
props->video_settings_request_tick_ = SDL_GetTicks();
|
props->video_settings_request_tick_ = SDL_GetTicks();
|
||||||
settings.vs = props->video_settings_;
|
settings.vs = props->video_settings_;
|
||||||
const auto message = settings.to_json();
|
const auto message = settings.to_json();
|
||||||
SendReliableDataFrame(
|
MiniRtcSendReliableDataFrame(
|
||||||
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
|
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
|
||||||
message.data(), message.size(),
|
message.data(), message.size(),
|
||||||
props->control_data_label_.c_str());
|
props->control_data_label_.c_str());
|
||||||
@@ -516,7 +530,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
// command arrives after it (e.g. when unmuting an inactive window).
|
// command arrives after it (e.g. when unmuting an inactive window).
|
||||||
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
||||||
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
||||||
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
|
const int ret = MiniRtcSetAudioSendPaused(bound_peer, wire_id.data(),
|
||||||
wire_id.size(), paused);
|
wire_id.size(), paused);
|
||||||
if (remote_action.a)
|
if (remote_action.a)
|
||||||
runtime->devices_.StartSpeakerCapturer();
|
runtime->devices_.StartSpeakerCapturer();
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ bool PeerEventHandler::DataAllowed() const {
|
|||||||
return session && session->authorized && !session->closing;
|
return session && session->authorized && !session->closing;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
void PeerEventHandler::SendClientInfo(MiniRtcPeerPtr* peer,
|
||||||
const std::string& client_id) {
|
const std::string& client_id) {
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
return;
|
return;
|
||||||
@@ -75,7 +75,7 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
|||||||
{"version", CROSSDESK_VERSION},
|
{"version", CROSSDESK_VERSION},
|
||||||
{"platform", HostPlatformName(kClientPlatform)}};
|
{"platform", HostPlatformName(kClientPlatform)}};
|
||||||
const std::string payload = message.dump();
|
const std::string payload = message.dump();
|
||||||
if (SendSignalMessage(peer, payload.data(), payload.size()) != 0) {
|
if (MiniRtcSendSignalMessage(peer, payload.data(), payload.size()) != 0) {
|
||||||
LOG_WARN("[{}] failed to report client information", client_id);
|
LOG_WARN("[{}] failed to report client information", client_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,13 +84,13 @@ PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
|
|||||||
owner_.transfers_.Connected(this, "");
|
owner_.transfers_.Connected(this, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
int PeerEventHandler::OnSignalExchange(SignalExchangeStage stage, const char* message,
|
int PeerEventHandler::OnSignalExchange(MiniRtcSignalExchangeStage stage, const char* message,
|
||||||
size_t size, char* response, size_t capacity, void* user_data) {
|
size_t size, char* response, size_t capacity, void* user_data) {
|
||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
if (!handler || !message) return -1;
|
if (!handler || !message) return -1;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return -1;
|
if (!callback) return -1;
|
||||||
if (stage == SignalExchangeStage::IncomingMessage &&
|
if (stage == MiniRtcSignalExchangeStage::IncomingMessage &&
|
||||||
!handler->device_login_.Pending()) return 0;
|
!handler->device_login_.Pending()) return 0;
|
||||||
try {
|
try {
|
||||||
return handler->device_login_.Exchange(stage,
|
return handler->device_login_.Exchange(stage,
|
||||||
@@ -209,7 +209,7 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
void PeerEventHandler::OnSignalStatus(MiniRtcSignalStatus status, const char* user_id,
|
||||||
size_t user_id_size, void* user_data) {
|
size_t user_id_size, void* user_data) {
|
||||||
using RecoveryAction = GuiRuntime::CredentialRecoveryAction;
|
using RecoveryAction = GuiRuntime::CredentialRecoveryAction;
|
||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
@@ -220,13 +220,13 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
|||||||
|
|
||||||
std::string client_id(user_id, user_id_size);
|
std::string client_id(user_id, user_id_size);
|
||||||
if (client_id == runtime->client_id_) {
|
if (client_id == runtime->client_id_) {
|
||||||
runtime->announcements_.SetConnected(status == SignalStatus::SignalConnected);
|
runtime->announcements_.SetConnected(status == MiniRtcSignalStatus::MiniRtcSignalConnected);
|
||||||
runtime->signal_status_ = status;
|
runtime->signal_status_ = status;
|
||||||
runtime->device_presence_cache_.SetSignalConnected(
|
runtime->device_presence_cache_.SetSignalConnected(
|
||||||
status == SignalStatus::SignalConnected);
|
status == MiniRtcSignalStatus::MiniRtcSignalConnected);
|
||||||
if (SignalStatus::SignalConnecting == status) {
|
if (MiniRtcSignalStatus::MiniRtcSignalConnecting == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalConnected == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalConnected == status) {
|
||||||
runtime->signal_connected_ = true;
|
runtime->signal_connected_ = true;
|
||||||
runtime->need_to_send_recent_connections_ = true;
|
runtime->need_to_send_recent_connections_ = true;
|
||||||
LOG_INFO("[{}] connected to signal server", client_id);
|
LOG_INFO("[{}] connected to signal server", client_id);
|
||||||
@@ -241,20 +241,20 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
|||||||
runtime->credential_recovery_action_ = RecoveryAction::ClearPending;
|
runtime->credential_recovery_action_ = RecoveryAction::ClearPending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (SignalStatus::SignalFailed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalFailed == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
|
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
|
||||||
if (runtime->credential_recovery_in_progress_ &&
|
if (runtime->credential_recovery_in_progress_ &&
|
||||||
runtime->credential_recovery_attempt_pending_) {
|
runtime->credential_recovery_attempt_pending_) {
|
||||||
runtime->credential_recovery_action_ = RecoveryAction::RetryActive;
|
runtime->credential_recovery_action_ = RecoveryAction::RetryActive;
|
||||||
}
|
}
|
||||||
} else if (SignalStatus::SignalClosed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalClosed == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalReconnecting == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalReconnecting == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalServerClosed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalServerClosed == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalTlsCertError == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalTlsCertError == status) {
|
||||||
runtime->signal_connected_ = false;
|
runtime->signal_connected_ = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -266,27 +266,27 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
|||||||
auto props = runtime->FindRemoteSession(remote_id);
|
auto props = runtime->FindRemoteSession(remote_id);
|
||||||
if (!props) return;
|
if (!props) return;
|
||||||
props->signal_status_ = status;
|
props->signal_status_ = status;
|
||||||
if (SignalStatus::SignalConnecting == status) {
|
if (MiniRtcSignalStatus::MiniRtcSignalConnecting == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalConnected == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalConnected == status) {
|
||||||
props->signal_connected_ = true;
|
props->signal_connected_ = true;
|
||||||
LOG_INFO("[{}] connected to signal server", remote_id);
|
LOG_INFO("[{}] connected to signal server", remote_id);
|
||||||
SendClientInfo(props->peer_, client_id);
|
SendClientInfo(props->peer_, client_id);
|
||||||
} else if (SignalStatus::SignalFailed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalFailed == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalClosed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalClosed == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalReconnecting == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalReconnecting == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalServerClosed == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalServerClosed == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
} else if (SignalStatus::SignalTlsCertError == status) {
|
} else if (MiniRtcSignalStatus::MiniRtcSignalTlsCertError == status) {
|
||||||
props->signal_connected_ = false;
|
props->signal_connected_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
void PeerEventHandler::OnConnectionStatus(MiniRtcConnectionStatus status,
|
||||||
const char* user_id, size_t size,
|
const char* user_id, size_t size,
|
||||||
void* context) {
|
void* context) {
|
||||||
auto* handler = static_cast<PeerEventHandler*>(context);
|
auto* handler = static_cast<PeerEventHandler*>(context);
|
||||||
@@ -300,7 +300,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
|||||||
DispatchConnectionStatus(status, user_id, size, context);
|
DispatchConnectionStatus(status, user_id, size, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status,
|
||||||
const char* user_id,
|
const char* user_id,
|
||||||
const size_t user_id_size,
|
const size_t user_id_size,
|
||||||
void* user_data) {
|
void* user_data) {
|
||||||
@@ -311,9 +311,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
GuiRuntime* runtime = &handler->owner_;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
|
|
||||||
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||||
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) runtime->transfers_.Connected(handler, remote_id);
|
||||||
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
else if (status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected || status == MiniRtcConnectionStatus::MiniRtcConnectionFailed ||
|
||||||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) runtime->transfers_.Cancel(handler, remote_id);
|
||||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||||
{
|
{
|
||||||
std::shared_lock lock(runtime->remote_sessions_mutex_);
|
std::shared_lock lock(runtime->remote_sessions_mutex_);
|
||||||
@@ -324,7 +324,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!props) {
|
if (!props) {
|
||||||
if (status == ConnectionStatus::Connected) {
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
if (!runtime->BeginControllerTrust(remote_id, handler)) return;
|
if (!runtime->BeginControllerTrust(remote_id, handler)) return;
|
||||||
} else {
|
} else {
|
||||||
runtime->ForgetControllerTrust(remote_id, handler);
|
runtime->ForgetControllerTrust(remote_id, handler);
|
||||||
@@ -335,11 +335,11 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
runtime->is_client_mode_ = true;
|
runtime->is_client_mode_ = true;
|
||||||
if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
|
if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
|
||||||
props->connection_status_.store(status);
|
props->connection_status_.store(status);
|
||||||
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering)
|
if (status != MiniRtcConnectionStatus::MiniRtcConnectionConnecting && status != MiniRtcConnectionStatus::MiniRtcConnectionGathering)
|
||||||
props->connection_auth_.Complete();
|
props->connection_auth_.Complete();
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ConnectionStatus::Connected: {
|
case MiniRtcConnectionStatus::MiniRtcConnectionConnected: {
|
||||||
runtime->ResetRemoteServiceStatus(*props);
|
runtime->ResetRemoteServiceStatus(*props);
|
||||||
{
|
{
|
||||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||||
@@ -359,6 +359,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
remote_action.i.supports_deskhub_resolution = true;
|
remote_action.i.supports_deskhub_resolution = true;
|
||||||
remote_action.i.supports_deskhub_fps15 = true;
|
remote_action.i.supports_deskhub_fps15 = true;
|
||||||
remote_action.i.supports_deskhub_bandwidth = true;
|
remote_action.i.supports_deskhub_bandwidth = true;
|
||||||
|
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||||
remote_action.i.display_list[i]);
|
remote_action.i.display_list[i]);
|
||||||
@@ -367,7 +368,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
information["device_trust_version"] = 1;
|
information["device_trust_version"] = 1;
|
||||||
std::string msg = information.dump();
|
std::string msg = information.dump();
|
||||||
const int ret =
|
const int ret =
|
||||||
SendReliableDataFrame(props->peer_, msg.data(), msg.size(),
|
MiniRtcSendReliableDataFrame(props->peer_, msg.data(), msg.size(),
|
||||||
runtime->control_data_label_.c_str());
|
runtime->control_data_label_.c_str());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
|
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
|
||||||
@@ -385,9 +386,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
|
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ConnectionStatus::Disconnected:
|
case MiniRtcConnectionStatus::MiniRtcConnectionDisconnected:
|
||||||
case ConnectionStatus::Failed:
|
case MiniRtcConnectionStatus::MiniRtcConnectionFailed:
|
||||||
case ConnectionStatus::Closed: {
|
case MiniRtcConnectionStatus::MiniRtcConnectionClosed: {
|
||||||
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
|
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
|
||||||
"connection_closed");
|
"connection_closed");
|
||||||
props->connection_established_ = false;
|
props->connection_established_ = false;
|
||||||
@@ -405,7 +406,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
props->gallery_video_latency_->Reset();
|
props->gallery_video_latency_->Reset();
|
||||||
props->gallery_frame_timing_ = {};
|
props->gallery_frame_timing_ = {};
|
||||||
}
|
}
|
||||||
props->traversal_mode_ = TraversalMode::UnknownMode;
|
props->traversal_mode_ = MiniRtcTraversalMode::MiniRtcTraversalUnknown;
|
||||||
props->enable_mouse_control_ = false;
|
props->enable_mouse_control_ = false;
|
||||||
props->preview_subscription_supported_ = false;
|
props->preview_subscription_supported_ = false;
|
||||||
props->preview_resolution_supported_ = false;
|
props->preview_resolution_supported_ = false;
|
||||||
@@ -476,7 +477,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
|
|
||||||
runtime->focus_on_stream_window_ = false;
|
runtime->focus_on_stream_window_ = false;
|
||||||
|
|
||||||
if (status == ConnectionStatus::Closed) {
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionClosed) {
|
||||||
props->remote_close_pending_.store(true, std::memory_order_release);
|
props->remote_close_pending_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +491,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
runtime->show_connection_status_window_ = true;
|
runtime->show_connection_status_window_ = true;
|
||||||
{
|
{
|
||||||
std::unique_lock lock(runtime->connection_status_mutex_);
|
std::unique_lock lock(runtime->connection_status_mutex_);
|
||||||
if (status == ConnectionStatus::Connected) {
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
runtime->disconnect_lock_.Connected(remote_id);
|
runtime->disconnect_lock_.Connected(remote_id);
|
||||||
// Local privacy requires explicit desktop capability. Host info may
|
// Local privacy requires explicit desktop capability. Host info may
|
||||||
// arrive before Connected; consume that saved capability here.
|
// arrive before Connected; consume that saved capability here.
|
||||||
@@ -499,19 +500,19 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
runtime->ApplyPrivacyAdmission(automatic_enable);
|
runtime->ApplyPrivacyAdmission(automatic_enable);
|
||||||
}
|
}
|
||||||
runtime->connection_status_[remote_id] = status;
|
runtime->connection_status_[remote_id] = status;
|
||||||
if (status == ConnectionStatus::Connected)
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected)
|
||||||
runtime->remote_input_.Connect(remote_id);
|
runtime->remote_input_.Connect(remote_id);
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
runtime->windows_rdp_handoff_connected_->store(std::any_of(
|
runtime->windows_rdp_handoff_connected_->store(std::any_of(
|
||||||
runtime->connection_status_.begin(), runtime->connection_status_.end(),
|
runtime->connection_status_.begin(), runtime->connection_status_.end(),
|
||||||
[](const auto& entry) { return entry.second == ConnectionStatus::Connected; }),
|
[](const auto& entry) { return entry.second == MiniRtcConnectionStatus::MiniRtcConnectionConnected; }),
|
||||||
std::memory_order_relaxed);
|
std::memory_order_relaxed);
|
||||||
#endif
|
#endif
|
||||||
runtime->devices_.OnControllerConnectionsChanged();
|
runtime->devices_.OnControllerConnectionsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ConnectionStatus::Connected: {
|
case MiniRtcConnectionStatus::MiniRtcConnectionConnected: {
|
||||||
runtime->preview_subscriptions_.Remove(remote_id);
|
runtime->preview_subscriptions_.Remove(remote_id);
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
runtime->last_windows_service_status_tick_ = 0;
|
runtime->last_windows_service_status_tick_ = 0;
|
||||||
@@ -528,6 +529,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
remote_action.i.supports_deskhub_resolution = true;
|
remote_action.i.supports_deskhub_resolution = true;
|
||||||
remote_action.i.supports_deskhub_fps15 = true;
|
remote_action.i.supports_deskhub_fps15 = true;
|
||||||
remote_action.i.supports_deskhub_bandwidth = true;
|
remote_action.i.supports_deskhub_bandwidth = true;
|
||||||
|
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||||
remote_action.i.display_list[i]);
|
remote_action.i.display_list[i]);
|
||||||
@@ -555,9 +557,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ConnectionStatus::Disconnected:
|
case MiniRtcConnectionStatus::MiniRtcConnectionDisconnected:
|
||||||
case ConnectionStatus::Failed:
|
case MiniRtcConnectionStatus::MiniRtcConnectionFailed:
|
||||||
case ConnectionStatus::Closed: {
|
case MiniRtcConnectionStatus::MiniRtcConnectionClosed: {
|
||||||
runtime->HandleServerControllerDisconnected(remote_id,
|
runtime->HandleServerControllerDisconnected(remote_id,
|
||||||
"connection_closed");
|
"connection_closed");
|
||||||
break;
|
break;
|
||||||
@@ -569,7 +571,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnNetStatusReport(
|
void PeerEventHandler::OnNetStatusReport(
|
||||||
const char* client_id, size_t client_id_size, TraversalMode mode,
|
const char* client_id, size_t client_id_size, MiniRtcTraversalMode mode,
|
||||||
const MiniRtcNetTrafficStats* net_traffic_stats, const char* user_id,
|
const MiniRtcNetTrafficStats* net_traffic_stats, const char* user_id,
|
||||||
const size_t user_id_size, void* user_data) {
|
const size_t user_id_size, void* user_data) {
|
||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
@@ -638,7 +640,7 @@ void PeerEventHandler::OnNetStatusReport(
|
|||||||
const auto status = runtime->connection_status_.find(remote_id);
|
const auto status = runtime->connection_status_.find(remote_id);
|
||||||
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
|
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
|
||||||
status != runtime->connection_status_.end() &&
|
status != runtime->connection_status_.end() &&
|
||||||
status->second == ConnectionStatus::Connected;
|
status->second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||||
}
|
}
|
||||||
if (net_traffic_stats && report_codecs) {
|
if (net_traffic_stats && report_codecs) {
|
||||||
RemoteAction action{};
|
RemoteAction action{};
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ public:
|
|||||||
std::string WireId(const std::string& key) const;
|
std::string WireId(const std::string& key) const;
|
||||||
bool DataAllowed() const;
|
bool DataAllowed() const;
|
||||||
bool IsDirect() const { return direct_bound_; }
|
bool IsDirect() const { return direct_bound_; }
|
||||||
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; }
|
void SetDirectPeer(MiniRtcPeerPtr* peer) { direct_peer_ = peer; }
|
||||||
PeerPtr* DirectPeer() const { return direct_peer_; }
|
MiniRtcPeerPtr* DirectPeer() const { return direct_peer_; }
|
||||||
void FlushEarlyData();
|
void FlushEarlyData();
|
||||||
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) {
|
void ApplyConnectionStatus(MiniRtcConnectionStatus status, const std::string& id) {
|
||||||
DispatchConnectionStatus(status, id.data(), id.size(), this);
|
DispatchConnectionStatus(status, id.data(), id.size(), this);
|
||||||
}
|
}
|
||||||
void StopAccepting() { callbacks_.StopAccepting(); }
|
void StopAccepting() { callbacks_.StopAccepting(); }
|
||||||
@@ -60,30 +60,30 @@ public:
|
|||||||
const char *user_id, size_t user_id_size,
|
const char *user_id, size_t user_id_size,
|
||||||
const char *src_id, size_t src_id_size,
|
const char *src_id, size_t src_id_size,
|
||||||
void *user_data);
|
void *user_data);
|
||||||
static void OnSignalStatus(SignalStatus status, const char *user_id,
|
static void OnSignalStatus(MiniRtcSignalStatus status, const char *user_id,
|
||||||
size_t user_id_size, void *user_data);
|
size_t user_id_size, void *user_data);
|
||||||
static void OnSignalMessage(const char *message, size_t size,
|
static void OnSignalMessage(const char *message, size_t size,
|
||||||
void *user_data);
|
void *user_data);
|
||||||
static int OnSignalExchange(SignalExchangeStage stage, const char* message,
|
static int OnSignalExchange(MiniRtcSignalExchangeStage stage, const char* message,
|
||||||
size_t size, char* response, size_t capacity,
|
size_t size, char* response, size_t capacity,
|
||||||
void* user_data);
|
void* user_data);
|
||||||
static void OnConnectionStatus(ConnectionStatus status, const char *user_id,
|
static void OnConnectionStatus(MiniRtcConnectionStatus status, const char *user_id,
|
||||||
size_t user_id_size, void *user_data);
|
size_t user_id_size, void *user_data);
|
||||||
static void OnNetStatusReport(const char *client_id, size_t client_id_size,
|
static void OnNetStatusReport(const char *client_id, size_t client_id_size,
|
||||||
TraversalMode mode,
|
MiniRtcTraversalMode mode,
|
||||||
const MiniRtcNetTrafficStats *net_traffic_stats,
|
const MiniRtcNetTrafficStats *net_traffic_stats,
|
||||||
const char *user_id, size_t user_id_size,
|
const char *user_id, size_t user_id_size,
|
||||||
void *user_data);
|
void *user_data);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static void DispatchConnectionStatus(ConnectionStatus status, const char* id,
|
static void DispatchConnectionStatus(MiniRtcConnectionStatus status, const char* id,
|
||||||
size_t size, void* context);
|
size_t size, void* context);
|
||||||
static void SendClientInfo(PeerPtr* peer, const std::string& client_id);
|
static void SendClientInfo(MiniRtcPeerPtr* peer, const std::string& client_id);
|
||||||
bool QueueEarlyData(const char* data, size_t size, const char* label,
|
bool QueueEarlyData(const char* data, size_t size, const char* label,
|
||||||
size_t label_size);
|
size_t label_size);
|
||||||
std::weak_ptr<lan::Session> direct_;
|
std::weak_ptr<lan::Session> direct_;
|
||||||
bool direct_bound_ = false;
|
bool direct_bound_ = false;
|
||||||
PeerPtr* direct_peer_ = nullptr;
|
MiniRtcPeerPtr* direct_peer_ = nullptr;
|
||||||
std::mutex early_data_mutex_;
|
std::mutex early_data_mutex_;
|
||||||
std::vector<std::string> early_data_;
|
std::vector<std::string> early_data_;
|
||||||
size_t early_data_bytes_ = 0;
|
size_t early_data_bytes_ = 0;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
|||||||
if (props->connection_established_) {
|
if (props->connection_established_) {
|
||||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||||
// Pair the clocks before preview conversion so its cost is included.
|
// Pair the clocks before preview conversion so its cost is included.
|
||||||
const auto local_now_us = GetSystemTimeMicros(
|
const auto local_now_us = MiniRtcGetSystemTimeMicros(
|
||||||
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
|
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
|
||||||
const auto* received_native = GetNativeVideoFrame(video_frame);
|
const auto* received_native = GetNativeVideoFrame(video_frame);
|
||||||
props->received_video_stats_.Record(
|
props->received_video_stats_.Record(
|
||||||
@@ -183,9 +183,11 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (submit_result == VideoRenderer::SubmitResult::dropped ||
|
if (submit_result == VideoRenderer::SubmitResult::dropped ||
|
||||||
submit_result == VideoRenderer::SubmitResult::failed) {
|
(submit_result == VideoRenderer::SubmitResult::failed &&
|
||||||
|
native_renderer->IsActive())) {
|
||||||
// Keep presenting the last native frame. A later decoded frame can
|
// Keep presenting the last native frame. A later decoded frame can
|
||||||
// reuse the renderer without changing ownership mid-window.
|
// reuse an active renderer. If rendering disabled it concurrently,
|
||||||
|
// continue below to materialize this frame for the CPU fallback.
|
||||||
props->streaming_ = true;
|
props->streaming_ = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
|||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
const auto found = connection_status_.find(remote_id);
|
const auto found = connection_status_.find(remote_id);
|
||||||
return found != connection_status_.end() &&
|
return found != connection_status_.end() &&
|
||||||
found->second == ConnectionStatus::Connected;
|
found->second == MiniRtcConnectionStatus::MiniRtcConnectionConnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
|
void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
|
||||||
@@ -34,9 +34,9 @@ void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
|
|||||||
std::unique_lock lock(connection_status_mutex_);
|
std::unique_lock lock(connection_status_mutex_);
|
||||||
const auto found = connection_status_.find(remote_id);
|
const auto found = connection_status_.find(remote_id);
|
||||||
if (found == connection_status_.end()) return;
|
if (found == connection_status_.end()) return;
|
||||||
if (found->second != ConnectionStatus::Connected) {
|
if (found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
|
||||||
if (found->second == ConnectionStatus::Connecting ||
|
if (found->second == MiniRtcConnectionStatus::MiniRtcConnectionConnecting ||
|
||||||
found->second == ConnectionStatus::Gathering) {
|
found->second == MiniRtcConnectionStatus::MiniRtcConnectionGathering) {
|
||||||
privacy_sessions_.RememberSupport(remote_id, supported);
|
privacy_sessions_.RememberSupport(remote_id, supported);
|
||||||
LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}",
|
LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}",
|
||||||
remote_id, supported);
|
remote_id, supported);
|
||||||
@@ -64,7 +64,7 @@ void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
|
|||||||
std::shared_lock session_lock(connection_status_mutex_);
|
std::shared_lock session_lock(connection_status_mutex_);
|
||||||
const auto found = connection_status_.find(remote_id);
|
const auto found = connection_status_.find(remote_id);
|
||||||
if (found == connection_status_.end() ||
|
if (found == connection_status_.end() ||
|
||||||
found->second != ConnectionStatus::Connected ||
|
found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected ||
|
||||||
!privacy_sessions_.Supports(remote_id)) return;
|
!privacy_sessions_.Supports(remote_id)) return;
|
||||||
std::lock_guard lock(privacy_commands_mutex_);
|
std::lock_guard lock(privacy_commands_mutex_);
|
||||||
if (privacy_commands_.size() >= 32) return;
|
if (privacy_commands_.size() >= 32) return;
|
||||||
@@ -94,7 +94,7 @@ void GuiRuntime::HandlePrivacy() {
|
|||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
const auto found = connection_status_.find(remote_id);
|
const auto found = connection_status_.find(remote_id);
|
||||||
if (found == connection_status_.end() ||
|
if (found == connection_status_.end() ||
|
||||||
found->second != ConnectionStatus::Connected ||
|
found->second != MiniRtcConnectionStatus::MiniRtcConnectionConnected ||
|
||||||
!privacy_sessions_.Supports(remote_id)) continue;
|
!privacy_sessions_.Supports(remote_id)) continue;
|
||||||
if (command.flag == PrivacyCommandFlag::enable) {
|
if (command.flag == PrivacyCommandFlag::enable) {
|
||||||
if (privacy_sessions_.CanEnable())
|
if (privacy_sessions_.CanEnable())
|
||||||
@@ -124,7 +124,7 @@ void GuiRuntime::HandlePrivacy() {
|
|||||||
privacy_.CancelAutomaticResume();
|
privacy_.CancelAutomaticResume();
|
||||||
status = privacy_.Snapshot();
|
status = privacy_.Snapshot();
|
||||||
for (const auto& [id, state] : connection_status_)
|
for (const auto& [id, state] : connection_status_)
|
||||||
if (state == ConnectionStatus::Connected &&
|
if (state == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
|
||||||
privacy_sessions_.Supports(id))
|
privacy_sessions_.Supports(id))
|
||||||
controllers.push_back(id);
|
controllers.push_back(id);
|
||||||
if (!privacy_sessions_.CanEnable()) {
|
if (!privacy_sessions_.CanEnable()) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ struct FileTransferState {
|
|||||||
uint32_t file_id = 0;
|
uint32_t file_id = 0;
|
||||||
uint32_t rate_bps = 0;
|
uint32_t rate_bps = 0;
|
||||||
bool receiving = false;
|
bool receiving = false;
|
||||||
|
uint32_t wire_file_id = 0;
|
||||||
};
|
};
|
||||||
std::vector<FileTransferInfo> file_transfer_list_;
|
std::vector<FileTransferInfo> file_transfer_list_;
|
||||||
std::mutex file_transfer_list_mutex_;
|
std::mutex file_transfer_list_mutex_;
|
||||||
@@ -111,8 +112,8 @@ struct RemoteSession {
|
|||||||
std::mutex gallery_conversion_mutex_;
|
std::mutex gallery_conversion_mutex_;
|
||||||
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
||||||
std::shared_ptr<lan::Session> lan_session_;
|
std::shared_ptr<lan::Session> lan_session_;
|
||||||
Params params_;
|
MiniRtcParams params_;
|
||||||
PeerPtr* peer_ = nullptr;
|
MiniRtcPeerPtr* peer_ = nullptr;
|
||||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||||
std::atomic<bool> closing_{false};
|
std::atomic<bool> closing_{false};
|
||||||
// Transport callbacks request tab closure; only the UI thread destroys it.
|
// Transport callbacks request tab closure; only the UI thread destroys it.
|
||||||
@@ -129,7 +130,7 @@ struct RemoteSession {
|
|||||||
std::string remote_id_;
|
std::string remote_id_;
|
||||||
bool exit_ = false;
|
bool exit_ = false;
|
||||||
bool signal_connected_ = false;
|
bool signal_connected_ = false;
|
||||||
SignalStatus signal_status_ = SignalStatus::SignalClosed;
|
MiniRtcSignalStatus signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
|
||||||
bool connection_established_ = false;
|
bool connection_established_ = false;
|
||||||
std::atomic<bool> was_connected_{false};
|
std::atomic<bool> was_connected_{false};
|
||||||
// Transport callbacks publish changes; the UI thread persists history.
|
// Transport callbacks publish changes; the UI thread persists history.
|
||||||
@@ -210,8 +211,8 @@ struct RemoteSession {
|
|||||||
bool remote_cursor_state_received_ = false;
|
bool remote_cursor_state_received_ = false;
|
||||||
CursorPresentation cursor_presentation_;
|
CursorPresentation cursor_presentation_;
|
||||||
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
|
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
|
||||||
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
|
std::atomic<MiniRtcConnectionStatus> connection_status_ = MiniRtcConnectionStatus::MiniRtcConnectionClosed;
|
||||||
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
|
std::atomic<MiniRtcTraversalMode> traversal_mode_{MiniRtcTraversalMode::MiniRtcTraversalUnknown};
|
||||||
int fps_ = 0;
|
int fps_ = 0;
|
||||||
int frame_count_ = 0;
|
int frame_count_ = 0;
|
||||||
std::chrono::steady_clock::time_point last_time_;
|
std::chrono::steady_clock::time_point last_time_;
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ struct RecentConnectionsState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
struct PeerState {
|
struct PeerState {
|
||||||
SignalStatus signal_status_ = SignalStatus::SignalClosed;
|
MiniRtcSignalStatus signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed;
|
||||||
std::string signal_status_str_;
|
std::string signal_status_str_;
|
||||||
std::atomic<bool> signal_connected_{false};
|
std::atomic<bool> signal_connected_{false};
|
||||||
PeerPtr *peer_ = nullptr;
|
MiniRtcPeerPtr *peer_ = nullptr;
|
||||||
PeerPtr *peer_reserved_ = nullptr;
|
MiniRtcPeerPtr *peer_reserved_ = nullptr;
|
||||||
std::string video_primary_label_ = "primary_display";
|
std::string video_primary_label_ = "primary_display";
|
||||||
std::string video_secondary_label_ = "secondary_display";
|
std::string video_secondary_label_ = "secondary_display";
|
||||||
std::string audio_label_ = "audio";
|
std::string audio_label_ = "audio";
|
||||||
@@ -83,7 +83,7 @@ struct PeerState {
|
|||||||
std::string file_label_ = kFileStream;
|
std::string file_label_ = kFileStream;
|
||||||
std::string file_feedback_label_ = kFileFeedbackStream;
|
std::string file_feedback_label_ = kFileFeedbackStream;
|
||||||
std::string clipboard_label_ = kClipboardStream;
|
std::string clipboard_label_ = kClipboardStream;
|
||||||
Params params_{};
|
MiniRtcParams params_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
// OS-specific service and permission state is kept separate from transport
|
// OS-specific service and permission state is kept separate from transport
|
||||||
@@ -203,7 +203,7 @@ struct ConnectionState {
|
|||||||
std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
|
std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
|
||||||
|
|
||||||
std::shared_mutex connection_status_mutex_;
|
std::shared_mutex connection_status_mutex_;
|
||||||
std::unordered_map<std::string, ConnectionStatus> connection_status_;
|
std::unordered_map<std::string, MiniRtcConnectionStatus> connection_status_;
|
||||||
std::unordered_map<std::string, std::string> connection_host_names_;
|
std::unordered_map<std::string, std::string> connection_host_names_;
|
||||||
std::unordered_set<std::string> codec_status_controllers_;
|
std::unordered_set<std::string> codec_status_controllers_;
|
||||||
// Cursor position is sampled asynchronously after remote mouse input has
|
// Cursor position is sampled asynchronously after remote mouse input has
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export global FontAwesomeIcons {
|
|||||||
out property <string> angle-right: "\u{f105}";
|
out property <string> angle-right: "\u{f105}";
|
||||||
out property <string> angle-up: "\u{f106}";
|
out property <string> angle-up: "\u{f106}";
|
||||||
out property <string> arrow-right-long: "\u{f178}";
|
out property <string> arrow-right-long: "\u{f178}";
|
||||||
|
out property <string> arrow-up: "\u{f062}";
|
||||||
out property <string> arrow-up-right-from-square: "\u{f35d}";
|
out property <string> arrow-up-right-from-square: "\u{f35d}";
|
||||||
out property <string> bars: "\u{f0c9}";
|
out property <string> bars: "\u{f0c9}";
|
||||||
out property <string> check: "\u{f00c}";
|
out property <string> check: "\u{f00c}";
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
|
|||||||
export { DeviceGroupOption } from "device_group_dialog.slint";
|
export { DeviceGroupOption } from "device_group_dialog.slint";
|
||||||
|
|
||||||
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
|
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
|
||||||
|
|
||||||
|
export { FileBrowserWindow, BrowserEntry, BrowserTreeEntry, BrowserTask } from "file_browser.slint";
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
import { ScrollView, ListView, Spinner } from "std-widgets.slint";
|
||||||
|
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint";
|
||||||
|
import { FlatSurface, UiSurface, SurfaceSeparator } from "ui_surface.slint";
|
||||||
|
import { UiRadius } from "ui_radius.slint";
|
||||||
|
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||||
|
import { FontAwesomeIcons, ControlStyle, ImGuiFontStyle } from "common.slint";
|
||||||
|
|
||||||
|
export struct BrowserEntry {
|
||||||
|
name: string, path: string, directory: bool, selected: bool,
|
||||||
|
size: string, modified: string, link: bool,
|
||||||
|
}
|
||||||
|
export struct BrowserTreeEntry {
|
||||||
|
name: string, path: string, depth: int, expanded: bool, selected: bool,
|
||||||
|
}
|
||||||
|
export struct BrowserTask {
|
||||||
|
id: int, name: string, size: string, detail: string, status: string,
|
||||||
|
progress: float, active: bool, failed: bool, can-cancel: bool, receiving: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserButton inherits WorkbenchButton {
|
||||||
|
mouse-cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserScrollView inherits Rectangle {
|
||||||
|
// Fluent overlays a 14px scrollbar on its viewport. Keep a 4px gap between
|
||||||
|
// that track and row content, including while the scrollbar expands on hover.
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
ScrollView {
|
||||||
|
x: 0px; y: 0px; width: root.width; height: root.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the visual compact while retaining a generous pointer target.
|
||||||
|
// Selection stays model-owned so refreshing rows cannot toggle it accidentally.
|
||||||
|
component BrowserCheckBox inherits Rectangle {
|
||||||
|
in property <bool> checked;
|
||||||
|
in property <bool> indeterminate;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> show-label: false;
|
||||||
|
private property <bool> keyboard-focus: false;
|
||||||
|
callback toggled;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
forward-focus: focus;
|
||||||
|
accessible-role: checkbox;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-checked: root.checked;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-action-default => { if root.enabled { root.toggled(); } }
|
||||||
|
box := FlatSurface {
|
||||||
|
x: 4px; y: (parent.height - self.height) / 2; width: 16px; height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: !root.enabled ? #f4f6f9
|
||||||
|
: touch.pressed ? #dce7fb
|
||||||
|
: root.checked || root.indeterminate ? (touch.has-hover ? #dfe9fc : #edf2fc)
|
||||||
|
: touch.has-hover ? #f5f8fe : white;
|
||||||
|
outline-color: !root.enabled ? #dce2eb
|
||||||
|
: touch.has-hover || touch.pressed ? #7d9edb
|
||||||
|
: root.checked || root.indeterminate ? #a6bce6 : #b7c3d4;
|
||||||
|
animate background, outline-color { duration: 100ms; }
|
||||||
|
if root.checked && !root.indeterminate: Path {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewbox-width: 16; viewbox-height: 16;
|
||||||
|
commands: "M 4 8 L 6.75 10.75 L 12 5.5";
|
||||||
|
stroke: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||||
|
stroke-width: 1.7px; stroke-line-cap: round; stroke-line-join: round;
|
||||||
|
}
|
||||||
|
if root.indeterminate: Rectangle {
|
||||||
|
x: 4px; y: 7px; width: 8px; height: 2px; border-radius: 1px;
|
||||||
|
background: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.show-label: Text {
|
||||||
|
x: 30px; width: parent.width - self.x; height: parent.height;
|
||||||
|
text: root.label; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base;
|
||||||
|
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||||
|
}
|
||||||
|
if root.keyboard-focus && focus.has-focus: FlatSurface {
|
||||||
|
x: 1px; y: box.y - 3px; width: 22px; height: 22px;
|
||||||
|
border-radius: 6px; outline-color: UiSurface.focus;
|
||||||
|
}
|
||||||
|
focus := FocusScope {
|
||||||
|
enabled: root.enabled;
|
||||||
|
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == " " || event.text == Key.Return {
|
||||||
|
root.keyboard-focus = true; root.toggled(); return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
touch := TouchArea {
|
||||||
|
enabled: root.enabled; mouse-cursor: default;
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down { root.keyboard-focus = false; }
|
||||||
|
}
|
||||||
|
clicked => { focus.focus(); root.keyboard-focus = false; root.toggled(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserSortHeader inherits FlatSurface {
|
||||||
|
in property <string> text;
|
||||||
|
in property <bool> chinese;
|
||||||
|
in property <bool> active;
|
||||||
|
in property <bool> descending;
|
||||||
|
in property <bool> default-descending;
|
||||||
|
in property <bool> right-aligned;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
private property <bool> keyboard-focus: false;
|
||||||
|
private property <bool> next-descending: root.active ? !root.descending : root.default-descending;
|
||||||
|
callback clicked;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
outline-enabled: focus.has-focus && root.keyboard-focus;
|
||||||
|
outline-color: UiSurface.focus;
|
||||||
|
background: touch.pressed ? ControlStyle.pressed.with-alpha(0.3)
|
||||||
|
: touch.has-hover && root.enabled ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
forward-focus: focus;
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-label: root.text + (root.active ? (root.descending ? (root.chinese ? ",降序" : ", descending") : (root.chinese ? ",升序" : ", ascending")) : "")
|
||||||
|
+ (root.next-descending ? (root.chinese ? ",点击按降序排列" : ", sort descending") : (root.chinese ? ",点击按升序排列" : ", sort ascending"));
|
||||||
|
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||||
|
label := Text {
|
||||||
|
x: root.right-aligned ? parent.width - self.width - (root.active ? 14px : 0px) : 0px;
|
||||||
|
width: min(self.preferred-width, parent.width - (root.active ? 14px : 0px));
|
||||||
|
height: parent.height;
|
||||||
|
text: root.text; font-size: ImGuiFontStyle.body; font-weight: root.active ? 600 : 400;
|
||||||
|
color: root.active ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||||
|
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||||
|
}
|
||||||
|
if root.active: Text {
|
||||||
|
x: label.x + label.width + 4px; width: 10px; height: parent.height;
|
||||||
|
text: root.descending ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up;
|
||||||
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
|
||||||
|
color: WorkbenchStyle.muted; vertical-alignment: center; accessible-role: none;
|
||||||
|
}
|
||||||
|
focus := FocusScope {
|
||||||
|
enabled: root.enabled;
|
||||||
|
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == " " || event.text == Key.Return {
|
||||||
|
root.keyboard-focus = true; root.clicked(); return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
touch := TouchArea {
|
||||||
|
enabled: root.enabled; mouse-cursor: default;
|
||||||
|
pointer-event(event) => { if event.kind == PointerEventKind.down { root.keyboard-focus = false; } }
|
||||||
|
clicked => { focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserPane inherits WorkbenchCard {
|
||||||
|
in property <bool> chinese;
|
||||||
|
in property <string> open-directory-text;
|
||||||
|
in property <string> heading;
|
||||||
|
in property <string> subtitle;
|
||||||
|
in-out property <string> path;
|
||||||
|
in property <string> current-path;
|
||||||
|
in-out property <string> filter-text;
|
||||||
|
in property <string> status;
|
||||||
|
in property <string> error;
|
||||||
|
in property <bool> busy;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <bool> can-up;
|
||||||
|
in property <bool> all-selected;
|
||||||
|
in property <bool> show-hidden;
|
||||||
|
in property <int> selected-count;
|
||||||
|
in property <bool> destination;
|
||||||
|
in property <bool> transfer-ready;
|
||||||
|
in property <int> selection-revision;
|
||||||
|
in property <int> drag-count;
|
||||||
|
in property <bool> dragging;
|
||||||
|
in property <bool> drop-hover;
|
||||||
|
in property <[BrowserEntry]> entries;
|
||||||
|
in property <[BrowserTreeEntry]> tree;
|
||||||
|
in property <int> sort-column;
|
||||||
|
in property <bool> sort-descending;
|
||||||
|
private property <length> modified-column-width: 72px;
|
||||||
|
private property <length> size-column-width: 56px;
|
||||||
|
outline-color: root.destination ? UiSurface.hover : UiSurface.edge;
|
||||||
|
callback navigate(string);
|
||||||
|
callback up;
|
||||||
|
callback select-entry(int);
|
||||||
|
callback open-entry(int);
|
||||||
|
callback open-directory(string);
|
||||||
|
callback select-all;
|
||||||
|
callback tree-open(int);
|
||||||
|
callback tree-toggle(int);
|
||||||
|
callback filter(string);
|
||||||
|
callback toggle-hidden;
|
||||||
|
callback sort(int);
|
||||||
|
pure callback drag-data(string, int) -> data-transfer;
|
||||||
|
callback drag-start(data-transfer, BrowserEntry);
|
||||||
|
callback drag-move(length, length);
|
||||||
|
callback drag-end;
|
||||||
|
callback drag-cancel;
|
||||||
|
public pure function contains-drop(x: length, y: length) -> bool {
|
||||||
|
return root.enabled && root.transfer-ready
|
||||||
|
&& x >= body.absolute-position.x && x < body.absolute-position.x + body.width
|
||||||
|
&& y >= body.absolute-position.y && y < body.absolute-position.y + body.height;
|
||||||
|
}
|
||||||
|
changed enabled => { if !root.enabled { directory-popup.close(); } }
|
||||||
|
changed dragging => { if root.dragging { directory-popup.close(); } }
|
||||||
|
changed current-path => { file-list.reset-scroll(); }
|
||||||
|
changed filter-text => { file-list.reset-scroll(); }
|
||||||
|
changed sort-column => { file-list.reset-scroll(); }
|
||||||
|
changed sort-descending => { file-list.reset-scroll(); }
|
||||||
|
VerticalLayout {
|
||||||
|
padding-left: 14px; padding-right: 14px;
|
||||||
|
padding-top: 12px; padding-bottom: 12px; spacing: 6px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 24px; spacing: 8px;
|
||||||
|
Text { text: root.heading; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.prominent; font-weight: 600; vertical-alignment: center; }
|
||||||
|
Text {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
text: root.destination ? (root.chinese ? "接收目录" : "Destination")
|
||||||
|
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项" : root.selected-count + " selected") : root.subtitle;
|
||||||
|
color: root.destination ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; horizontal-alignment: right; vertical-alignment: center; overflow: elide;
|
||||||
|
}
|
||||||
|
RoundedLineEdit {
|
||||||
|
width: min(164px, root.width * 0.4); height: 24px;
|
||||||
|
text <=> root.filter-text; tint-opacity: 0.15;
|
||||||
|
placeholder-text: root.chinese ? "筛选名称…" : "Filter by name…";
|
||||||
|
enabled: root.enabled && !root.busy && !root.dragging;
|
||||||
|
edited => { root.filter(self.text); }
|
||||||
|
accepted => { self.clear-focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 32px; spacing: 6px;
|
||||||
|
BrowserButton {
|
||||||
|
width: 32px; height: 32px; quiet: true; icon: FontAwesomeIcons.arrow-up;
|
||||||
|
tooltip: root.chinese ? "打开上一级目录" : "Open parent folder";
|
||||||
|
tooltip-above: true; tooltip-align-left: true;
|
||||||
|
accessible-label: self.tooltip;
|
||||||
|
enabled: root.can-up && !root.busy && root.enabled && !root.dragging;
|
||||||
|
clicked => { root.up(); }
|
||||||
|
}
|
||||||
|
path-focus := FocusScope {
|
||||||
|
horizontal-stretch: 1; height: 32px;
|
||||||
|
forward-focus: path-editor;
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == Key.Escape {
|
||||||
|
root.path = root.current-path;
|
||||||
|
path-editor.clear-focus();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
path-editor := RoundedLineEdit {
|
||||||
|
width: parent.width; height: parent.height; text <=> root.path;
|
||||||
|
tint-opacity: 0.2;
|
||||||
|
enabled: root.enabled && !root.dragging;
|
||||||
|
placeholder-text: root.chinese ? "输入路径,按回车打开" : "Enter a folder path";
|
||||||
|
accepted => { root.navigate(self.text); self.clear-focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tree-trigger := BrowserButton {
|
||||||
|
width: 28px; height: 32px; quiet: true; icon: FontAwesomeIcons.folder-open;
|
||||||
|
enabled: root.enabled && !root.dragging;
|
||||||
|
accessible-label: root.chinese ? "浏览目录树" : "Browse folder tree";
|
||||||
|
clicked => { if directory-popup.is-open { directory-popup.close(); } else { directory-popup.show(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := Rectangle {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
Rectangle {
|
||||||
|
height: 28px; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 6px; padding-right: 8px + file-list.content-gutter; spacing: 8px;
|
||||||
|
BrowserCheckBox {
|
||||||
|
width: 24px; height: 28px; checked: root.all-selected;
|
||||||
|
indeterminate: root.selected-count > 0 && !root.all-selected;
|
||||||
|
label: (root.chinese ? "选择全部" : "Select all")
|
||||||
|
+ (self.indeterminate ? (root.chinese ? ",部分已选" : ", partially selected") : "");
|
||||||
|
enabled: root.enabled && root.entries.length > 0 && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
toggled => { root.select-all(); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
horizontal-stretch: 1; text: root.chinese ? "名称" : "Name"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 0; descending: root.sort-descending;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(0); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
width: root.modified-column-width; text: root.chinese ? "修改日期" : "Modified"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 1; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(1); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
width: root.size-column-width; text: root.chinese ? "大小" : "Size"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 2; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(2); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file-list := Rectangle {
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
public function reset-scroll() { visible-files.viewport-y = 0px; }
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
visible-files := ListView {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
for entry[index] in root.entries: Rectangle {
|
||||||
|
x: 0px; width: file-list.width - file-list.content-gutter;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
background: entry.selected
|
||||||
|
? (row-touch.pressed ? ControlStyle.selected-pressed.with-alpha(0.4) : row-touch.has-hover ? ControlStyle.selected-hover.with-alpha(0.35) : ControlStyle.selected.with-alpha(0.3))
|
||||||
|
: row-touch.pressed ? ControlStyle.pressed.with-alpha(0.3) : row-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
opacity: root.dragging && row-touch.drag-started ? 0.55 : 1;
|
||||||
|
folder-menu := ContextMenuArea {
|
||||||
|
enabled: entry.directory && root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
Menu {
|
||||||
|
MenuItem {
|
||||||
|
title: root.open-directory-text;
|
||||||
|
activated => { root.open-directory(entry.path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 6px; padding-right: 8px; spacing: 8px;
|
||||||
|
Rectangle { width: 24px; }
|
||||||
|
Text { width: 17px; text: entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 14px; color: entry.directory ? WorkbenchStyle.accent : ControlStyle.icon; vertical-alignment: center; }
|
||||||
|
Text { text: entry.name; color: entry.link ? WorkbenchStyle.muted : entry.selected ? ControlStyle.selected-text : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
|
||||||
|
Text { width: root.modified-column-width; text: entry.modified != "" ? entry.modified : "—"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
Text { width: root.size-column-width; text: entry.directory ? "—" : entry.size; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
}
|
||||||
|
row-touch := TouchArea {
|
||||||
|
// Only the row body starts drags; checkboxes remain selection controls.
|
||||||
|
x: 34px; width: parent.width - 34px; height: parent.height;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "";
|
||||||
|
private property <bool> drag-armed;
|
||||||
|
out property <bool> drag-started;
|
||||||
|
// Keep pointer capture throughout the in-window drag, so the cursor
|
||||||
|
// remains a closed hand instead of DragArea's copy/no-drop cursor.
|
||||||
|
mouse-cursor: self.drag-started && root.dragging ? MouseCursor.grabbing : MouseCursor.default;
|
||||||
|
function update-drag-position() {
|
||||||
|
root.drag-move(self.absolute-position.x + self.mouse-x,
|
||||||
|
self.absolute-position.y + self.mouse-y);
|
||||||
|
}
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.button == PointerEventButton.left && event.kind == PointerEventKind.down {
|
||||||
|
self.drag-armed = root.transfer-ready && !entry.link;
|
||||||
|
self.drag-started = false;
|
||||||
|
} else if event.kind == PointerEventKind.cancel {
|
||||||
|
self.drag-armed = false;
|
||||||
|
if self.drag-started && root.dragging { root.drag-cancel(); }
|
||||||
|
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
|
||||||
|
self.drag-armed = false;
|
||||||
|
if self.drag-started && root.dragging {
|
||||||
|
self.update-drag-position();
|
||||||
|
root.drag-end();
|
||||||
|
}
|
||||||
|
} else if event.button == PointerEventButton.right && event.kind == PointerEventKind.up && folder-menu.enabled {
|
||||||
|
folder-menu.show({ x: self.mouse-x + 34px, y: self.mouse-y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved => {
|
||||||
|
if self.drag-armed && !self.drag-started && root.transfer-ready
|
||||||
|
&& (abs(self.mouse-x - self.pressed-x) >= 5px || abs(self.mouse-y - self.pressed-y) >= 5px) {
|
||||||
|
self.drag-started = true;
|
||||||
|
root.drag-start(root.drag-data(entry.path, root.selection-revision), entry);
|
||||||
|
}
|
||||||
|
if self.drag-started && root.dragging { self.update-drag-position(); }
|
||||||
|
}
|
||||||
|
clicked => { if !self.drag-started { root.select-entry(index); } }
|
||||||
|
double-clicked => { if !self.drag-started { root.open-entry(index); } }
|
||||||
|
}
|
||||||
|
BrowserCheckBox { x: 6px; y: (parent.height - self.height) / 2; width: 24px; checked: entry.selected; label: (root.chinese ? "选择 " : "Select ") + entry.name; enabled: !entry.link && root.enabled && !root.busy && root.error == "" && !root.dragging; toggled => { root.select-entry(index); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.entries.length == 0 && !root.busy && root.error == "": VerticalLayout {
|
||||||
|
x: 12px; y: 34px; width: parent.width - 24px; height: parent.height - 40px;
|
||||||
|
alignment: center; spacing: 10px;
|
||||||
|
Text {
|
||||||
|
text: root.filter-text != "" ? (root.chinese ? "没有匹配的项目" : "No matching items")
|
||||||
|
: (root.chinese ? "此目录为空" : "This folder is empty");
|
||||||
|
color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
if root.filter-text != "": HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
BrowserButton { width: 112px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "清空筛选" : "Clear filter"; clicked => { root.filter-text = ""; root.filter(""); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.error != "" && !root.busy: Rectangle {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface;
|
||||||
|
TouchArea {}
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 16px; spacing: 10px; alignment: center;
|
||||||
|
Text { text: root.chinese ? "无法读取目录" : "Unable to open folder"; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||||
|
Text { text: root.error; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; }
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
BrowserButton { width: 128px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "打开主目录" : "Open home folder"; clicked => { root.navigate(""); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.busy: Rectangle {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface.with-alpha(0.9);
|
||||||
|
VerticalLayout { alignment: center; spacing: 12px; Spinner { width: 24px; height: 24px; } Text { text: root.status; horizontal-alignment: center; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; } }
|
||||||
|
}
|
||||||
|
if root.drop-hover: FlatSurface {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
background: ControlStyle.selected.with-alpha(0.3); outline-color: UiSurface.focus; border-radius: UiRadius.control;
|
||||||
|
Rectangle {
|
||||||
|
x: 8px; y: max(6px, parent.height - 68px); width: parent.width - 16px; height: 60px;
|
||||||
|
background: ControlStyle.surface; border-radius: UiRadius.control;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 9px; spacing: 4px;
|
||||||
|
Text { text: root.chinese ? "松手复制 " + root.drag-count + " 项到此目录" : "Release to copy " + root.drag-count + " items here"; color: WorkbenchStyle.accent; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||||
|
Text { text: root.path; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.body; overflow: elide; horizontal-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
height: 16px;
|
||||||
|
text: root.busy ? root.status
|
||||||
|
: root.path != root.current-path ? (root.chinese ? "Enter 打开路径 · Esc 还原" : "Enter to open · Esc to restore")
|
||||||
|
: root.error != "" ? (root.chinese ? "目录不可用" : "Folder unavailable")
|
||||||
|
: root.filter-text != "" ? (root.chinese ? "筛选结果 · " : "Filtered · ") + root.status : root.status;
|
||||||
|
color: root.path != root.current-path ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directory-popup := PopupWindow {
|
||||||
|
x: 14px; y: 78px; width: root.width - 28px; height: min(330px, max(180px, root.height - 90px));
|
||||||
|
close-policy: close-on-click-outside;
|
||||||
|
FlatSurface {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 12px; spacing: 10px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 28px; spacing: 8px;
|
||||||
|
BrowserButton { text: root.chinese ? "主目录" : "Home"; quiet: true; height: 28px; clicked => { root.navigate(""); directory-popup.close(); } }
|
||||||
|
BrowserButton { text: root.chinese ? "磁盘" : "Drives"; quiet: true; height: 28px; clicked => { root.navigate("@roots"); directory-popup.close(); } }
|
||||||
|
}
|
||||||
|
tree-list := Rectangle {
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
ListView {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
for node[index] in root.tree: Rectangle {
|
||||||
|
x: 0px; width: tree-list.width - tree-list.content-gutter;
|
||||||
|
height: 29px; border-radius: UiRadius.control;
|
||||||
|
background: node.selected ? ControlStyle.selected.with-alpha(0.3) : tree-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
Text { x: 24px + min(node.depth, 8) * 12px; width: parent.width - self.x - 4px; height: parent.height; text: node.name; color: node.selected ? WorkbenchStyle.accent : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide; }
|
||||||
|
tree-touch := TouchArea { clicked => { root.tree-open(index); directory-popup.close(); } }
|
||||||
|
BrowserButton { x: min(node.depth, 8) * 12px; width: 22px; height: 28px; quiet: true; icon: node.expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; clicked => { root.tree-toggle(index); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BrowserCheckBox { width: parent.width; show-label: true; label: root.chinese ? "显示隐藏项" : "Show hidden files"; checked: root.show-hidden; toggled => { root.toggle-hidden(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component FileBrowserWindow inherits Window {
|
||||||
|
title: root.chinese ? "文件传输" : "File transfer";
|
||||||
|
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||||
|
preferred-width: 900px; preferred-height: 620px;
|
||||||
|
min-width: 760px; min-height: 500px;
|
||||||
|
background: WorkbenchStyle.canvas;
|
||||||
|
default-font-size: ImGuiFontStyle.base;
|
||||||
|
in property <bool> chinese: true;
|
||||||
|
in property <string> send-local-text: root.chinese ? "发送到本机" : "Send to local";
|
||||||
|
in property <string> send-remote-text: root.chinese ? "发送到远端" : "Send to remote";
|
||||||
|
in property <string> clear-completed-text: root.chinese ? "清除已完成" : "Clear completed";
|
||||||
|
in property <string> open-directory-text: root.chinese ? "打开目录" : "Open folder";
|
||||||
|
in property <bool> connected: true;
|
||||||
|
in property <string> peer-name;
|
||||||
|
in property <string> local-role;
|
||||||
|
in property <string> remote-role;
|
||||||
|
in-out property <string> local-path;
|
||||||
|
in-out property <string> remote-path;
|
||||||
|
in property <string> local-current-path;
|
||||||
|
in property <string> remote-current-path;
|
||||||
|
in-out property <string> local-filter;
|
||||||
|
in-out property <string> remote-filter;
|
||||||
|
in property <string> local-status;
|
||||||
|
in property <string> remote-status;
|
||||||
|
in property <string> local-error;
|
||||||
|
in property <string> remote-error;
|
||||||
|
in property <bool> local-busy;
|
||||||
|
in property <bool> remote-busy;
|
||||||
|
in property <bool> local-up;
|
||||||
|
in property <bool> remote-up;
|
||||||
|
in property <bool> local-all;
|
||||||
|
in property <bool> remote-all;
|
||||||
|
in property <bool> local-hidden;
|
||||||
|
in property <bool> remote-hidden;
|
||||||
|
in property <int> local-sort-column;
|
||||||
|
in property <int> remote-sort-column;
|
||||||
|
in property <bool> local-sort-descending;
|
||||||
|
in property <bool> remote-sort-descending;
|
||||||
|
in property <int> local-selected;
|
||||||
|
in property <int> remote-selected;
|
||||||
|
in property <[BrowserEntry]> local-entries;
|
||||||
|
in property <[BrowserEntry]> remote-entries;
|
||||||
|
in property <[BrowserTreeEntry]> local-tree;
|
||||||
|
in property <[BrowserTreeEntry]> remote-tree;
|
||||||
|
in property <[BrowserTask]> tasks;
|
||||||
|
in property <string> task-summary;
|
||||||
|
in property <string> notice;
|
||||||
|
in property <bool> notice-error;
|
||||||
|
in property <bool> can-upload;
|
||||||
|
in property <bool> can-download;
|
||||||
|
in property <bool> source-remote;
|
||||||
|
in property <bool> transfer-ready;
|
||||||
|
in property <int> selection-revision;
|
||||||
|
in property <bool> drag-active;
|
||||||
|
in property <int> drag-count;
|
||||||
|
private property <data-transfer> drag-payload;
|
||||||
|
private property <BrowserEntry> drag-entry;
|
||||||
|
private property <length> drag-x;
|
||||||
|
private property <length> drag-y;
|
||||||
|
private property <int> drop-target: -1;
|
||||||
|
changed drag-active => { if !root.drag-active { root.drop-target = -1; } }
|
||||||
|
in-out property <bool> tasks-expanded: false;
|
||||||
|
private property <length> task-list-max-height: min(144px, max(72px, root.height - 440px));
|
||||||
|
private property <int> selected-count: root.local-selected + root.remote-selected;
|
||||||
|
private property <bool> path-pending: root.local-path != root.local-current-path || root.remote-path != root.remote-current-path;
|
||||||
|
private property <bool> notice-visible: false;
|
||||||
|
changed notice => { root.notice-visible = root.notice != ""; }
|
||||||
|
Timer {
|
||||||
|
interval: 3500ms;
|
||||||
|
running: root.notice-visible && !root.notice-error;
|
||||||
|
triggered => { root.notice-visible = false; }
|
||||||
|
}
|
||||||
|
callback navigate(bool, string);
|
||||||
|
callback refresh(bool);
|
||||||
|
callback up(bool);
|
||||||
|
callback select-entry(bool, int);
|
||||||
|
callback open-entry(bool, int);
|
||||||
|
callback open-directory(bool, string);
|
||||||
|
callback select-all(bool);
|
||||||
|
callback clear-selection;
|
||||||
|
callback tree-open(bool, int);
|
||||||
|
callback tree-toggle(bool, int);
|
||||||
|
callback filter(bool, string);
|
||||||
|
callback toggle-hidden(bool);
|
||||||
|
callback sort(bool, int);
|
||||||
|
callback transfer(bool);
|
||||||
|
callback cancel-task(int);
|
||||||
|
callback clear-finished;
|
||||||
|
pure callback drag-data(bool, string, int) -> data-transfer;
|
||||||
|
callback drag-state(data-transfer, bool);
|
||||||
|
callback can-drop(bool, data-transfer) -> bool;
|
||||||
|
callback dropped(bool, data-transfer) -> bool;
|
||||||
|
callback cancel-drag;
|
||||||
|
function start-drag(data: data-transfer, entry: BrowserEntry) {
|
||||||
|
root.drag-payload = data;
|
||||||
|
root.drag-entry = entry;
|
||||||
|
root.drop-target = -1;
|
||||||
|
root.drag-state(data, true);
|
||||||
|
}
|
||||||
|
function move-drag(x: length, y: length) {
|
||||||
|
if !root.drag-active { return; }
|
||||||
|
root.drag-x = x;
|
||||||
|
root.drag-y = y;
|
||||||
|
root.drop-target = local-pane.contains-drop(x, y) && root.can-drop(false, root.drag-payload) ? 0
|
||||||
|
: remote-pane.contains-drop(x, y) && root.can-drop(true, root.drag-payload) ? 1 : -1;
|
||||||
|
}
|
||||||
|
function finish-drag() {
|
||||||
|
if !root.drag-active { return; }
|
||||||
|
// The payload stays fixed from press-and-drag through release; C++ validates
|
||||||
|
// its source, destination, session and generation again before copying.
|
||||||
|
let data = root.drag-payload;
|
||||||
|
if root.drop-target >= 0 { root.dropped(root.drop-target == 1, data); }
|
||||||
|
root.drag-state(data, false);
|
||||||
|
}
|
||||||
|
forward-focus: focus;
|
||||||
|
SurfaceSeparator { x: 0px; y: 0px; width: parent.width; }
|
||||||
|
focus := FocusScope {
|
||||||
|
focus-on-click: true;
|
||||||
|
// Window sizing is defined above. Keep its layout constraints separate
|
||||||
|
// from children whose dimensions adapt to the current window size.
|
||||||
|
x: 0px; y: 0px; width: root.width; height: root.height;
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == Key.Escape { root.cancel-drag(); return accept; }
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
focus-lost => { if root.drag-active { root.cancel-drag(); } }
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 12px; padding: 16px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 32px; spacing: 12px;
|
||||||
|
Text { text: root.chinese ? "文件传输" : "File transfer"; font-size: ImGuiFontStyle.section-title; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; horizontal-stretch: 1; }
|
||||||
|
Text { text: root.peer-name; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: ImGuiFontStyle.base; overflow: elide; }
|
||||||
|
Rectangle {
|
||||||
|
width: root.chinese ? 104px : 144px;
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
width: parent.width; height: 28px; icon: FontAwesomeIcons.rotate;
|
||||||
|
text: root.chinese ? "刷新目录" : "Refresh folders";
|
||||||
|
text-size: ImGuiFontStyle.base;
|
||||||
|
accessible-label: root.chinese ? "刷新双方目录" : "Refresh both folders";
|
||||||
|
enabled: root.connected && !root.drag-active && !root.local-busy && !root.remote-busy;
|
||||||
|
clicked => { root.refresh(false); root.refresh(true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
vertical-stretch: 1; spacing: 12px;
|
||||||
|
local-pane := BrowserPane {
|
||||||
|
width: (parent.width - 12px) / 2;
|
||||||
|
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "本机" : "Local"; subtitle: root.local-role;
|
||||||
|
path <=> root.local-path; status: root.local-status; error: root.local-error; busy: root.local-busy;
|
||||||
|
open-directory-text: root.open-directory-text;
|
||||||
|
open-directory(path) => { root.open-directory(false, path); }
|
||||||
|
current-path: root.local-current-path; filter-text <=> root.local-filter;
|
||||||
|
can-up: root.local-up; all-selected: root.local-all; entries: root.local-entries; tree: root.local-tree;
|
||||||
|
show-hidden: root.local-hidden; toggle-hidden => { root.toggle-hidden(false); }
|
||||||
|
sort-column: root.local-sort-column; sort-descending: root.local-sort-descending;
|
||||||
|
sort(column) => { root.sort(false, column); }
|
||||||
|
selected-count: root.local-selected; destination: root.remote-selected > 0;
|
||||||
|
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||||
|
drop-hover: root.drag-active && root.drop-target == 0;
|
||||||
|
navigate(path) => { root.navigate(false, path); } up => { root.up(false); }
|
||||||
|
select-entry(index) => { root.select-entry(false, index); } open-entry(index) => { root.open-entry(false, index); }
|
||||||
|
select-all => { root.select-all(false); } tree-open(index) => { root.tree-open(false, index); } tree-toggle(index) => { root.tree-toggle(false, index); }
|
||||||
|
filter(text) => { root.filter(false, text); }
|
||||||
|
drag-data(path, revision) => { return root.drag-data(false, path, revision); }
|
||||||
|
drag-start(data, entry) => { root.start-drag(data, entry); }
|
||||||
|
drag-move(x, y) => { root.move-drag(x, y); }
|
||||||
|
drag-end => { root.finish-drag(); }
|
||||||
|
drag-cancel => { root.cancel-drag(); }
|
||||||
|
}
|
||||||
|
remote-pane := BrowserPane {
|
||||||
|
width: (parent.width - 12px) / 2;
|
||||||
|
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "远端" : "Remote"; subtitle: root.remote-role; enabled: root.connected;
|
||||||
|
path <=> root.remote-path; status: root.remote-status; error: root.remote-error; busy: root.remote-busy;
|
||||||
|
open-directory-text: root.open-directory-text;
|
||||||
|
open-directory(path) => { root.open-directory(true, path); }
|
||||||
|
current-path: root.remote-current-path; filter-text <=> root.remote-filter;
|
||||||
|
can-up: root.remote-up; all-selected: root.remote-all; entries: root.remote-entries; tree: root.remote-tree;
|
||||||
|
show-hidden: root.remote-hidden; toggle-hidden => { root.toggle-hidden(true); }
|
||||||
|
sort-column: root.remote-sort-column; sort-descending: root.remote-sort-descending;
|
||||||
|
sort(column) => { root.sort(true, column); }
|
||||||
|
selected-count: root.remote-selected; destination: root.local-selected > 0;
|
||||||
|
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||||
|
drop-hover: root.drag-active && root.drop-target == 1;
|
||||||
|
navigate(path) => { root.navigate(true, path); } up => { root.up(true); }
|
||||||
|
select-entry(index) => { root.select-entry(true, index); } open-entry(index) => { root.open-entry(true, index); }
|
||||||
|
select-all => { root.select-all(true); } tree-open(index) => { root.tree-open(true, index); } tree-toggle(index) => { root.tree-toggle(true, index); }
|
||||||
|
filter(text) => { root.filter(true, text); }
|
||||||
|
drag-data(path, revision) => { return root.drag-data(true, path, revision); }
|
||||||
|
drag-start(data, entry) => { root.start-drag(data, entry); }
|
||||||
|
drag-move(x, y) => { root.move-drag(x, y); }
|
||||||
|
drag-end => { root.finish-drag(); }
|
||||||
|
drag-cancel => { root.cancel-drag(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkbenchCard {
|
||||||
|
height: 83px + task-content.height;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 0px; padding: 1px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 48px; padding-left: 12px; padding-right: 12px; spacing: 12px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center; spacing: 3px; horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: root.path-pending ? (root.chinese ? "按 Enter 打开路径" : "Press Enter to open the path")
|
||||||
|
: root.drag-active ? (root.chinese ? "松手复制 · Esc 取消" : "Release to copy · Esc to cancel")
|
||||||
|
: root.notice-error ? root.notice
|
||||||
|
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项 · " + (root.source-remote ? "远端 → 本机" : "本机 → 远端")
|
||||||
|
: (root.source-remote ? "Remote → Local" : "Local → Remote") + " · " + root.selected-count + " selected")
|
||||||
|
: root.notice-visible ? root.notice
|
||||||
|
: (root.chinese ? "选择文件,或拖到另一侧" : "Select files or drag to the other side");
|
||||||
|
color: root.notice-error && !root.drag-active && !root.path-pending ? UiSurface.error
|
||||||
|
: root.selected-count > 0 ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 400; overflow: elide;
|
||||||
|
}
|
||||||
|
if root.selected-count > 0 && !root.path-pending && !root.drag-active && !root.notice-error: Text {
|
||||||
|
text: (root.chinese ? "目标:" : "To: ") + (root.source-remote ? root.local-current-path : root.remote-current-path);
|
||||||
|
color: WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.selected-count > 0: Rectangle {
|
||||||
|
width: root.chinese ? 76px : 100px;
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true;
|
||||||
|
text-size: ImGuiFontStyle.base; text: root.chinese ? "取消选择" : "Clear selection";
|
||||||
|
enabled: !root.drag-active; clicked => { root.clear-selection(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: max(root.chinese ? 148px : 160px, send-label-measure.preferred-width + 32px);
|
||||||
|
send-label-measure := Text {
|
||||||
|
visible: false;
|
||||||
|
text: root.source-remote ? root.send-local-text : root.send-remote-text;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 600;
|
||||||
|
}
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2; width: parent.width; height: 32px; primary: true;
|
||||||
|
text-size: ImGuiFontStyle.base;
|
||||||
|
enabled: !root.drag-active && (root.source-remote ? root.can-download : root.can-upload);
|
||||||
|
text: send-label-measure.text;
|
||||||
|
clicked => { root.transfer(root.source-remote); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 1px; SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; } }
|
||||||
|
Rectangle {
|
||||||
|
height: 32px;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 10px; padding-right: 10px; spacing: 8px;
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
BrowserButton { x: 0px; width: min(parent.width, task-label.preferred-width + 46px); height: 32px; quiet: true; accessible-label: task-label.text; clicked => { root.tasks-expanded = !root.tasks-expanded; } }
|
||||||
|
Text { x: 8px; width: 14px; height: parent.height; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: ImGuiFontStyle.base; color: ControlStyle.icon; vertical-alignment: center; text: root.tasks-expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; }
|
||||||
|
task-label := Text { x: 30px; width: parent.width - 40px; height: parent.height; font-size: ImGuiFontStyle.base; color: WorkbenchStyle.muted; vertical-alignment: center; overflow: elide; text: (root.chinese ? "传输任务" : "Transfers") + " · " + (root.tasks.length == 0 ? (root.chinese ? "暂无任务" : "No tasks") : root.task-summary != "" ? root.task-summary : root.tasks.length); }
|
||||||
|
}
|
||||||
|
if root.tasks-expanded: Rectangle {
|
||||||
|
width: max(100px, clear-label-measure.preferred-width + 24px);
|
||||||
|
clear-label-measure := Text {
|
||||||
|
visible: false; text: root.clear-completed-text;
|
||||||
|
font-size: ImGuiFontStyle.body; font-weight: 500;
|
||||||
|
}
|
||||||
|
BrowserButton { y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true; text-size: ImGuiFontStyle.body; text: root.clear-completed-text; clicked => { root.clear-finished(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task-content := Rectangle {
|
||||||
|
height: root.tasks-expanded ? min(root.task-list-max-height, root.tasks.length == 0 ? 40px : task-rows.preferred-height) : 0px;
|
||||||
|
visible: root.tasks-expanded;
|
||||||
|
clip: true;
|
||||||
|
SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; }
|
||||||
|
if root.tasks.length == 0: Text { text: root.chinese ? "暂无传输任务" : "No transfers yet"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
task-list := BrowserScrollView {
|
||||||
|
x: 16px; width: parent.width - 32px; height: parent.height;
|
||||||
|
visible: root.tasks.length > 0;
|
||||||
|
task-rows := VerticalLayout {
|
||||||
|
spacing: 2px; padding-top: 4px; padding-bottom: 4px; padding-right: task-list.content-gutter;
|
||||||
|
for task in root.tasks: Rectangle {
|
||||||
|
height: task.active ? 46px : 32px;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
Text { width: 18px; text: task.receiving ? "↓" : "↑"; color: WorkbenchStyle.accent; font-size: 16px; vertical-alignment: center; }
|
||||||
|
VerticalLayout {
|
||||||
|
padding-top: 5px; padding-bottom: 5px; spacing: 3px; alignment: center; horizontal-stretch: 1;
|
||||||
|
Rectangle {
|
||||||
|
height: 16px;
|
||||||
|
task-name := Text {
|
||||||
|
x: 0px;
|
||||||
|
width: min(self.preferred-width, max(0px, parent.width - task-size.preferred-width - 8px));
|
||||||
|
height: parent.height; text: task.name; color: WorkbenchStyle.text;
|
||||||
|
font-size: ImGuiFontStyle.base; overflow: elide; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
task-size := Text {
|
||||||
|
x: task-name.width + 8px; width: self.preferred-width; height: parent.height;
|
||||||
|
text: task.size; color: WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.active: Text { text: task.detail; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; }
|
||||||
|
}
|
||||||
|
Rectangle { width: root.width < 820px ? 80px : 110px; Rectangle { x: 0px; y: (parent.height - 4px) / 2; width: parent.width; height: 4px; background: WorkbenchStyle.separator; border-radius: 2px; Rectangle { x: 0px; y: 0px; width: parent.width * task.progress; height: 4px; border-radius: 2px; background: task.failed ? UiSurface.error : WorkbenchStyle.accent; } } }
|
||||||
|
Text { width: root.width < 820px ? 92px : 110px; text: task.status; color: task.failed ? UiSurface.error : WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
Rectangle { width: 56px; if task.can-cancel: BrowserButton { y: (parent.height - self.height) / 2; width: 56px; height: 26px; text: root.chinese ? "取消" : "Cancel"; text-size: ImGuiFontStyle.body; quiet: true; clicked => { root.cancel-task(task.id); } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Visual only: pointer capture and drop validation stay with the source row.
|
||||||
|
if root.drag-active: FlatSurface {
|
||||||
|
private property <bool> label-on-left: root.drag-x - 12px + self.width > root.width - 8px;
|
||||||
|
z: 100;
|
||||||
|
width: min(300px, max(112px, drag-label.preferred-width + 54px + drag-badge.width));
|
||||||
|
height: 40px;
|
||||||
|
// Keep the icon's top-left corner at the pointer when the label flips at the right edge.
|
||||||
|
x: root.drag-x - drag-icon.x;
|
||||||
|
y: root.drag-y - drag-icon.y;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
background: WorkbenchStyle.surface;
|
||||||
|
outline-color: root.drop-target >= 0 ? WorkbenchStyle.accent : UiSurface.hover;
|
||||||
|
opacity: 0.94;
|
||||||
|
drag-icon := Text {
|
||||||
|
x: parent.label-on-left ? parent.width - 30px : 12px;
|
||||||
|
y: (parent.height - self.height) / 2; width: 18px; height: 18px;
|
||||||
|
text: root.drag-entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines;
|
||||||
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 17px;
|
||||||
|
color: root.drag-entry.directory ? WorkbenchStyle.accent : ControlStyle.icon;
|
||||||
|
horizontal-alignment: left; vertical-alignment: top;
|
||||||
|
}
|
||||||
|
drag-label := Text {
|
||||||
|
x: parent.label-on-left ? 12px : 40px;
|
||||||
|
width: parent.width - 54px - drag-badge.width; height: parent.height;
|
||||||
|
text: root.drag-entry.name; color: WorkbenchStyle.text;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 500;
|
||||||
|
vertical-alignment: center; overflow: elide;
|
||||||
|
}
|
||||||
|
drag-badge := Rectangle {
|
||||||
|
visible: root.drag-count > 1;
|
||||||
|
x: parent.width - self.width - (parent.label-on-left ? 38px : 10px); y: 10px;
|
||||||
|
width: root.drag-count > 1 ? max(24px, count-label.preferred-width + 12px) : 0px;
|
||||||
|
height: 20px; border-radius: 10px; background: WorkbenchStyle.accent;
|
||||||
|
count-label := Text {
|
||||||
|
width: parent.width; height: parent.height; text: root.drag-count;
|
||||||
|
color: white; font-size: ImGuiFontStyle.body; font-weight: 600;
|
||||||
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,8 @@ export global UiStrings {
|
|||||||
in-out property <string> capture-method: "Capture method";
|
in-out property <string> capture-method: "Capture method";
|
||||||
in-out property <string> capture-method-auto: "Auto";
|
in-out property <string> capture-method-auto: "Auto";
|
||||||
in-out property <string> codec: "Video codec";
|
in-out property <string> codec: "Video codec";
|
||||||
|
in-out property <string> pause-video-when-minimized: "Pause video when minimized";
|
||||||
|
in-out property <string> pause-video-when-minimized-hint: "Pause video reception when the control window is minimized to reduce local decoding resource usage.";
|
||||||
in-out property <string> force-relay: "Force relay";
|
in-out property <string> force-relay: "Force relay";
|
||||||
in-out property <string> self-hosted: "Self-hosted server";
|
in-out property <string> self-hosted: "Self-hosted server";
|
||||||
in-out property <string> autostart: "Start with system";
|
in-out property <string> autostart: "Start with system";
|
||||||
@@ -118,6 +120,11 @@ export global UiStrings {
|
|||||||
in-out property <string> permission-required: "The application requires the following permissions:";
|
in-out property <string> permission-required: "The application requires the following permissions:";
|
||||||
in-out property <string> screen-recording-permission: "Screen recording permission";
|
in-out property <string> screen-recording-permission: "Screen recording permission";
|
||||||
in-out property <string> accessibility-permission: "Accessibility permission";
|
in-out property <string> accessibility-permission: "Accessibility permission";
|
||||||
|
in-out property <string> system-permissions: "System permissions";
|
||||||
|
in-out property <string> permission-migration-running: "Migrating permissions…";
|
||||||
|
in-out property <string> permission-migration-failed;
|
||||||
|
in-out property <string> retry-permission-migration: "Retry migration";
|
||||||
|
in-out property <string> permission-later: "Later";
|
||||||
in-out property <string> wayland-notice-title: "Wayland Session Notice";
|
in-out property <string> wayland-notice-title: "Wayland Session Notice";
|
||||||
in-out property <string> wayland-notice-subtitle;
|
in-out property <string> wayland-notice-subtitle;
|
||||||
in-out property <string> wayland-permission-title;
|
in-out property <string> wayland-permission-title;
|
||||||
@@ -623,6 +630,9 @@ export component MainWindow inherits Window {
|
|||||||
in-out property <string> connection-password: "";
|
in-out property <string> connection-password: "";
|
||||||
in-out property <bool> connection-remember-password: false;
|
in-out property <bool> connection-remember-password: false;
|
||||||
in property <bool> permission-dialog-open: false;
|
in property <bool> permission-dialog-open: false;
|
||||||
|
in property <bool> mac-permission-settings-visible: false;
|
||||||
|
in property <bool> permission-migration-running: false;
|
||||||
|
in property <bool> permission-migration-failed: false;
|
||||||
in property <bool> screen-recording-granted: true;
|
in property <bool> screen-recording-granted: true;
|
||||||
in property <bool> accessibility-granted: true;
|
in property <bool> accessibility-granted: true;
|
||||||
in-out property <bool> wayland-notice-open: false;
|
in-out property <bool> wayland-notice-open: false;
|
||||||
@@ -658,6 +668,7 @@ export component MainWindow inherits Window {
|
|||||||
changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } }
|
changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } }
|
||||||
in-out property <bool> daemon-enabled: false;
|
in-out property <bool> daemon-enabled: false;
|
||||||
in-out property <bool> save-remote-previews: true;
|
in-out property <bool> save-remote-previews: true;
|
||||||
|
in-out property <bool> pause-video-when-minimized: true;
|
||||||
// Show the preference on every platform; only adapted hosts can change it.
|
// Show the preference on every platform; only adapted hosts can change it.
|
||||||
in property <bool> privacy-setting-available: false;
|
in property <bool> privacy-setting-available: false;
|
||||||
in-out property <bool> privacy-on-connect-enabled: true;
|
in-out property <bool> privacy-on-connect-enabled: true;
|
||||||
@@ -714,6 +725,9 @@ export component MainWindow inherits Window {
|
|||||||
callback reset-remote-id;
|
callback reset-remote-id;
|
||||||
callback request-screen-recording-permission;
|
callback request-screen-recording-permission;
|
||||||
callback request-accessibility-permission;
|
callback request-accessibility-permission;
|
||||||
|
callback open-permissions;
|
||||||
|
callback dismiss-permissions;
|
||||||
|
callback retry-permission-migration;
|
||||||
callback install-portable-service;
|
callback install-portable-service;
|
||||||
callback cancel-portable-service;
|
callback cancel-portable-service;
|
||||||
callback acknowledge-portable-service;
|
callback acknowledge-portable-service;
|
||||||
@@ -726,6 +740,11 @@ export component MainWindow inherits Window {
|
|||||||
in-out property <bool> settings-open: false;
|
in-out property <bool> settings-open: false;
|
||||||
in-out property <int> settings-tab: 0;
|
in-out property <int> settings-tab: 0;
|
||||||
private property <length> settings-scroll-y: 0px;
|
private property <length> settings-scroll-y: 0px;
|
||||||
|
private property <bool> pause-video-hint-visible: false;
|
||||||
|
private property <length> pause-video-hint-x: 0px;
|
||||||
|
private property <length> pause-video-hint-y: 0px;
|
||||||
|
changed settings-tab => { root.pause-video-hint-visible = false; }
|
||||||
|
changed settings-scroll-y => { root.pause-video-hint-visible = false; }
|
||||||
in-out property <bool> self-host-settings-open: false;
|
in-out property <bool> self-host-settings-open: false;
|
||||||
changed self-host-settings-open => {
|
changed self-host-settings-open => {
|
||||||
if !root.self-host-settings-open { root.self-host-settings-error = ""; }
|
if !root.self-host-settings-open { root.self-host-settings-error = ""; }
|
||||||
@@ -826,6 +845,7 @@ export component MainWindow inherits Window {
|
|||||||
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
|
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
|
||||||
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
|
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
|
||||||
changed settings-open => {
|
changed settings-open => {
|
||||||
|
root.pause-video-hint-visible = false;
|
||||||
if root.settings-open {
|
if root.settings-open {
|
||||||
root.settings-tab = 0;
|
root.settings-tab = 0;
|
||||||
root.settings-scroll-y = 0px;
|
root.settings-scroll-y = 0px;
|
||||||
@@ -2568,82 +2588,117 @@ export component MainWindow inherits Window {
|
|||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
width: min(400px, parent.width - 40px);
|
width: min(400px, parent.width - 40px);
|
||||||
height: permission-content.preferred-height;
|
height: min(permission-content.preferred-height, parent.height - 32px);
|
||||||
background: #f8fafc; outline-color: UiSurface.edge;
|
background: #f8fafc; outline-color: UiSurface.edge;
|
||||||
TouchArea { clicked => { focus-sink.focus(); } }
|
TouchArea { clicked => { focus-sink.focus(); } }
|
||||||
permission-content := VerticalLayout {
|
permission-scroll := ScrollView {
|
||||||
padding: 24px; spacing: 20px;
|
width: parent.width; height: parent.height;
|
||||||
VerticalLayout {
|
viewport-width: self.visible-width;
|
||||||
spacing: 8px;
|
viewport-height: permission-content.preferred-height;
|
||||||
Text {
|
horizontal-scrollbar-policy: always-off;
|
||||||
text: UiStrings.request-permissions;
|
vertical-scrollbar-policy: as-needed;
|
||||||
font-size: 20px; font-weight: 600; color: WorkbenchStyle.text;
|
Rectangle {
|
||||||
wrap: word-wrap;
|
width: permission-scroll.viewport-width;
|
||||||
}
|
height: permission-scroll.viewport-height;
|
||||||
Text {
|
permission-content := VerticalLayout {
|
||||||
text: UiStrings.permission-required;
|
padding: 24px; spacing: 20px;
|
||||||
font-size: 12px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
VerticalLayout {
|
||||||
}
|
spacing: 8px;
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 12px;
|
|
||||||
for permission[index] in [
|
|
||||||
{label: UiStrings.accessibility-permission, granted: root.accessibility-granted,
|
|
||||||
description: GalleryStrings.texts[108]},
|
|
||||||
{label: UiStrings.screen-recording-permission, granted: root.screen-recording-granted,
|
|
||||||
description: GalleryStrings.texts[109]},
|
|
||||||
]: WorkbenchCard {
|
|
||||||
height: max(80px, permission-labels.preferred-height + 32px);
|
|
||||||
permission-labels := VerticalLayout {
|
|
||||||
x: 16px; y: 16px; width: parent.width - permission-action.width - 48px;
|
|
||||||
height: parent.height - 32px; spacing: 6px; alignment: center;
|
|
||||||
Text {
|
Text {
|
||||||
text: permission.label; font-size: 13px; font-weight: 600;
|
text: UiStrings.request-permissions;
|
||||||
color: WorkbenchStyle.text; wrap: word-wrap;
|
font-size: 20px; font-weight: 600; color: WorkbenchStyle.text;
|
||||||
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: permission.description; font-size: 11px;
|
text: UiStrings.permission-required;
|
||||||
color: WorkbenchStyle.muted; wrap: word-wrap;
|
font-size: 12px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
permission-action := Rectangle {
|
VerticalLayout {
|
||||||
x: parent.width - self.width - 16px; y: (parent.height - self.height) / 2;
|
spacing: 12px;
|
||||||
width: max(96px, permission-action-metrics.preferred-width + 24px);
|
for permission[index] in [
|
||||||
height: 36px;
|
{label: UiStrings.accessibility-permission, granted: root.accessibility-granted,
|
||||||
permission-action-metrics := Text {
|
description: GalleryStrings.texts[108]},
|
||||||
visible: false; font-size: 13px; font-weight: 600;
|
{label: UiStrings.screen-recording-permission, granted: root.screen-recording-granted,
|
||||||
text: GalleryStrings.texts[110];
|
description: GalleryStrings.texts[109]},
|
||||||
}
|
]: WorkbenchCard {
|
||||||
if !permission.granted: WorkbenchButton {
|
height: max(80px, permission-labels.preferred-height + 32px);
|
||||||
width: parent.width; height: parent.height; primary: true;
|
permission-labels := VerticalLayout {
|
||||||
text: permission-action-metrics.text;
|
x: 16px; y: 16px; width: parent.width - permission-action.width - 48px;
|
||||||
accessible-label: permission.label + ": " + self.text;
|
height: parent.height - 32px; spacing: 6px; alignment: center;
|
||||||
clicked => {
|
Text {
|
||||||
if index == 0 { root.request-accessibility-permission(); }
|
text: permission.label; font-size: 13px; font-weight: 600;
|
||||||
else { root.request-screen-recording-permission(); }
|
color: WorkbenchStyle.text; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: permission.description; font-size: 11px;
|
||||||
|
color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
permission-action := Rectangle {
|
||||||
if permission.granted: HorizontalLayout {
|
x: parent.width - self.width - 16px; y: (parent.height - self.height) / 2;
|
||||||
alignment: center; spacing: 6px;
|
width: max(96px, permission-action-metrics.preferred-width + 24px);
|
||||||
Text {
|
height: 36px;
|
||||||
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
permission-action-metrics := Text {
|
||||||
font-weight: 900; font-size: 12px; color: WorkbenchStyle.success;
|
visible: false; font-size: 13px; font-weight: 600;
|
||||||
vertical-alignment: center; accessible-role: none;
|
text: GalleryStrings.texts[110];
|
||||||
}
|
}
|
||||||
Text {
|
if !permission.granted: WorkbenchButton {
|
||||||
text: GalleryStrings.texts[111];
|
width: parent.width; height: parent.height; primary: true;
|
||||||
font-size: 12px; color: WorkbenchStyle.success; vertical-alignment: center;
|
enabled: !root.permission-migration-running;
|
||||||
|
text: permission-action-metrics.text;
|
||||||
|
accessible-label: permission.label + ": " + self.text;
|
||||||
|
clicked => {
|
||||||
|
if index == 0 { root.request-accessibility-permission(); }
|
||||||
|
else { root.request-screen-recording-permission(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if permission.granted: HorizontalLayout {
|
||||||
|
alignment: center; spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
||||||
|
font-weight: 900; font-size: 12px; color: WorkbenchStyle.success;
|
||||||
|
vertical-alignment: center; accessible-role: none;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: GalleryStrings.texts[111];
|
||||||
|
font-size: 12px; color: WorkbenchStyle.success; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
|
||||||
|
if root.permission-migration-running || root.permission-migration-failed: Text {
|
||||||
|
text: root.permission-migration-running
|
||||||
|
? UiStrings.permission-migration-running : UiStrings.permission-migration-failed;
|
||||||
|
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: GalleryStrings.texts[112];
|
||||||
|
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: end; spacing: 8px;
|
||||||
|
WorkbenchButton {
|
||||||
|
text: root.accessibility-granted && root.screen-recording-granted
|
||||||
|
&& !root.permission-migration-running && !root.permission-migration-failed
|
||||||
|
? UiStrings.ok : UiStrings.permission-later;
|
||||||
|
clicked => { root.dismiss-permissions(); }
|
||||||
|
}
|
||||||
|
if root.permission-migration-failed: retry-migration := WorkbenchButton {
|
||||||
|
width: max(112px, retry-migration-label.preferred-width + 24px);
|
||||||
|
primary: true; text: UiStrings.retry-permission-migration;
|
||||||
|
clicked => { root.retry-permission-migration(); }
|
||||||
|
retry-migration-label := Text {
|
||||||
|
visible: false; accessible-role: none;
|
||||||
|
text: retry-migration.text; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 12px;
|
|
||||||
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
|
|
||||||
Text {
|
|
||||||
text: GalleryStrings.texts[112];
|
|
||||||
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2730,15 +2785,30 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
title: GalleryStrings.texts[115];
|
title: GalleryStrings.texts[115];
|
||||||
rows: root.capture-method-visible ? 2 : 1;
|
rows: root.capture-method-visible ? 3 : 2;
|
||||||
if root.capture-method-visible: SettingsRow {
|
if root.capture-method-visible: SettingsRow {
|
||||||
label: UiStrings.capture-method; enabled: !root.settings-session-active;
|
label: UiStrings.capture-method; enabled: !root.settings-session-active;
|
||||||
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
|
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
|
||||||
}
|
}
|
||||||
SettingsRow {
|
SettingsRow {
|
||||||
label: UiStrings.codec; enabled: !root.settings-session-active; divider: false;
|
label: UiStrings.codec; enabled: !root.settings-session-active;
|
||||||
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; }
|
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; }
|
||||||
}
|
}
|
||||||
|
pause-video-hover := TouchArea {
|
||||||
|
height: 36px;
|
||||||
|
function update-hint() {
|
||||||
|
root.pause-video-hint-visible = self.has-hover;
|
||||||
|
root.pause-video-hint-x = self.absolute-position.x + self.mouse-x;
|
||||||
|
root.pause-video-hint-y = self.absolute-position.y + self.mouse-y;
|
||||||
|
}
|
||||||
|
changed has-hover => { self.update-hint(); }
|
||||||
|
pointer-event(event) => { self.update-hint(); }
|
||||||
|
SettingsToggleRow {
|
||||||
|
width: parent.width;
|
||||||
|
label: UiStrings.pause-video-when-minimized;
|
||||||
|
checked <=> root.pause-video-when-minimized; divider: false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
title: GalleryStrings.texts[116]; rows: 4;
|
title: GalleryStrings.texts[116]; rows: 4;
|
||||||
@@ -2770,13 +2840,14 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
title: GalleryStrings.texts[120]; rows: 5;
|
title: GalleryStrings.texts[120];
|
||||||
|
rows: root.mac-permission-settings-visible ? 6 : 5;
|
||||||
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
|
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
|
||||||
SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; }
|
SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; }
|
||||||
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; }
|
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; }
|
||||||
SettingsToggleRow { label: TrustStrings.only; checked <=> root.trusted-devices-only; }
|
SettingsToggleRow { label: TrustStrings.only; checked <=> root.trusted-devices-only; }
|
||||||
SettingsRow {
|
SettingsRow {
|
||||||
label: TrustStrings.title; divider: false;
|
label: TrustStrings.title; divider: root.mac-permission-settings-visible;
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
x: parent.width - self.width - 12px; y: 4px;
|
x: parent.width - self.width - 12px; y: 4px;
|
||||||
width: max(104px, self.label-width); height: 28px;
|
width: max(104px, self.label-width); height: 28px;
|
||||||
@@ -2784,6 +2855,15 @@ export component MainWindow inherits Window {
|
|||||||
clicked => { root.trusted-devices-open = true; root.trust-action("open", "", ""); }
|
clicked => { root.trusted-devices-open = true; root.trust-action("open", "", ""); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if root.mac-permission-settings-visible: SettingsRow {
|
||||||
|
label: UiStrings.system-permissions; divider: false;
|
||||||
|
SettingsButton {
|
||||||
|
x: parent.width - self.width - 12px; y: 4px;
|
||||||
|
width: max(104px, self.label-width); height: 28px;
|
||||||
|
text: TrustStrings.manage;
|
||||||
|
clicked => { root.open-permissions(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: GalleryStrings.texts[121];
|
text: GalleryStrings.texts[121];
|
||||||
@@ -2843,6 +2923,25 @@ export component MainWindow inherits Window {
|
|||||||
clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } }
|
clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if root.window-active && root.settings-tab == 0 && root.pause-video-hint-visible: Rectangle {
|
||||||
|
// Keep the immediate hint outside ScrollView's clipping area.
|
||||||
|
width: min(260px, parent.width - 16px);
|
||||||
|
height: max(28px, pause-video-hint-text.preferred-height + 12px);
|
||||||
|
x: clamp(root.pause-video-hint-x - settings-dialog.absolute-position.x,
|
||||||
|
8px, parent.width - self.width - 8px);
|
||||||
|
private property <length> pointer-y: root.pause-video-hint-y - settings-dialog.absolute-position.y;
|
||||||
|
y: self.pointer-y + 8px + self.height <= parent.height - 8px
|
||||||
|
? self.pointer-y + 8px : self.pointer-y - self.height - 8px;
|
||||||
|
background: #24324a;
|
||||||
|
border-radius: UiRadius.control; z: 20;
|
||||||
|
pause-video-hint-text := Text {
|
||||||
|
x: 10px; y: 6px;
|
||||||
|
width: parent.width - 20px; height: parent.height - 12px;
|
||||||
|
text: UiStrings.pause-video-when-minimized-hint;
|
||||||
|
font-size: 12px; font-weight: 400; color: white; wrap: word-wrap;
|
||||||
|
horizontal-alignment: left; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <length> font-size: 12px;
|
in property <length> font-size: 12px;
|
||||||
in property <color> normal-outline-color: UiSurface.edge;
|
in property <color> normal-outline-color: UiSurface.edge;
|
||||||
|
in property <float> tint-opacity: 1;
|
||||||
in property <brush> surrounding-background: white;
|
in property <brush> surrounding-background: white;
|
||||||
callback accepted();
|
callback accepted();
|
||||||
callback edited();
|
callback edited();
|
||||||
@@ -36,6 +37,7 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
// A visual-only tint preserves the standard editor and its built-in actions.
|
// A visual-only tint preserves the standard editor and its built-in actions.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
|
opacity: root.tint-opacity;
|
||||||
background: !root.enabled ? transparent
|
background: !root.enabled ? transparent
|
||||||
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ export component TrustedDevicesDialog inherits Rectangle {
|
|||||||
// Extend into the right margin to preserve the existing card width.
|
// Extend into the right margin to preserve the existing card width.
|
||||||
width: parent.width - 56px + dialog.scrollbar-gutter;
|
width: parent.width - 56px + dialog.scrollbar-gutter;
|
||||||
height: parent.height - 84px;
|
height: parent.height - 84px;
|
||||||
min-width: 0px; min-height: 0px;
|
|
||||||
horizontal-scrollbar-policy: always-off;
|
horizontal-scrollbar-policy: always-off;
|
||||||
vertical-scrollbar-policy: as-needed;
|
vertical-scrollbar-policy: as-needed;
|
||||||
viewport-width: self.visible-width;
|
viewport-width: self.visible-width;
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ inline int ApplyMainWindowStrings(
|
|||||||
trust.set_remove(Text(localization::Literal("移除信任", language)));
|
trust.set_remove(Text(localization::Literal("移除信任", language)));
|
||||||
trust.set_close(Text(localization::message_close[language]));
|
trust.set_close(Text(localization::message_close[language]));
|
||||||
auto& strings = window->global<ui::UiStrings>();
|
auto& strings = window->global<ui::UiStrings>();
|
||||||
|
strings.set_system_permissions(Text(localization::system_permissions[language]));
|
||||||
|
strings.set_permission_migration_running(Text(localization::permission_migration_running[language]));
|
||||||
|
strings.set_permission_migration_failed(Text(localization::permission_migration_failed[language]));
|
||||||
|
strings.set_retry_permission_migration(Text(localization::retry_permission_migration[language]));
|
||||||
|
strings.set_permission_later(Text(localization::permission_later[language]));
|
||||||
strings.set_window_minimize(Text(localization::window_minimize[language]));
|
strings.set_window_minimize(Text(localization::window_minimize[language]));
|
||||||
strings.set_window_close(Text(localization::window_close[language]));
|
strings.set_window_close(Text(localization::window_close[language]));
|
||||||
strings.set_save(Text(localization::save[language]));
|
strings.set_save(Text(localization::save[language]));
|
||||||
@@ -130,6 +135,10 @@ inline int ApplyMainWindowStrings(
|
|||||||
strings.set_capture_method_auto(
|
strings.set_capture_method_auto(
|
||||||
Text(localization::screen_capture_method_auto[language]));
|
Text(localization::screen_capture_method_auto[language]));
|
||||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||||
|
strings.set_pause_video_when_minimized(
|
||||||
|
Text(localization::pause_video_when_minimized[language]));
|
||||||
|
strings.set_pause_video_when_minimized_hint(
|
||||||
|
Text(localization::pause_video_when_minimized_hint[language]));
|
||||||
strings.set_force_relay(Text(localization::force_relay[language]));
|
strings.set_force_relay(Text(localization::force_relay[language]));
|
||||||
strings.set_self_hosted(
|
strings.set_self_hosted(
|
||||||
Text(localization::self_hosted_server_config[language]));
|
Text(localization::self_hosted_server_config[language]));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
in property <string> tooltip;
|
in property <string> tooltip;
|
||||||
in property <bool> tooltip-above: false;
|
in property <bool> tooltip-above: false;
|
||||||
|
in property <bool> tooltip-align-left: false;
|
||||||
in property <bool> tooltip-suppressed: false;
|
in property <bool> tooltip-suppressed: false;
|
||||||
in property <color> icon-color: root.destructive ? white : root.primary
|
in property <color> icon-color: root.destructive ? white : root.primary
|
||||||
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
||||||
@@ -33,6 +34,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
in property <bool> soft: false;
|
in property <bool> soft: false;
|
||||||
in property <bool> quiet: false;
|
in property <bool> quiet: false;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
in property <MouseCursor> mouse-cursor: pointer;
|
||||||
out property <bool> has-focus: button-focus.has-focus;
|
out property <bool> has-focus: button-focus.has-focus;
|
||||||
private property <bool> keyboard-focus: false;
|
private property <bool> keyboard-focus: false;
|
||||||
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
||||||
@@ -99,15 +101,12 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
button-focus := FocusScope {
|
button-focus := FocusScope {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
focus-gained(reason) => {
|
focus-gained(reason) => {
|
||||||
if reason == FocusReason.tab-navigation {
|
root.keyboard-focus = reason == FocusReason.tab-navigation;
|
||||||
root.keyboard-focus = true;
|
|
||||||
} else if reason == FocusReason.pointer-click {
|
|
||||||
root.keyboard-focus = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
root.keyboard-focus = true;
|
|
||||||
if event.text == Key.Return || event.text == " " {
|
if event.text == Key.Return || event.text == " " {
|
||||||
|
root.keyboard-focus = true;
|
||||||
root.clicked();
|
root.clicked();
|
||||||
return accept;
|
return accept;
|
||||||
}
|
}
|
||||||
@@ -115,7 +114,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
}
|
}
|
||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: root.mouse-cursor;
|
||||||
pointer-event(event) => {
|
pointer-event(event) => {
|
||||||
if event.kind == PointerEventKind.down {
|
if event.kind == PointerEventKind.down {
|
||||||
root.keyboard-focus = false;
|
root.keyboard-focus = false;
|
||||||
@@ -123,7 +122,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
}
|
}
|
||||||
// Keep focus for subsequent keyboard activation without leaving a
|
// Keep focus for subsequent keyboard activation without leaving a
|
||||||
// keyboard focus ring behind after a pointer click.
|
// keyboard focus ring behind after a pointer click.
|
||||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
clicked => { button-focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tooltip-timer := Timer {
|
tooltip-timer := Timer {
|
||||||
@@ -132,7 +131,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
triggered => { root.tooltip-visible = true; }
|
triggered => { root.tooltip-visible = true; }
|
||||||
}
|
}
|
||||||
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
||||||
x: min(0px, root.width - self.width);
|
x: root.tooltip-align-left ? 0px : min(0px, root.width - self.width);
|
||||||
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
|
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
|
||||||
width: min(260px, tip.preferred-width + 20px);
|
width: min(260px, tip.preferred-width + 20px);
|
||||||
height: max(28px, tip.preferred-height + 12px);
|
height: max(28px, tip.preferred-height + 12px);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
#include "direct_address.h"
|
#include <minirtc.h>
|
||||||
#include "lan_password.h"
|
#include "lan_password.h"
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Winsock must precede windows.h; the networking headers need its types.
|
// Winsock must precede windows.h; the networking headers need its types.
|
||||||
@@ -212,12 +212,14 @@ unsigned ParsePort(const std::string& value) {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
bool IsIpAddress(const std::string& value) {
|
bool IsIpAddress(const std::string& value) {
|
||||||
return minirtc::ParseDirectAddress(value).has_value();
|
char normalized[129]{};
|
||||||
|
return MiniRtcNormalizeDirectAddress(value.data(), value.size(), normalized, sizeof(normalized)) == 0;
|
||||||
}
|
}
|
||||||
std::string NormalizeAddress(const std::string& value) {
|
std::string NormalizeAddress(const std::string& value) {
|
||||||
const auto address = minirtc::ParseDirectAddress(value);
|
char normalized[129]{};
|
||||||
Require(address.has_value(), "Invalid LAN endpoint");
|
Require(MiniRtcNormalizeDirectAddress(value.data(), value.size(), normalized, sizeof(normalized)) == 0,
|
||||||
return minirtc::DirectAddressString(*address);
|
"Invalid LAN endpoint");
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
std::string FormatEndpoint(const std::string& address, unsigned port) {
|
std::string FormatEndpoint(const std::string& address, unsigned port) {
|
||||||
return (address.find(':') == std::string::npos ? address
|
return (address.find(':') == std::string::npos ? address
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ void Session::Error(MiniRtcDirectError error, void* context) {
|
|||||||
s->failed = true;
|
s->failed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void Session::Status(ConnectionStatus status) {
|
void Session::Status(MiniRtcConnectionStatus status) {
|
||||||
if (closing) return;
|
if (closing) return;
|
||||||
if (status == ConnectionStatus::Connected)
|
if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected)
|
||||||
ready = true;
|
ready = true;
|
||||||
else if (status == ConnectionStatus::Closed ||
|
else if (status == MiniRtcConnectionStatus::MiniRtcConnectionClosed ||
|
||||||
status == ConnectionStatus::Failed ||
|
status == MiniRtcConnectionStatus::MiniRtcConnectionFailed ||
|
||||||
status == ConnectionStatus::Disconnected)
|
status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected)
|
||||||
failed = true;
|
failed = true;
|
||||||
}
|
}
|
||||||
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
|
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
|
||||||
@@ -417,9 +417,9 @@ void SessionManager::Prepare(const SessionPtr& s) {
|
|||||||
options.send_signal = Session::SendSignal;
|
options.send_signal = Session::SendSignal;
|
||||||
options.on_error = Session::Error;
|
options.on_error = Session::Error;
|
||||||
options.context = s.get();
|
options.context = s.get();
|
||||||
if (InitDirectPeer(prepared.peer, &options) != 0 ||
|
if (MiniRtcInitDirectPeer(prepared.peer, &options) != 0 ||
|
||||||
(!s->closing &&
|
(!s->closing &&
|
||||||
StartDirectSession(prepared.peer, !s->incoming) != 0))
|
MiniRtcStartDirectSession(prepared.peer, !s->incoming) != 0))
|
||||||
s->failed = true;
|
s->failed = true;
|
||||||
return prepared;
|
return prepared;
|
||||||
})});
|
})});
|
||||||
@@ -560,7 +560,7 @@ void SessionManager::Message(const SessionPtr& s, const std::string& text) {
|
|||||||
Check(type == "candidate" &&
|
Check(type == "candidate" &&
|
||||||
(s->phase == Session::AwaitAnswer ||
|
(s->phase == Session::AwaitAnswer ||
|
||||||
s->phase == Session::Negotiating || s->phase == Session::Connected));
|
s->phase == Session::Negotiating || s->phase == Session::Connected));
|
||||||
if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
|
if (MiniRtcReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
|
||||||
Fail(s, "InvalidSignal");
|
Fail(s, "InvalidSignal");
|
||||||
}
|
}
|
||||||
void SessionManager::Dispose(const SessionPtr& s) {
|
void SessionManager::Dispose(const SessionPtr& s) {
|
||||||
@@ -571,8 +571,8 @@ void SessionManager::Dispose(const SessionPtr& s) {
|
|||||||
cleanup_jobs_.push_back(
|
cleanup_jobs_.push_back(
|
||||||
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
|
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
|
||||||
if (drain) drain();
|
if (drain) drain();
|
||||||
CloseDirectSession(peer);
|
MiniRtcCloseDirectSession(peer);
|
||||||
DestroyPeer(&peer);
|
MiniRtcDestroyPeer(&peer);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
|
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ struct Session {
|
|||||||
bool incoming = false;
|
bool incoming = false;
|
||||||
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
|
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
|
||||||
std::chrono::steady_clock::time_point deadline;
|
std::chrono::steady_clock::time_point deadline;
|
||||||
PeerPtr* peer = nullptr; // Only published/cleared by the manager.
|
MiniRtcPeerPtr* peer = nullptr; // Only published/cleared by the manager.
|
||||||
std::atomic<bool> closing{false}, authorized{false}, ready{false},
|
std::atomic<bool> closing{false}, authorized{false}, ready{false},
|
||||||
failed{false};
|
failed{false};
|
||||||
std::atomic<int> failure_code{-1};
|
std::atomic<int> failure_code{-1};
|
||||||
@@ -54,7 +54,7 @@ struct Session {
|
|||||||
std::function<void()> drain_callbacks;
|
std::function<void()> drain_callbacks;
|
||||||
static int SendSignal(const char* data, size_t size, void* context);
|
static int SendSignal(const char* data, size_t size, void* context);
|
||||||
static void Error(MiniRtcDirectError error, void* context);
|
static void Error(MiniRtcDirectError error, void* context);
|
||||||
void Status(ConnectionStatus status);
|
void Status(MiniRtcConnectionStatus status);
|
||||||
};
|
};
|
||||||
|
|
||||||
// All public methods run on the GUI manager thread. Only Session's bounded
|
// All public methods run on the GUI manager thread. Only Session's bounded
|
||||||
@@ -63,7 +63,7 @@ class SessionManager {
|
|||||||
public:
|
public:
|
||||||
using SessionPtr = std::shared_ptr<Session>;
|
using SessionPtr = std::shared_ptr<Session>;
|
||||||
struct Prepared {
|
struct Prepared {
|
||||||
PeerPtr* peer = nullptr;
|
MiniRtcPeerPtr* peer = nullptr;
|
||||||
std::shared_ptr<void> callback_owner;
|
std::shared_ptr<void> callback_owner;
|
||||||
std::function<void()> drain_callbacks;
|
std::function<void()> drain_callbacks;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#include <websocketpp/config/asio_client.hpp>
|
#include <websocketpp/config/asio_client.hpp>
|
||||||
#include <websocketpp/server.hpp>
|
#include <websocketpp/server.hpp>
|
||||||
|
|
||||||
#include "direct_address.h"
|
#include <minirtc.h>
|
||||||
|
|
||||||
namespace crossdesk::lan {
|
namespace crossdesk::lan {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -147,7 +147,11 @@ bool SupportsDualStack() {
|
|||||||
std::string SocketAddress(asio::ip::address address) {
|
std::string SocketAddress(asio::ip::address address) {
|
||||||
if (address.is_v6() && address.to_v6().is_v4_mapped())
|
if (address.is_v6() && address.to_v6().is_v4_mapped())
|
||||||
address = asio::ip::make_address_v4(asio::ip::v4_mapped, address.to_v6());
|
address = asio::ip::make_address_v4(asio::ip::v4_mapped, address.to_v6());
|
||||||
return minirtc::DirectAddressString(address);
|
if (address.is_v4()) return address.to_string();
|
||||||
|
auto ipv6 = address.to_v6();
|
||||||
|
const auto scope = ipv6.scope_id();
|
||||||
|
ipv6.scope_id(0);
|
||||||
|
return ipv6.to_string() + (scope ? "%" + std::to_string(scope) : "");
|
||||||
}
|
}
|
||||||
std::shared_ptr<Context> TlsContext() {
|
std::shared_ptr<Context> TlsContext() {
|
||||||
auto context = std::make_shared<Context>(Context::tls);
|
auto context = std::make_shared<Context>(Context::tls);
|
||||||
@@ -323,7 +327,8 @@ struct Transport::Impl {
|
|||||||
c->local_address = SocketAddress(local.address());
|
c->local_address = SocketAddress(local.address());
|
||||||
c->remote_address = SocketAddress(remote.address());
|
c->remote_address = SocketAddress(remote.address());
|
||||||
const auto interfaces = LocalIpInterfaces();
|
const auto interfaces = LocalIpInterfaces();
|
||||||
if (!minirtc::IsDirectAddressPair(c->local_address, c->remote_address) ||
|
if (MiniRtcIsDirectAddressPair(c->local_address.data(), c->local_address.size(),
|
||||||
|
c->remote_address.data(), c->remote_address.size()) != 1 ||
|
||||||
std::none_of(
|
std::none_of(
|
||||||
interfaces.begin(), interfaces.end(),
|
interfaces.begin(), interfaces.end(),
|
||||||
[&](const auto& i) { return i.address == c->local_address; })) {
|
[&](const auto& i) { return i.address == c->local_address; })) {
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ struct OpenGlFunctions {
|
|||||||
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
|
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
|
||||||
PFNGLUSEPROGRAMPROC use_program = nullptr;
|
PFNGLUSEPROGRAMPROC use_program = nullptr;
|
||||||
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
|
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
|
||||||
|
PFNGLGENVERTEXARRAYSPROC gen_vertex_arrays = nullptr;
|
||||||
|
PFNGLDELETEVERTEXARRAYSPROC delete_vertex_arrays = nullptr;
|
||||||
|
PFNGLBINDVERTEXARRAYPROC bind_vertex_array = nullptr;
|
||||||
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
|
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
|
||||||
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
|
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
|
||||||
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
|
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
|
||||||
@@ -178,6 +181,12 @@ struct OpenGlFunctions {
|
|||||||
"glCheckFramebufferStatus");
|
"glCheckFramebufferStatus");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LoadVertexArrayFunctions() {
|
||||||
|
return LoadOpenGlFunction(&gen_vertex_arrays, "glGenVertexArrays") &&
|
||||||
|
LoadOpenGlFunction(&delete_vertex_arrays, "glDeleteVertexArrays") &&
|
||||||
|
LoadOpenGlFunction(&bind_vertex_array, "glBindVertexArray");
|
||||||
|
}
|
||||||
|
|
||||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||||
bool LoadSyncFunctions() {
|
bool LoadSyncFunctions() {
|
||||||
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
|
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
|
||||||
@@ -292,6 +301,8 @@ struct OpenGlVideoRenderer::Impl {
|
|||||||
std::atomic<bool> ready{false};
|
std::atomic<bool> ready{false};
|
||||||
GLuint program = 0;
|
GLuint program = 0;
|
||||||
GLuint vertex_buffer = 0;
|
GLuint vertex_buffer = 0;
|
||||||
|
GLuint vertex_array = 0;
|
||||||
|
bool modern_gl = false;
|
||||||
GLuint y_texture = 0;
|
GLuint y_texture = 0;
|
||||||
GLuint uv_texture = 0;
|
GLuint uv_texture = 0;
|
||||||
GLint position_location = -1;
|
GLint position_location = -1;
|
||||||
@@ -644,6 +655,8 @@ struct OpenGlVideoRenderer::Impl {
|
|||||||
void ResetGlHandles() {
|
void ResetGlHandles() {
|
||||||
program = 0;
|
program = 0;
|
||||||
vertex_buffer = 0;
|
vertex_buffer = 0;
|
||||||
|
vertex_array = 0;
|
||||||
|
modern_gl = false;
|
||||||
y_texture = 0;
|
y_texture = 0;
|
||||||
uv_texture = 0;
|
uv_texture = 0;
|
||||||
position_location = -1;
|
position_location = -1;
|
||||||
@@ -698,6 +711,13 @@ bool OpenGlVideoRenderer::Setup() {
|
|||||||
std::sscanf(is_gles ? version + std::strlen("OpenGL ES ") : version,
|
std::sscanf(is_gles ? version + std::strlen("OpenGL ES ") : version,
|
||||||
"%d.%d", &major, &minor);
|
"%d.%d", &major, &minor);
|
||||||
}
|
}
|
||||||
|
// Slint may fall back from GLES to a desktop core context. In that context
|
||||||
|
// VAO 0, legacy luminance textures and legacy shader inputs are not usable.
|
||||||
|
impl_->modern_gl = major >= 3;
|
||||||
|
if (impl_->modern_gl && !impl_->gl.LoadVertexArrayFunctions()) {
|
||||||
|
LOG_WARN("OpenGL NV12 underlay unavailable: vertex array functions missing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const auto *extensions = major < 3
|
const auto *extensions = major < 3
|
||||||
? reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS)) : nullptr;
|
? reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS)) : nullptr;
|
||||||
impl_->pixel_unpack_buffer_available = major >= 3 ||
|
impl_->pixel_unpack_buffer_available = major >= 3 ||
|
||||||
@@ -727,22 +747,30 @@ bool OpenGlVideoRenderer::Setup() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
const std::string vertex_source =
|
const std::string shader_version = is_gles
|
||||||
std::string(is_gles ? "#version 100\n" : "#version 110\n") + R"glsl(
|
? (impl_->modern_gl ? "#version 300 es\n" : "#version 100\n")
|
||||||
attribute vec2 position;
|
: (major > 3 || (major == 3 && minor >= 2) ? "#version 150\n"
|
||||||
attribute vec2 texcoord;
|
: impl_->modern_gl ? "#version 130\n" : "#version 110\n");
|
||||||
varying vec2 video_texcoord;
|
const std::string vertex_source = shader_version +
|
||||||
|
(impl_->modern_gl ? "#define INPUT in\n#define OUTPUT out\n"
|
||||||
|
: "#define INPUT attribute\n#define OUTPUT varying\n") + R"glsl(
|
||||||
|
INPUT vec2 position;
|
||||||
|
INPUT vec2 texcoord;
|
||||||
|
OUTPUT vec2 video_texcoord;
|
||||||
void main() {
|
void main() {
|
||||||
video_texcoord = texcoord;
|
video_texcoord = texcoord;
|
||||||
gl_Position = vec4(position, 0.0, 1.0);
|
gl_Position = vec4(position, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
)glsl";
|
)glsl";
|
||||||
const std::string fragment_source =
|
const std::string fragment_source = shader_version +
|
||||||
std::string(is_gles ? "#version 100\n"
|
(is_gles ? "#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
|
||||||
"#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
|
"precision highp float;\n"
|
||||||
"precision highp float;\n"
|
"#else\nprecision mediump float;\n#endif\n" : "") +
|
||||||
"#else\nprecision mediump float;\n#endif\n"
|
(impl_->modern_gl
|
||||||
: "#version 110\n") + R"glsl(
|
? "#define INPUT in\n#define SAMPLE texture\n"
|
||||||
|
"#define UV_CHANNELS rg\nout vec4 fragment_color;\n"
|
||||||
|
: "#define INPUT varying\n#define SAMPLE texture2D\n"
|
||||||
|
"#define UV_CHANNELS ra\n#define fragment_color gl_FragColor\n") + R"glsl(
|
||||||
uniform sampler2D y_texture;
|
uniform sampler2D y_texture;
|
||||||
uniform sampler2D uv_texture;
|
uniform sampler2D uv_texture;
|
||||||
uniform vec2 target_size;
|
uniform vec2 target_size;
|
||||||
@@ -752,7 +780,7 @@ uniform float packed_video;
|
|||||||
uniform vec4 video_rect;
|
uniform vec4 video_rect;
|
||||||
uniform float corner_radius;
|
uniform float corner_radius;
|
||||||
uniform float video_enabled;
|
uniform float video_enabled;
|
||||||
varying vec2 video_texcoord;
|
INPUT vec2 video_texcoord;
|
||||||
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
|
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
|
||||||
// so 16 taps cover its complete support even for non-integer scale ratios.
|
// so 16 taps cover its complete support even for non-integer scale ratios.
|
||||||
float lanczos2(float x) {
|
float lanczos2(float x) {
|
||||||
@@ -766,7 +794,7 @@ vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
|
|||||||
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
|
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
|
||||||
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
|
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
|
||||||
if (filter_direction.x + filter_direction.y < 0.5)
|
if (filter_direction.x + filter_direction.y < 0.5)
|
||||||
return texture2D(plane, coord);
|
return SAMPLE(plane, coord);
|
||||||
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
|
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
|
||||||
float center = dot(coord * size - vec2(0.5), filter_direction);
|
float center = dot(coord * size - vec2(0.5), filter_direction);
|
||||||
float first = floor(center - 2.0 * scale) + 1.0;
|
float first = floor(center - 2.0 * scale) + 1.0;
|
||||||
@@ -776,7 +804,7 @@ vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
|
|||||||
float offset = first + float(tap) - center;
|
float offset = first + float(tap) - center;
|
||||||
if (offset >= 2.0 * scale) break;
|
if (offset >= 2.0 * scale) break;
|
||||||
float weight = lanczos2(offset / scale);
|
float weight = lanczos2(offset / scale);
|
||||||
sum += weight * texture2D(plane, coord + filter_direction * offset / size);
|
sum += weight * SAMPLE(plane, coord + filter_direction * offset / size);
|
||||||
weight_sum += weight;
|
weight_sum += weight;
|
||||||
}
|
}
|
||||||
return sum / weight_sum;
|
return sum / weight_sum;
|
||||||
@@ -785,7 +813,7 @@ vec3 sample_video(vec2 coord) {
|
|||||||
vec4 y_sample = sample_plane(y_texture, source_size, coord);
|
vec4 y_sample = sample_plane(y_texture, source_size, coord);
|
||||||
if (packed_video > 0.5) return y_sample.rgb;
|
if (packed_video > 0.5) return y_sample.rgb;
|
||||||
return vec3(y_sample.r,
|
return vec3(y_sample.r,
|
||||||
sample_plane(uv_texture, source_size * 0.5, coord).ra);
|
sample_plane(uv_texture, source_size * 0.5, coord).UV_CHANNELS);
|
||||||
}
|
}
|
||||||
void main() {
|
void main() {
|
||||||
vec2 surface_point = video_texcoord * target_size;
|
vec2 surface_point = video_texcoord * target_size;
|
||||||
@@ -800,7 +828,7 @@ void main() {
|
|||||||
vec3 yuv = sample_video(frame_texcoord);
|
vec3 yuv = sample_video(frame_texcoord);
|
||||||
if (filter_direction.x + filter_direction.y > 0.5) {
|
if (filter_direction.x + filter_direction.y > 0.5) {
|
||||||
// Intermediate RGBA textures carry Y/U/V until the final conversion.
|
// Intermediate RGBA textures carry Y/U/V until the final conversion.
|
||||||
gl_FragColor = vec4(yuv, 1.0);
|
fragment_color = vec4(yuv, 1.0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
|
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
|
||||||
@@ -820,7 +848,7 @@ void main() {
|
|||||||
min(max(corner.x, corner.y), 0.0) - corner_radius;
|
min(max(corner.x, corner.y), 0.0) - corner_radius;
|
||||||
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
|
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
gl_FragColor = vec4(rgb * coverage, coverage);
|
fragment_color = vec4(rgb * coverage, coverage);
|
||||||
}
|
}
|
||||||
)glsl";
|
)glsl";
|
||||||
|
|
||||||
@@ -903,6 +931,11 @@ void main() {
|
|||||||
};
|
};
|
||||||
GLint previous_array_buffer = 0;
|
GLint previous_array_buffer = 0;
|
||||||
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
|
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
|
||||||
|
while (glGetError() != GL_NO_ERROR) {
|
||||||
|
}
|
||||||
|
if (impl_->modern_gl) {
|
||||||
|
impl_->gl.gen_vertex_arrays(1, &impl_->vertex_array);
|
||||||
|
}
|
||||||
impl_->gl.gen_buffers(1, &impl_->vertex_buffer);
|
impl_->gl.gen_buffers(1, &impl_->vertex_buffer);
|
||||||
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
|
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
|
||||||
impl_->gl.buffer_data(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices,
|
impl_->gl.buffer_data(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices,
|
||||||
@@ -918,8 +951,6 @@ void main() {
|
|||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
GLint previous_texture_1 = 0;
|
GLint previous_texture_1 = 0;
|
||||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
||||||
while (glGetError() != GL_NO_ERROR) {
|
|
||||||
}
|
|
||||||
glGenTextures(1, &impl_->y_texture);
|
glGenTextures(1, &impl_->y_texture);
|
||||||
glGenTextures(1, &impl_->uv_texture);
|
glGenTextures(1, &impl_->uv_texture);
|
||||||
for (const auto [unit, texture] :
|
for (const auto [unit, texture] :
|
||||||
@@ -940,6 +971,7 @@ void main() {
|
|||||||
|
|
||||||
const GLenum setup_error = glGetError();
|
const GLenum setup_error = glGetError();
|
||||||
if (impl_->program == 0 || impl_->vertex_buffer == 0 ||
|
if (impl_->program == 0 || impl_->vertex_buffer == 0 ||
|
||||||
|
(impl_->modern_gl && impl_->vertex_array == 0) ||
|
||||||
impl_->y_texture == 0 || impl_->uv_texture == 0 ||
|
impl_->y_texture == 0 || impl_->uv_texture == 0 ||
|
||||||
setup_error != GL_NO_ERROR) {
|
setup_error != GL_NO_ERROR) {
|
||||||
LOG_ERROR("OpenGL NV12 resource setup failed, error={}", setup_error);
|
LOG_ERROR("OpenGL NV12 resource setup failed, error={}", setup_error);
|
||||||
@@ -950,9 +982,16 @@ void main() {
|
|||||||
impl_->ready.store(true, std::memory_order_release);
|
impl_->ready.store(true, std::memory_order_release);
|
||||||
const auto *vendor = reinterpret_cast<const char *>(glGetString(GL_VENDOR));
|
const auto *vendor = reinterpret_cast<const char *>(glGetString(GL_VENDOR));
|
||||||
const auto *renderer = reinterpret_cast<const char *>(glGetString(GL_RENDERER));
|
const auto *renderer = reinterpret_cast<const char *>(glGetString(GL_RENDERER));
|
||||||
LOG_INFO("OpenGL NV12 underlay initialized ({}), vendor={}, renderer={}",
|
GLint profile = 0;
|
||||||
|
if (!is_gles && (major > 3 || (major == 3 && minor >= 2))) {
|
||||||
|
glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile);
|
||||||
|
}
|
||||||
|
LOG_INFO("OpenGL NV12 underlay initialized ({}), vendor={}, renderer={}, "
|
||||||
|
"profile={}, textures={}, vao={}",
|
||||||
version ? version : "unknown OpenGL version",
|
version ? version : "unknown OpenGL version",
|
||||||
vendor ? vendor : "unknown", renderer ? renderer : "unknown");
|
vendor ? vendor : "unknown", renderer ? renderer : "unknown",
|
||||||
|
profile, impl_->modern_gl ? "R/RG" : "luminance",
|
||||||
|
impl_->vertex_array);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +1012,9 @@ void OpenGlVideoRenderer::Teardown() {
|
|||||||
if (impl_->vertex_buffer != 0 && impl_->gl.delete_buffers) {
|
if (impl_->vertex_buffer != 0 && impl_->gl.delete_buffers) {
|
||||||
impl_->gl.delete_buffers(1, &impl_->vertex_buffer);
|
impl_->gl.delete_buffers(1, &impl_->vertex_buffer);
|
||||||
}
|
}
|
||||||
|
if (impl_->vertex_array != 0 && impl_->gl.delete_vertex_arrays) {
|
||||||
|
impl_->gl.delete_vertex_arrays(1, &impl_->vertex_array);
|
||||||
|
}
|
||||||
if (impl_->program != 0 && impl_->gl.delete_program) {
|
if (impl_->program != 0 && impl_->gl.delete_program) {
|
||||||
impl_->gl.delete_program(impl_->program);
|
impl_->gl.delete_program(impl_->program);
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1098,9 @@ OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitNv12Internal(
|
|||||||
|
|
||||||
auto &frames = *impl_->frames;
|
auto &frames = *impl_->frames;
|
||||||
std::lock_guard lock(frames.mutex);
|
std::lock_guard lock(frames.mutex);
|
||||||
|
if (!IsReady()) {
|
||||||
|
return SubmitResult::failed;
|
||||||
|
}
|
||||||
if (frames.selected_stream != remote_id) {
|
if (frames.selected_stream != remote_id) {
|
||||||
return SubmitResult::not_selected;
|
return SubmitResult::not_selected;
|
||||||
}
|
}
|
||||||
@@ -1194,9 +1239,12 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
int target_width, int target_height,
|
int target_width, int target_height,
|
||||||
int top_inset_pixels,
|
int top_inset_pixels,
|
||||||
int corner_radius_pixels) {
|
int corner_radius_pixels) {
|
||||||
if (!IsReady() || target_width <= 0 || target_height <= 0) {
|
if (!IsReady()) {
|
||||||
return {RenderResult::failed};
|
return {RenderResult::failed};
|
||||||
}
|
}
|
||||||
|
if (target_width <= 0 || target_height <= 0) {
|
||||||
|
return {RenderResult::idle};
|
||||||
|
}
|
||||||
|
|
||||||
size_t slot_index = kFrameSlotCount;
|
size_t slot_index = kFrameSlotCount;
|
||||||
uint64_t sequence = 0;
|
uint64_t sequence = 0;
|
||||||
@@ -1286,10 +1334,18 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
GLint previous_texture_1 = 0;
|
GLint previous_texture_1 = 0;
|
||||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
||||||
|
|
||||||
const auto position_state = CaptureVertexAttrib(
|
GLint previous_vertex_array = 0;
|
||||||
impl_->gl, static_cast<GLuint>(impl_->position_location));
|
VertexAttribState position_state;
|
||||||
const auto texcoord_state = CaptureVertexAttrib(
|
VertexAttribState texcoord_state;
|
||||||
impl_->gl, static_cast<GLuint>(impl_->texcoord_location));
|
if (impl_->vertex_array != 0) {
|
||||||
|
glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &previous_vertex_array);
|
||||||
|
impl_->gl.bind_vertex_array(impl_->vertex_array);
|
||||||
|
} else {
|
||||||
|
position_state = CaptureVertexAttrib(
|
||||||
|
impl_->gl, static_cast<GLuint>(impl_->position_location));
|
||||||
|
texcoord_state = CaptureVertexAttrib(
|
||||||
|
impl_->gl, static_cast<GLuint>(impl_->texcoord_location));
|
||||||
|
}
|
||||||
|
|
||||||
glDisable(GL_BLEND);
|
glDisable(GL_BLEND);
|
||||||
glDisable(GL_CULL_FACE);
|
glDisable(GL_CULL_FACE);
|
||||||
@@ -1401,20 +1457,26 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
if (uploaded_texture_width != source_width ||
|
if (uploaded_texture_width != source_width ||
|
||||||
uploaded_texture_height != source_height) {
|
uploaded_texture_height != source_height) {
|
||||||
impl_->gl.active_texture(GL_TEXTURE0);
|
impl_->gl.active_texture(GL_TEXTURE0);
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, source_width,
|
glTexImage2D(GL_TEXTURE_2D, 0,
|
||||||
source_height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE,
|
impl_->modern_gl ? GL_R8 : GL_LUMINANCE, source_width,
|
||||||
|
source_height, 0,
|
||||||
|
impl_->modern_gl ? GL_RED : GL_LUMINANCE, GL_UNSIGNED_BYTE,
|
||||||
y_pixels);
|
y_pixels);
|
||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, source_width / 2,
|
glTexImage2D(GL_TEXTURE_2D, 0,
|
||||||
source_height / 2, 0, GL_LUMINANCE_ALPHA,
|
impl_->modern_gl ? GL_RG8 : GL_LUMINANCE_ALPHA,
|
||||||
|
source_width / 2, source_height / 2, 0,
|
||||||
|
impl_->modern_gl ? GL_RG : GL_LUMINANCE_ALPHA,
|
||||||
GL_UNSIGNED_BYTE, uv_pixels);
|
GL_UNSIGNED_BYTE, uv_pixels);
|
||||||
} else {
|
} else {
|
||||||
impl_->gl.active_texture(GL_TEXTURE0);
|
impl_->gl.active_texture(GL_TEXTURE0);
|
||||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width, source_height,
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width, source_height,
|
||||||
GL_LUMINANCE, GL_UNSIGNED_BYTE, y_pixels);
|
impl_->modern_gl ? GL_RED : GL_LUMINANCE,
|
||||||
|
GL_UNSIGNED_BYTE, y_pixels);
|
||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width / 2,
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width / 2,
|
||||||
source_height / 2, GL_LUMINANCE_ALPHA,
|
source_height / 2,
|
||||||
|
impl_->modern_gl ? GL_RG : GL_LUMINANCE_ALPHA,
|
||||||
GL_UNSIGNED_BYTE, uv_pixels);
|
GL_UNSIGNED_BYTE, uv_pixels);
|
||||||
}
|
}
|
||||||
const GLenum upload_error = glGetError();
|
const GLenum upload_error = glGetError();
|
||||||
@@ -1562,10 +1624,23 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
has_video ? source_width : 0, has_video ? source_height : 0,
|
has_video ? source_width : 0, has_video ? source_height : 0,
|
||||||
has_video ? sequence : 0};
|
has_video ? sequence : 0};
|
||||||
|
|
||||||
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->position_location),
|
if (outcome.result == RenderResult::failed) {
|
||||||
position_state);
|
LOG_WARN("OpenGL NV12 presentation failed: upload={}, draw_error={}, "
|
||||||
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->texcoord_location),
|
"vao={}; disabling native video until the context is recreated",
|
||||||
texcoord_state);
|
upload_succeeded, draw_error, impl_->vertex_array);
|
||||||
|
// Do not expose the desktop during the UI tick that switches to pixels.
|
||||||
|
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impl_->vertex_array != 0) {
|
||||||
|
impl_->gl.bind_vertex_array(static_cast<GLuint>(previous_vertex_array));
|
||||||
|
} else {
|
||||||
|
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->position_location),
|
||||||
|
position_state);
|
||||||
|
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->texcoord_location),
|
||||||
|
texcoord_state);
|
||||||
|
}
|
||||||
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
|
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
|
||||||
static_cast<GLuint>(previous_array_buffer));
|
static_cast<GLuint>(previous_array_buffer));
|
||||||
if (impl_->pixel_unpack_buffer_available) {
|
if (impl_->pixel_unpack_buffer_available) {
|
||||||
@@ -1600,9 +1675,13 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
const GLenum restore_error = glGetError();
|
const GLenum restore_error = glGetError();
|
||||||
if (restore_error != GL_NO_ERROR) {
|
if (restore_error != GL_NO_ERROR) {
|
||||||
LOG_WARN("OpenGL NV12 state restoration failed, error={}", restore_error);
|
LOG_WARN("OpenGL NV12 state restoration failed, error={}", restore_error);
|
||||||
if (outcome.result == RenderResult::rendered) {
|
outcome.result = RenderResult::failed;
|
||||||
outcome.result = RenderResult::failed;
|
}
|
||||||
}
|
if (outcome.result == RenderResult::failed) {
|
||||||
|
// Keep the last decoded frame available for a CPU snapshot. GL resources
|
||||||
|
// are released by RenderingTeardown while the owning context is current.
|
||||||
|
std::lock_guard lock(impl_->frames->mutex);
|
||||||
|
impl_->ready.store(false, std::memory_order_release);
|
||||||
}
|
}
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ void Launch(const fs::path& executable, const std::vector<std::string>& argument
|
|||||||
}
|
}
|
||||||
execv(executable.c_str(), argv.data());
|
execv(executable.c_str(), argv.data());
|
||||||
const int error = errno;
|
const int error = errno;
|
||||||
(void)write(errors[1], &error, sizeof(error));
|
// This small blocking pipe write is atomic. Retry signal interruptions so
|
||||||
|
// the parent cannot mistake a failed exec for a successful launch.
|
||||||
|
ssize_t written;
|
||||||
|
do { written = write(errors[1], &error, sizeof(error)); }
|
||||||
|
while (written < 0 && errno == EINTR);
|
||||||
_exit(127);
|
_exit(127);
|
||||||
}
|
}
|
||||||
close(errors[1]);
|
close(errors[1]);
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
#include "desktop_automation.h"
|
||||||
|
|
||||||
|
#include <xcb/xcb.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <deque>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <mutex>
|
||||||
|
#include <memory>
|
||||||
|
#include <spawn.h>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <unistd.h>
|
||||||
|
#ifdef CROSSDESK_HAS_ATSPI
|
||||||
|
#include <atspi/atspi.h>
|
||||||
|
#endif
|
||||||
|
extern char** environ;
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
using nlohmann::json;
|
||||||
|
class LaunchedProcesses {
|
||||||
|
public:
|
||||||
|
LaunchedProcesses() {
|
||||||
|
children_.reserve(128);
|
||||||
|
worker_ = std::thread([this] {
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
while (!stopping_) {
|
||||||
|
wake_.wait_for(lock, std::chrono::milliseconds(500));
|
||||||
|
children_.erase(std::remove_if(children_.begin(), children_.end(), [](pid_t child) {
|
||||||
|
const auto result = waitpid(child, nullptr, WNOHANG);
|
||||||
|
return result > 0 || (result < 0 && errno == ECHILD);
|
||||||
|
}), children_.end());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
~LaunchedProcesses() {
|
||||||
|
{ std::lock_guard lock(mutex_); stopping_ = true; }
|
||||||
|
wake_.notify_all();
|
||||||
|
worker_.join();
|
||||||
|
}
|
||||||
|
pid_t Spawn(const std::string& executable, const std::string& argument) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (children_.size() >= 128) throw std::runtime_error("BUSY");
|
||||||
|
char* args[] = {const_cast<char*>(executable.c_str()), argument.empty() ? nullptr : const_cast<char*>(argument.c_str()), nullptr};
|
||||||
|
pid_t pid = 0;
|
||||||
|
if (posix_spawn(&pid, executable.c_str(), nullptr, nullptr, args, environ) != 0)
|
||||||
|
throw std::runtime_error("APPLICATION_UNAVAILABLE");
|
||||||
|
children_.push_back(pid);
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::condition_variable wake_;
|
||||||
|
bool stopping_ = false;
|
||||||
|
std::vector<pid_t> children_;
|
||||||
|
std::thread worker_;
|
||||||
|
};
|
||||||
|
json Spawn(const std::string& executable, const std::string& argument = {}) {
|
||||||
|
static LaunchedProcesses processes;
|
||||||
|
return {{"accepted", true}, {"pid", processes.Spawn(executable, argument)}};
|
||||||
|
}
|
||||||
|
struct XConnection {
|
||||||
|
std::shared_ptr<xcb_connection_t> connection;
|
||||||
|
xcb_window_t root = 0;
|
||||||
|
XConnection() {
|
||||||
|
int screen_index = 0;
|
||||||
|
connection = {xcb_connect(nullptr, &screen_index), xcb_disconnect};
|
||||||
|
if (!connection || xcb_connection_has_error(connection.get())) throw std::runtime_error("X11_UNAVAILABLE");
|
||||||
|
auto screen = xcb_setup_roots_iterator(xcb_get_setup(connection.get()));
|
||||||
|
for (int index = 0; index < screen_index && screen.rem; ++index) xcb_screen_next(&screen);
|
||||||
|
if (!screen.rem) throw std::runtime_error("X11_UNAVAILABLE");
|
||||||
|
root = screen.data->root;
|
||||||
|
}
|
||||||
|
xcb_atom_t Atom(const char* name) const {
|
||||||
|
auto reply = xcb_intern_atom_reply(connection.get(), xcb_intern_atom(connection.get(), 0, uint16_t(std::strlen(name)), name), nullptr);
|
||||||
|
const auto atom = reply ? reply->atom : XCB_ATOM_NONE;
|
||||||
|
std::free(reply);
|
||||||
|
return atom;
|
||||||
|
}
|
||||||
|
std::vector<uint32_t> Property(xcb_window_t window, const char* name, xcb_atom_t type) const {
|
||||||
|
auto reply = xcb_get_property_reply(connection.get(),
|
||||||
|
xcb_get_property(connection.get(), 0, window, Atom(name), type, 0, 256), nullptr);
|
||||||
|
std::vector<uint32_t> result;
|
||||||
|
if (reply && reply->type == type && reply->format == 32) {
|
||||||
|
const auto data = static_cast<const uint32_t*>(xcb_get_property_value(reply));
|
||||||
|
result.assign(data, data + xcb_get_property_value_length(reply) / 4);
|
||||||
|
}
|
||||||
|
std::free(reply);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::string Title(xcb_window_t window) const {
|
||||||
|
auto reply = xcb_get_property_reply(connection.get(),
|
||||||
|
xcb_get_property(connection.get(), 0, window, Atom("_NET_WM_NAME"), Atom("UTF8_STRING"), 0, 256), nullptr);
|
||||||
|
std::string text;
|
||||||
|
if (reply && reply->format == 8) text.assign(static_cast<const char*>(xcb_get_property_value(reply)), xcb_get_property_value_length(reply));
|
||||||
|
std::free(reply);
|
||||||
|
return DesktopText(std::move(text));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#ifdef CROSSDESK_HAS_ATSPI
|
||||||
|
template <class T> std::shared_ptr<T> Own(T* object) { return {object, [](T* value) { if (value) g_object_unref(value); }}; }
|
||||||
|
using Accessible = std::shared_ptr<AtspiAccessible>;
|
||||||
|
std::string Text(char* value) {
|
||||||
|
auto text = DesktopText(value ? value : "");
|
||||||
|
g_free(value);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
Accessible Desktop() {
|
||||||
|
static const int initialized = atspi_init();
|
||||||
|
if (initialized > 1) throw std::runtime_error("ACCESSIBILITY_UNAVAILABLE");
|
||||||
|
atspi_set_timeout(120, 120);
|
||||||
|
for (int count = 0; count < 32 && g_main_context_pending(nullptr); ++count) g_main_context_iteration(nullptr, false);
|
||||||
|
auto desktop = Own(atspi_get_desktop(0));
|
||||||
|
if (!desktop) throw std::runtime_error("ACCESSIBILITY_UNAVAILABLE");
|
||||||
|
return desktop;
|
||||||
|
}
|
||||||
|
json Perform(Accessible element, const std::string& operation, const std::string& value) {
|
||||||
|
atspi_accessible_clear_cache(element.get());
|
||||||
|
auto state = Own(atspi_accessible_get_state_set(element.get()));
|
||||||
|
if (!state || atspi_state_set_contains(state.get(), ATSPI_STATE_DEFUNCT)) throw std::runtime_error("STALE_ELEMENT");
|
||||||
|
const auto role = atspi_accessible_get_role(element.get(), nullptr);
|
||||||
|
if (role == ATSPI_ROLE_INVALID) throw std::runtime_error("STALE_ELEMENT");
|
||||||
|
if (role == ATSPI_ROLE_PASSWORD_TEXT) throw std::runtime_error("SECURE_ELEMENT");
|
||||||
|
bool accepted = false;
|
||||||
|
if (operation == "focus" || operation == "activate") {
|
||||||
|
auto component = Own(atspi_accessible_get_component_iface(element.get()));
|
||||||
|
if (component) accepted = atspi_component_grab_focus(component.get(), nullptr);
|
||||||
|
} else if (operation == "set_value") {
|
||||||
|
auto editable = Own(atspi_accessible_get_editable_text_iface(element.get()));
|
||||||
|
if (editable) accepted = atspi_editable_text_set_text_contents(editable.get(), value.c_str(), nullptr);
|
||||||
|
} else if (operation == "press") {
|
||||||
|
auto action = Own(atspi_accessible_get_action_iface(element.get()));
|
||||||
|
if (action) {
|
||||||
|
const int count = std::min(16, atspi_action_get_n_actions(action.get(), nullptr));
|
||||||
|
for (int index = 0; index < count; ++index) {
|
||||||
|
const auto name = Text(atspi_action_get_action_name(action.get(), index, nullptr));
|
||||||
|
if (name == "click" || name == "press" || name == "activate" || name == "toggle") {
|
||||||
|
accepted = atspi_action_do_action(action.get(), index, nullptr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accepted) throw std::runtime_error("UI_ACTION_FAILED");
|
||||||
|
return {{"accepted", true}};
|
||||||
|
}
|
||||||
|
DesktopSnapshot Tree(Accessible root, int limit, int depth) {
|
||||||
|
struct Entry { Accessible element; int depth, parent; };
|
||||||
|
std::deque<Entry> queue{{root, 0, -1}};
|
||||||
|
DesktopSnapshot result;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1200);
|
||||||
|
while (!queue.empty() && int(result.items.size()) < limit && std::chrono::steady_clock::now() < deadline) {
|
||||||
|
auto entry = std::move(queue.front()); queue.pop_front();
|
||||||
|
auto element = entry.element;
|
||||||
|
atspi_accessible_clear_cache(element.get());
|
||||||
|
const auto role = atspi_accessible_get_role(element.get(), nullptr);
|
||||||
|
if (role == ATSPI_ROLE_INVALID) continue;
|
||||||
|
const bool secure = role == ATSPI_ROLE_PASSWORD_TEXT;
|
||||||
|
json data{{"role", Text(atspi_accessible_get_role_name(element.get(), nullptr))},
|
||||||
|
{"name", secure ? std::string{} : Text(atspi_accessible_get_name(element.get(), nullptr))},
|
||||||
|
{"pid", atspi_accessible_get_process_id(element.get(), nullptr)}, {"parent", entry.parent}, {"secure", secure}};
|
||||||
|
auto state = Own(atspi_accessible_get_state_set(element.get()));
|
||||||
|
if (state) {
|
||||||
|
data["foreground"] = bool(atspi_state_set_contains(state.get(), ATSPI_STATE_ACTIVE));
|
||||||
|
data["focused"] = bool(atspi_state_set_contains(state.get(), ATSPI_STATE_FOCUSED));
|
||||||
|
}
|
||||||
|
auto component = Own(atspi_accessible_get_component_iface(element.get()));
|
||||||
|
if (component && state && atspi_state_set_contains(state.get(), ATSPI_STATE_SHOWING)) {
|
||||||
|
const auto rect = atspi_component_get_extents(component.get(), ATSPI_COORD_TYPE_SCREEN, nullptr);
|
||||||
|
if (rect) {
|
||||||
|
data["bounds"] = {{"x", rect->x}, {"y", rect->y}, {"width", rect->width}, {"height", rect->height}};
|
||||||
|
g_free(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!secure) {
|
||||||
|
auto text = Own(atspi_accessible_get_text_iface(element.get()));
|
||||||
|
if (text) {
|
||||||
|
const int count = std::clamp(atspi_text_get_character_count(text.get(), nullptr), 0, 256);
|
||||||
|
if (count) data["value"] = Text(atspi_text_get_text(text.get(), 0, count, nullptr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int parent = int(result.items.size());
|
||||||
|
result.items.push_back({data,
|
||||||
|
[element](const auto& operation, const auto& value) { return Perform(element, operation, value); },
|
||||||
|
[element](int max, int levels) { return Tree(element, max, levels); }});
|
||||||
|
const int count = std::max(0, atspi_accessible_get_child_count(element.get(), nullptr));
|
||||||
|
if (entry.depth >= depth) { result.truncated |= count > 0; continue; }
|
||||||
|
const int available = std::max(0, limit - int(result.items.size() + queue.size()));
|
||||||
|
result.truncated |= count > available;
|
||||||
|
for (int index = 0; index < std::min(count, available) && std::chrono::steady_clock::now() < deadline; ++index) {
|
||||||
|
auto child = Own(atspi_accessible_get_child_at_index(element.get(), index, nullptr));
|
||||||
|
if (child) queue.push_back({child, entry.depth + 1, parent});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.truncated |= !queue.empty();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} // namespace
|
||||||
|
nlohmann::json DesktopCapabilities() {
|
||||||
|
bool accessibility = false;
|
||||||
|
#ifdef CROSSDESK_HAS_ATSPI
|
||||||
|
try { accessibility = bool(Desktop()); } catch (...) {}
|
||||||
|
#endif
|
||||||
|
return {{"available", true}, {"backend", accessibility ? "linux-atspi" : "linux-x11"},
|
||||||
|
{"tree", accessibility}, {"windows", accessibility || std::getenv("DISPLAY") != nullptr},
|
||||||
|
{"launch", true}, {"open_url", access("/usr/bin/xdg-open", X_OK) == 0}};
|
||||||
|
}
|
||||||
|
DesktopSnapshot DesktopWindows() {
|
||||||
|
#ifdef CROSSDESK_HAS_ATSPI
|
||||||
|
try { return Tree(Desktop(), 128, 2); }
|
||||||
|
catch (const std::runtime_error&) { /* X11 remains usable when the accessibility bus is unavailable. */ }
|
||||||
|
#endif
|
||||||
|
XConnection display;
|
||||||
|
const auto windows = display.Property(display.root, "_NET_CLIENT_LIST_STACKING", XCB_ATOM_WINDOW);
|
||||||
|
const auto active = display.Property(display.root, "_NET_ACTIVE_WINDOW", XCB_ATOM_WINDOW);
|
||||||
|
DesktopSnapshot result;
|
||||||
|
for (const auto window : windows) {
|
||||||
|
const auto pid = display.Property(window, "_NET_WM_PID", XCB_ATOM_CARDINAL);
|
||||||
|
json data{{"role", "window"}, {"name", display.Title(window)},
|
||||||
|
{"pid", pid.empty() ? 0 : pid.front()}, {"foreground", !active.empty() && active.front() == window}};
|
||||||
|
result.items.push_back({data, [window, pid](const std::string& operation, const std::string&) -> json {
|
||||||
|
if (operation != "activate" && operation != "focus") throw std::runtime_error("UNSUPPORTED_OPERATION");
|
||||||
|
XConnection display;
|
||||||
|
const auto windows = display.Property(display.root, "_NET_CLIENT_LIST_STACKING", XCB_ATOM_WINDOW);
|
||||||
|
if (std::find(windows.begin(), windows.end(), window) == windows.end() ||
|
||||||
|
display.Property(window, "_NET_WM_PID", XCB_ATOM_CARDINAL) != pid) throw std::runtime_error("STALE_ELEMENT");
|
||||||
|
xcb_client_message_event_t event{};
|
||||||
|
event.response_type = XCB_CLIENT_MESSAGE;
|
||||||
|
event.window = window;
|
||||||
|
event.type = display.Atom("_NET_ACTIVE_WINDOW");
|
||||||
|
event.format = 32;
|
||||||
|
event.data.data32[0] = 2;
|
||||||
|
event.data.data32[1] = XCB_CURRENT_TIME;
|
||||||
|
const auto cookie = xcb_send_event_checked(display.connection.get(), 0, display.root,
|
||||||
|
XCB_EVENT_MASK_SUBSTRUCTURE_REDIRECT | XCB_EVENT_MASK_SUBSTRUCTURE_NOTIFY, reinterpret_cast<const char*>(&event));
|
||||||
|
const auto error = xcb_request_check(display.connection.get(), cookie);
|
||||||
|
const bool accepted = !error;
|
||||||
|
std::free(error);
|
||||||
|
return {{"accepted", accepted}, {"state", "requested"}};
|
||||||
|
}, {}});
|
||||||
|
if (result.items.size() >= 128) { result.truncated = true; break; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
DesktopSnapshot DesktopTree(int pid, int limit, int depth) {
|
||||||
|
#ifdef CROSSDESK_HAS_ATSPI
|
||||||
|
auto desktop = Desktop();
|
||||||
|
if (!pid) return Tree(desktop, limit, depth);
|
||||||
|
const int count = std::min(256, atspi_accessible_get_child_count(desktop.get(), nullptr));
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1200);
|
||||||
|
for (int index = 0; index < count; ++index) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) throw std::runtime_error("UI_QUERY_TIMEOUT");
|
||||||
|
auto app = Own(atspi_accessible_get_child_at_index(desktop.get(), index, nullptr));
|
||||||
|
if (app && atspi_accessible_get_process_id(app.get(), nullptr) == unsigned(pid)) return Tree(app, limit, depth);
|
||||||
|
}
|
||||||
|
throw std::runtime_error("APPLICATION_UNAVAILABLE");
|
||||||
|
#else
|
||||||
|
(void)pid; (void)limit; (void)depth;
|
||||||
|
throw std::runtime_error("ATSPI_NOT_BUILT");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
nlohmann::json DesktopLaunch(const std::string& application) {
|
||||||
|
if (application.empty() || application.front() != '/' || access(application.c_str(), X_OK) != 0)
|
||||||
|
throw std::invalid_argument("APPLICATION_UNAVAILABLE");
|
||||||
|
return Spawn(application);
|
||||||
|
}
|
||||||
|
nlohmann::json DesktopOpenUrl(const std::string& url) { return Spawn("/usr/bin/xdg-open", url); }
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include "config_center.h"
|
#include "config_center.h"
|
||||||
#include "signal_server_address.h"
|
#include <minirtc.h>
|
||||||
#include "signal_server_settings.h"
|
#include "signal_server_settings.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
@@ -88,7 +88,8 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
|||||||
canonical = value;
|
canonical = value;
|
||||||
save = [&config, mode] { return config.SetTurnMode(static_cast<ConfigCenter::TURN_MODE>(mode)); };
|
save = [&config, mode] { return config.SetTurnMode(static_cast<ConfigCenter::TURN_MODE>(mode)); };
|
||||||
} else if (key == "server_host") {
|
} else if (key == "server_host") {
|
||||||
if (!minirtc::ParseSignalServerAddress(input)) return result;
|
MiniRtcSignalServerAddress address{};
|
||||||
|
if (MiniRtcParseSignalServerAddress(input.data(), input.size(), &address) != 0) return result;
|
||||||
save = [&] { return config.SetServerHost(input); };
|
save = [&] { return config.SetServerHost(input); };
|
||||||
} else if (key == "server_port") {
|
} else if (key == "server_port") {
|
||||||
int port = 0;
|
int port = 0;
|
||||||
|
|||||||
@@ -7,11 +7,54 @@
|
|||||||
#include "screen_lock.h"
|
#include "screen_lock.h"
|
||||||
|
|
||||||
#include <dbus/dbus.h>
|
#include <dbus/dbus.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
|
ScreenLockState QueryScreenLock() {
|
||||||
|
dbus_threads_init_default();
|
||||||
|
DBusConnection* connection = dbus_bus_get_private(DBUS_BUS_SYSTEM, nullptr);
|
||||||
|
if (!connection) return ScreenLockState::unknown;
|
||||||
|
dbus_connection_set_exit_on_disconnect(connection, false);
|
||||||
|
ScreenLockState state = ScreenLockState::unknown;
|
||||||
|
DBusMessage* query = dbus_message_new_method_call("org.freedesktop.login1",
|
||||||
|
"/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetSessionByPID");
|
||||||
|
if (query) {
|
||||||
|
dbus_uint32_t pid = getpid();
|
||||||
|
dbus_message_append_args(query, DBUS_TYPE_UINT32, &pid, DBUS_TYPE_INVALID);
|
||||||
|
DBusMessage* session = dbus_connection_send_with_reply_and_block(connection, query, 500, nullptr);
|
||||||
|
dbus_message_unref(query);
|
||||||
|
const char* path = nullptr;
|
||||||
|
if (session && dbus_message_get_args(session, nullptr, DBUS_TYPE_OBJECT_PATH, &path, DBUS_TYPE_INVALID)) {
|
||||||
|
query = dbus_message_new_method_call("org.freedesktop.login1", path,
|
||||||
|
"org.freedesktop.DBus.Properties", "Get");
|
||||||
|
const char* interface = "org.freedesktop.login1.Session";
|
||||||
|
const char* property = "LockedHint";
|
||||||
|
if (query) {
|
||||||
|
dbus_message_append_args(query, DBUS_TYPE_STRING, &interface, DBUS_TYPE_STRING, &property, DBUS_TYPE_INVALID);
|
||||||
|
DBusMessage* reply = dbus_connection_send_with_reply_and_block(connection, query, 500, nullptr);
|
||||||
|
dbus_message_unref(query);
|
||||||
|
DBusMessageIter outer, value;
|
||||||
|
if (reply && dbus_message_iter_init(reply, &outer) && dbus_message_iter_get_arg_type(&outer) == DBUS_TYPE_VARIANT) {
|
||||||
|
dbus_message_iter_recurse(&outer, &value);
|
||||||
|
if (dbus_message_iter_get_arg_type(&value) == DBUS_TYPE_BOOLEAN) {
|
||||||
|
dbus_bool_t locked = false;
|
||||||
|
dbus_message_iter_get_basic(&value, &locked);
|
||||||
|
state = locked ? ScreenLockState::locked : ScreenLockState::unlocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reply) dbus_message_unref(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session) dbus_message_unref(session);
|
||||||
|
}
|
||||||
|
dbus_connection_close(connection);
|
||||||
|
dbus_connection_unref(connection);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
bool LockScreen() {
|
bool LockScreen() {
|
||||||
dbus_threads_init_default();
|
dbus_threads_init_default();
|
||||||
DBusError error;
|
DBusError error;
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#include "desktop_automation.h"
|
||||||
|
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#include <ApplicationServices/ApplicationServices.h>
|
||||||
|
#include <deque>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
using nlohmann::json;
|
||||||
|
using Element = std::shared_ptr<const __AXUIElement>;
|
||||||
|
Element Own(AXUIElementRef element) { return Element(element, [](auto value) { if (value) CFRelease(value); }); }
|
||||||
|
CFTypeRef Attribute(AXUIElementRef element, CFStringRef name) {
|
||||||
|
CFTypeRef value = nullptr;
|
||||||
|
AXUIElementCopyAttributeValue(element, name, &value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
std::string String(CFTypeRef value) {
|
||||||
|
if (!value || CFGetTypeID(value) != CFStringGetTypeID()) return {};
|
||||||
|
char bytes[2048]{};
|
||||||
|
CFIndex used = 0;
|
||||||
|
const auto count = CFStringGetBytes(static_cast<CFStringRef>(value),
|
||||||
|
CFRangeMake(0, std::min<CFIndex>(CFStringGetLength(static_cast<CFStringRef>(value)), 256)),
|
||||||
|
kCFStringEncodingUTF8, 0, false, reinterpret_cast<UInt8*>(bytes), sizeof(bytes) - 1, &used);
|
||||||
|
(void)count;
|
||||||
|
return DesktopText(std::string(bytes, size_t(used)));
|
||||||
|
}
|
||||||
|
std::string Text(AXUIElementRef element, CFStringRef name) {
|
||||||
|
const auto value = Attribute(element, name);
|
||||||
|
auto result = String(value);
|
||||||
|
if (value) CFRelease(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
void Accessible() { if (!AXIsProcessTrusted()) throw std::runtime_error("ACCESSIBILITY_PERMISSION_REQUIRED"); }
|
||||||
|
json Perform(Element element, const std::string& operation, const std::string& value) {
|
||||||
|
Accessible();
|
||||||
|
if (Text(element.get(), kAXSubroleAttribute) == "AXSecureTextField") throw std::runtime_error("SECURE_ELEMENT");
|
||||||
|
AXError error = kAXErrorActionUnsupported;
|
||||||
|
if (operation == "press") error = AXUIElementPerformAction(element.get(), kAXPressAction);
|
||||||
|
else if (operation == "activate") {
|
||||||
|
pid_t pid = 0;
|
||||||
|
AXUIElementGetPid(element.get(), &pid);
|
||||||
|
[[NSRunningApplication runningApplicationWithProcessIdentifier:pid] activateWithOptions:0];
|
||||||
|
error = AXUIElementPerformAction(element.get(), kAXRaiseAction);
|
||||||
|
} else if (operation == "focus") error = AXUIElementSetAttributeValue(element.get(), kAXFocusedAttribute, kCFBooleanTrue);
|
||||||
|
else if (operation == "set_value") {
|
||||||
|
const auto text = CFStringCreateWithBytes(nullptr, reinterpret_cast<const UInt8*>(value.data()), value.size(), kCFStringEncodingUTF8, false);
|
||||||
|
if (!text) throw std::invalid_argument("INVALID_ARGUMENT");
|
||||||
|
error = AXUIElementSetAttributeValue(element.get(), kAXValueAttribute, text);
|
||||||
|
CFRelease(text);
|
||||||
|
}
|
||||||
|
if (error != kAXErrorSuccess) throw std::runtime_error(error == kAXErrorInvalidUIElement ? "STALE_ELEMENT" : "UI_ACTION_FAILED");
|
||||||
|
return {{"accepted", true}};
|
||||||
|
}
|
||||||
|
DesktopSnapshot Tree(Element root, int limit, int depth) {
|
||||||
|
Accessible();
|
||||||
|
struct Entry { Element element; int depth, parent; };
|
||||||
|
std::deque<Entry> queue{{root, 0, -1}};
|
||||||
|
DesktopSnapshot result;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1200);
|
||||||
|
while (!queue.empty() && int(result.items.size()) < limit && std::chrono::steady_clock::now() < deadline) {
|
||||||
|
auto entry = std::move(queue.front()); queue.pop_front();
|
||||||
|
const auto element = entry.element.get();
|
||||||
|
AXUIElementSetMessagingTimeout(element, .1f);
|
||||||
|
const auto role = Text(element, kAXRoleAttribute);
|
||||||
|
if (role.empty()) continue;
|
||||||
|
const bool secure = Text(element, kAXSubroleAttribute) == "AXSecureTextField";
|
||||||
|
json data{{"role", role}, {"name", Text(element, kAXTitleAttribute)}, {"parent", entry.parent}, {"secure", secure}};
|
||||||
|
if (!secure) data["value"] = Text(element, kAXValueAttribute);
|
||||||
|
CGPoint point{}; CGSize size{};
|
||||||
|
auto position = Attribute(element, kAXPositionAttribute), extent = Attribute(element, kAXSizeAttribute);
|
||||||
|
if (position && extent && CFGetTypeID(position) == AXValueGetTypeID() && CFGetTypeID(extent) == AXValueGetTypeID() &&
|
||||||
|
AXValueGetValue(static_cast<AXValueRef>(position), kAXValueTypeCGPoint, &point) &&
|
||||||
|
AXValueGetValue(static_cast<AXValueRef>(extent), kAXValueTypeCGSize, &size))
|
||||||
|
data["bounds"] = {{"x", point.x}, {"y", point.y}, {"width", size.width}, {"height", size.height}};
|
||||||
|
if (position) CFRelease(position);
|
||||||
|
if (extent) CFRelease(extent);
|
||||||
|
const int index = int(result.items.size());
|
||||||
|
result.items.push_back({data,
|
||||||
|
[element = entry.element](const auto& operation, const auto& value) { return Perform(element, operation, value); },
|
||||||
|
[element = entry.element](int max, int levels) { return Tree(element, max, levels); }});
|
||||||
|
CFIndex count = 0;
|
||||||
|
if (AXUIElementGetAttributeValueCount(element, kAXChildrenAttribute, &count) != kAXErrorSuccess || count <= 0) continue;
|
||||||
|
if (entry.depth >= depth) { result.truncated = true; continue; }
|
||||||
|
const auto available = std::max(0, limit - int(result.items.size()) - int(queue.size()));
|
||||||
|
if (count > available) result.truncated = true;
|
||||||
|
CFArrayRef children = nullptr;
|
||||||
|
if (available && AXUIElementCopyAttributeValues(element, kAXChildrenAttribute, 0, std::min<CFIndex>(count, available), &children) == kAXErrorSuccess) {
|
||||||
|
for (CFIndex index = 0; index < CFArrayGetCount(children); ++index) {
|
||||||
|
const auto child = static_cast<AXUIElementRef>(CFArrayGetValueAtIndex(children, index));
|
||||||
|
if (CFGetTypeID(child) == AXUIElementGetTypeID()) {
|
||||||
|
CFRetain(child); queue.push_back({Own(child), entry.depth + 1, int(result.items.size()) - 1});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CFRelease(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.truncated |= !queue.empty();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
nlohmann::json DesktopCapabilities() {
|
||||||
|
return {{"available", true}, {"backend", "macos-ax"}, {"accessibility_permission", bool(AXIsProcessTrusted())},
|
||||||
|
{"windows", bool(AXIsProcessTrusted())}, {"tree", bool(AXIsProcessTrusted())}, {"launch", true}, {"open_url", true}};
|
||||||
|
}
|
||||||
|
DesktopSnapshot DesktopTree(int pid, int limit, int depth) {
|
||||||
|
@autoreleasepool {
|
||||||
|
if (!pid) pid = NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier;
|
||||||
|
if (pid <= 0) throw std::runtime_error("APPLICATION_UNAVAILABLE");
|
||||||
|
return Tree(Own(AXUIElementCreateApplication(pid)), limit, depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesktopSnapshot DesktopWindows() {
|
||||||
|
@autoreleasepool {
|
||||||
|
DesktopSnapshot result;
|
||||||
|
const auto front = NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier;
|
||||||
|
// AX window references provide exact activation targets when permission is present.
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1200);
|
||||||
|
Accessible();
|
||||||
|
for (NSRunningApplication* app in NSWorkspace.sharedWorkspace.runningApplications) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) { result.truncated = true; break; }
|
||||||
|
if (app.activationPolicy != NSApplicationActivationPolicyRegular) continue;
|
||||||
|
auto application = Own(AXUIElementCreateApplication(app.processIdentifier));
|
||||||
|
AXUIElementSetMessagingTimeout(application.get(), .1f);
|
||||||
|
const auto raw = Attribute(application.get(), kAXWindowsAttribute);
|
||||||
|
if (raw && CFGetTypeID(raw) == CFArrayGetTypeID()) {
|
||||||
|
const auto windows = static_cast<CFArrayRef>(raw);
|
||||||
|
for (CFIndex index = 0; index < CFArrayGetCount(windows) && result.items.size() < 128; ++index) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) { result.truncated = true; break; }
|
||||||
|
const auto window = static_cast<AXUIElementRef>(CFArrayGetValueAtIndex(windows, index));
|
||||||
|
if (CFGetTypeID(window) != AXUIElementGetTypeID()) continue;
|
||||||
|
CFRetain(window); const auto element = Own(window);
|
||||||
|
const auto main = Attribute(window, kAXMainAttribute);
|
||||||
|
const bool foreground = app.processIdentifier == front && main == kCFBooleanTrue;
|
||||||
|
if (main) CFRelease(main);
|
||||||
|
result.items.push_back({{{"role", "window"}, {"name", Text(window, kAXTitleAttribute)},
|
||||||
|
{"application", DesktopText(app.localizedName.UTF8String ?: "")}, {"pid", app.processIdentifier}, {"foreground", foreground}, {"foreground_application", app.processIdentifier == front}},
|
||||||
|
[element](const auto& operation, const auto& value) { return Perform(element, operation, value); },
|
||||||
|
[element](int max, int levels) { return Tree(element, max, levels); }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw) CFRelease(raw);
|
||||||
|
if (result.items.size() >= 128) { result.truncated = true; break; }
|
||||||
|
}
|
||||||
|
if (!AXIsProcessTrusted()) throw std::runtime_error("ACCESSIBILITY_PERMISSION_REQUIRED");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nlohmann::json DesktopLaunch(const std::string& application) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSString* value = [NSString stringWithUTF8String:application.c_str()];
|
||||||
|
NSURL* url = [value hasPrefix:@"/"] ? [NSURL fileURLWithPath:value] :
|
||||||
|
[NSWorkspace.sharedWorkspace URLForApplicationWithBundleIdentifier:value];
|
||||||
|
if (!url || ![url.path.pathExtension.lowercaseString isEqualToString:@"app"])
|
||||||
|
throw std::runtime_error("APPLICATION_UNAVAILABLE");
|
||||||
|
[NSWorkspace.sharedWorkspace openApplicationAtURL:url configuration:NSWorkspaceOpenConfiguration.configuration completionHandler:nil];
|
||||||
|
return {{"accepted", true}, {"state", "requested"}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nlohmann::json DesktopOpenUrl(const std::string& value) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSURL* url = [NSURL URLWithString:[NSString stringWithUTF8String:value.c_str()]];
|
||||||
|
if (!url || ![NSWorkspace.sharedWorkspace openURL:url]) throw std::runtime_error("UI_ACTION_FAILED");
|
||||||
|
return {{"accepted", true}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-07
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _MAC_PERMISSION_MIGRATION_H_
|
||||||
|
#define _MAC_PERMISSION_MIGRATION_H_
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
struct MacPermissionMigrationResult {
|
||||||
|
bool needs_repair = false;
|
||||||
|
std::string detail;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs on a worker before the new app requests missing permissions. Explicit
|
||||||
|
// retries may resume failed/interrupted services, while completed ones persist.
|
||||||
|
MacPermissionMigrationResult MigrateMacPermissions(bool retry);
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#include "mac_permission_migration.h"
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#include <ApplicationServices/ApplicationServices.h>
|
||||||
|
#include <CoreGraphics/CoreGraphics.h>
|
||||||
|
#include <Security/Security.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "private_file.h"
|
||||||
|
#include "platform/single_instance.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
using nlohmann::json;
|
||||||
|
constexpr const char* kMigrationFile =
|
||||||
|
"/Library/Application Support/CrossDesk/permission-migration.json";
|
||||||
|
|
||||||
|
bool IsDeveloperIdProcess() {
|
||||||
|
SecCodeRef code = nullptr;
|
||||||
|
SecRequirementRef requirement = nullptr;
|
||||||
|
const auto compiled = SecRequirementCreateWithString(
|
||||||
|
CFSTR("anchor apple generic and identifier \"cn.crossdesk.app\" and "
|
||||||
|
"certificate 1[field.1.2.840.113635.100.6.2.6] exists and "
|
||||||
|
"certificate leaf[field.1.2.840.113635.100.6.1.13] exists"),
|
||||||
|
kSecCSDefaultFlags, &requirement);
|
||||||
|
const bool valid = compiled == errSecSuccess &&
|
||||||
|
SecCodeCopySelf(kSecCSDefaultFlags, &code) == errSecSuccess &&
|
||||||
|
SecCodeCheckValidity(code, kSecCSDefaultFlags, requirement) == errSecSuccess;
|
||||||
|
if (code) CFRelease(code);
|
||||||
|
if (requirement) CFRelease(requirement);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool KnownBundleId(const std::string& value) {
|
||||||
|
return value == "cn.crossdesk.app" || value == "cn.crossdesk.CrossDesk" ||
|
||||||
|
value == "com.crossdesk.app" || value == "com.crossdesk.CrossDesk" ||
|
||||||
|
value == "com.kunkundi.crossdesk" || value == "com.kunkundi.CrossDesk";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PermissionGranted(const std::string& service) {
|
||||||
|
if (service == "Accessibility") return AXIsProcessTrusted();
|
||||||
|
return CGPreflightScreenCaptureAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ResetPermission(const std::string& service, const std::string& bundle_id) {
|
||||||
|
NSTask* task = [[NSTask alloc] init];
|
||||||
|
task.executableURL = [NSURL fileURLWithPath:@"/usr/bin/tccutil"];
|
||||||
|
task.arguments = @[@"reset", @(service.c_str()), @(bundle_id.c_str())];
|
||||||
|
task.standardOutput = [NSFileHandle fileHandleWithNullDevice];
|
||||||
|
task.standardError = [NSFileHandle fileHandleWithNullDevice];
|
||||||
|
NSError* error = nil;
|
||||||
|
if (![task launchAndReturnError:&error]) return false;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||||
|
while (task.running && std::chrono::steady_clock::now() < deadline) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||||
|
}
|
||||||
|
if (task.running) {
|
||||||
|
kill(task.processIdentifier, SIGKILL);
|
||||||
|
[task waitUntilExit];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
[task waitUntilExit];
|
||||||
|
return task.terminationReason == NSTaskTerminationReasonExit &&
|
||||||
|
task.terminationStatus == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
MacPermissionMigrationResult Migrate(bool retry) {
|
||||||
|
if (!IsDeveloperIdProcess()) return {};
|
||||||
|
struct stat info {};
|
||||||
|
if (lstat(kMigrationFile, &info) != 0) {
|
||||||
|
if (errno == ENOENT) return {};
|
||||||
|
throw std::runtime_error("Cannot read the permission migration marker");
|
||||||
|
}
|
||||||
|
if (!S_ISREG(info.st_mode) || info.st_uid != 0 ||
|
||||||
|
(info.st_mode & 0022) != 0 || info.st_size > 16384) {
|
||||||
|
throw std::runtime_error("Invalid permission migration marker ownership or size");
|
||||||
|
}
|
||||||
|
std::ifstream input(kMigrationFile);
|
||||||
|
const json migration = json::parse(input);
|
||||||
|
const std::string generation = migration.at("migration_id");
|
||||||
|
const std::string bundle_id = migration.at("legacy_bundle_id");
|
||||||
|
const std::string app_path = migration.at("app_path");
|
||||||
|
NSUUID* uuid = [[NSUUID alloc] initWithUUIDString:@(generation.c_str())];
|
||||||
|
if (migration.at("schema") != 1 || !uuid || !KnownBundleId(bundle_id)) {
|
||||||
|
throw std::runtime_error("Invalid permission migration marker contents");
|
||||||
|
}
|
||||||
|
if (![NSBundle.mainBundle.bundlePath isEqualToString:@(app_path.c_str())]) return {};
|
||||||
|
if (geteuid() == 0) {
|
||||||
|
throw std::runtime_error("Run CrossDesk as the logged-in user to migrate permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
platform::SingleInstanceGuard guard("CrossDeskPermissionMigration");
|
||||||
|
std::string lock_error;
|
||||||
|
if (guard.TryAcquire(platform::InstanceRole::kClient, lock_error) !=
|
||||||
|
platform::InstanceResult::kAcquired) {
|
||||||
|
throw std::runtime_error("Permission migration is already running or its lock is unavailable");
|
||||||
|
}
|
||||||
|
NSURL* support = [NSFileManager.defaultManager
|
||||||
|
URLForDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask
|
||||||
|
appropriateForURL:nil create:YES error:nullptr];
|
||||||
|
if (!support) throw std::runtime_error("Cannot find the user's application support directory");
|
||||||
|
const std::filesystem::path directory =
|
||||||
|
std::filesystem::path(support.fileSystemRepresentation) / "CrossDesk" / "PermissionMigration";
|
||||||
|
private_file::EnsureDirectory(directory, true);
|
||||||
|
const auto state_file = directory / "progress.json";
|
||||||
|
json state = {{"schema", 1}, {"migration_id", generation}, {"services", json::object()}};
|
||||||
|
if (std::filesystem::exists(state_file)) {
|
||||||
|
state = json::parse(private_file::Read(state_file));
|
||||||
|
if (state.at("schema") != 1 || !state.at("services").is_object()) {
|
||||||
|
throw std::runtime_error("Invalid per-user permission migration progress");
|
||||||
|
}
|
||||||
|
if (state.at("migration_id") != generation) {
|
||||||
|
state = {{"schema", 1}, {"migration_id", generation}, {"services", json::object()}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MacPermissionMigrationResult result;
|
||||||
|
for (const std::string service : {"Accessibility", "ScreenCapture"}) {
|
||||||
|
const std::string progress = state["services"].value(service, "pending");
|
||||||
|
if (progress == "done") continue;
|
||||||
|
if (PermissionGranted(service)) {
|
||||||
|
// The user may have fixed this manually after a failed/interrupted reset.
|
||||||
|
state["services"][service] = "done";
|
||||||
|
private_file::Write(state_file, state.dump());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!retry && progress != "pending") {
|
||||||
|
result.needs_repair = true;
|
||||||
|
result.detail += service + " needs a manual retry. ";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Persist intent before the external operation. An interrupted attempt is
|
||||||
|
// offered for explicit retry, never silently repeated at the next launch.
|
||||||
|
state["services"][service] = "attempted";
|
||||||
|
private_file::Write(state_file, state.dump());
|
||||||
|
if (ResetPermission(service, bundle_id)) {
|
||||||
|
state["services"][service] = "done";
|
||||||
|
} else {
|
||||||
|
state["services"][service] = "failed";
|
||||||
|
result.needs_repair = true;
|
||||||
|
result.detail += service + " reset failed. ";
|
||||||
|
}
|
||||||
|
private_file::Write(state_file, state.dump());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
MacPermissionMigrationResult MigrateMacPermissions(bool retry) {
|
||||||
|
@autoreleasepool {
|
||||||
|
@try {
|
||||||
|
try {
|
||||||
|
return Migrate(retry);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
return {true, error.what()};
|
||||||
|
}
|
||||||
|
} @catch (NSException* error) {
|
||||||
|
return {true, error.reason.UTF8String ?: "Permission migration failed"};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -28,6 +28,18 @@ void OpenPrivacyPreferences(const char *pane) {
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void GuiRuntime::StartMacPermissionMigration(bool retry) {
|
||||||
|
if (mac_permission_migration_task_.valid()) return;
|
||||||
|
mac_permission_migration_failed_ = false;
|
||||||
|
try {
|
||||||
|
mac_permission_migration_task_ = std::async(
|
||||||
|
std::launch::async, [retry] { return MigrateMacPermissions(retry); });
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
mac_permission_migration_failed_ = true;
|
||||||
|
LOG_ERROR("Could not start macOS permission migration: {}", error.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool GuiRuntime::CheckScreenRecordingPermission() {
|
bool GuiRuntime::CheckScreenRecordingPermission() {
|
||||||
// CGPreflightScreenCaptureAccess is available on macOS 10.15+
|
// CGPreflightScreenCaptureAccess is available on macOS 10.15+
|
||||||
if (@available(macOS 10.15, *)) {
|
if (@available(macOS 10.15, *)) {
|
||||||
@@ -46,6 +58,7 @@ bool GuiRuntime::CheckAccessibilityPermission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::OpenAccessibilityPreferences() {
|
void GuiRuntime::OpenAccessibilityPreferences() {
|
||||||
|
if (mac_permission_migration_task_.valid()) return;
|
||||||
if (!mac_accessibility_permission_requested_) {
|
if (!mac_accessibility_permission_requested_) {
|
||||||
NSDictionary *options = @{(__bridge id)kAXTrustedCheckOptionPrompt : @YES};
|
NSDictionary *options = @{(__bridge id)kAXTrustedCheckOptionPrompt : @YES};
|
||||||
AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options);
|
AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options);
|
||||||
@@ -55,6 +68,7 @@ void GuiRuntime::OpenAccessibilityPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::OpenScreenRecordingPreferences() {
|
void GuiRuntime::OpenScreenRecordingPreferences() {
|
||||||
|
if (mac_permission_migration_task_.valid()) return;
|
||||||
if (@available(macOS 10.15, *)) {
|
if (@available(macOS 10.15, *)) {
|
||||||
if (!mac_screen_recording_permission_requested_) {
|
if (!mac_screen_recording_permission_requested_) {
|
||||||
CGRequestScreenCaptureAccess();
|
CGRequestScreenCaptureAccess();
|
||||||
@@ -67,6 +81,19 @@ void GuiRuntime::OpenScreenRecordingPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::RefreshMacPermissionStatus(bool force) {
|
void GuiRuntime::RefreshMacPermissionStatus(bool force) {
|
||||||
|
if (mac_permission_migration_task_.valid() &&
|
||||||
|
mac_permission_migration_task_.wait_for(std::chrono::seconds(0)) ==
|
||||||
|
std::future_status::ready) {
|
||||||
|
try {
|
||||||
|
const auto result = mac_permission_migration_task_.get();
|
||||||
|
mac_permission_migration_failed_ = result.needs_repair;
|
||||||
|
if (result.needs_repair) LOG_WARN("macOS permission migration: {}", result.detail);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
mac_permission_migration_failed_ = true;
|
||||||
|
LOG_ERROR("macOS permission migration failed: {}", error.what());
|
||||||
|
}
|
||||||
|
force = true;
|
||||||
|
}
|
||||||
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
|
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
|
||||||
if (!force && mac_permission_status_initialized_ &&
|
if (!force && mac_permission_status_initialized_ &&
|
||||||
now - mac_permission_last_check_tick_ <
|
now - mac_permission_last_check_tick_ <
|
||||||
@@ -98,7 +125,7 @@ bool GuiRuntime::EnsureMacScreenRecordingPermission() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
show_request_permission_window_ = true;
|
show_request_permission_window_ = !mac_permission_dialog_dismissed_;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +135,7 @@ bool GuiRuntime::EnsureMacAccessibilityPermission() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
show_request_permission_window_ = true;
|
show_request_permission_window_ = !mac_permission_dialog_dismissed_;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user