mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-07-03 13:54:53 +03:00
Compare commits
105 Commits
keyboard-s
...
1.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 | ||
|
|
cd2fff0655 | ||
|
|
10f61ffdc2 | ||
|
|
d72952bf93 | ||
|
|
a7c55db9ac | ||
|
|
bff47e2b81 | ||
|
|
3d478c4935 | ||
|
|
a658e987b7 | ||
|
|
7c8b0adc1e | ||
|
|
a732ebc3e1 | ||
|
|
30c0867e40 | ||
|
|
8f50ea64dc | ||
|
|
0797ebb695 | ||
|
|
c9391fb894 | ||
|
|
8a955888bf | ||
|
|
36e812e550 | ||
|
|
8baa995c7a | ||
|
|
f4a0535289 | ||
|
|
6665242edf | ||
|
|
3cdf1cce54 | ||
|
|
88ae00ba73 | ||
|
|
7c26575dbd | ||
|
|
93d064a9b0 | ||
|
|
bf206dc309 | ||
|
|
6d116cf1c9 | ||
|
|
1fc33218dc | ||
|
|
b73e5bbfa0 | ||
|
|
78533e428e | ||
|
|
cc7fe4efdc | ||
|
|
84af60c07e | ||
|
|
6426269d41 | ||
|
|
7c41f993fe | ||
|
|
0c86d46162 | ||
|
|
e87797418f | ||
|
|
78a3a2aeb9 | ||
|
|
e18cf7a245 | ||
|
|
50d5823ef5 | ||
|
|
518296f257 | ||
|
|
3217125dd3 | ||
|
|
00032854eb | ||
|
|
d99ddf6816 | ||
|
|
32c6e32e04 | ||
|
|
c55b1f3359 | ||
|
|
fabeae4180 | ||
|
|
5eed50961d | ||
|
|
bed0976eb9 | ||
|
|
70d92d9b07 | ||
|
|
fb4ba31504 | ||
|
|
152c5c71b1 | ||
|
|
fa369365a5 | ||
|
|
7345366ba7 | ||
|
|
6151ea7128 | ||
|
|
440ab26b69 | ||
|
|
caadd72ab2 | ||
|
|
d59d543ec1 | ||
|
|
58d1109510 | ||
|
|
9c52e25a6a | ||
|
|
62a44c5a09 | ||
|
|
e5fa40e903 | ||
|
|
8177083992 | ||
|
|
4bfd8e9f61 | ||
|
|
81e7d27ec8 | ||
|
|
f3fc0b5ac2 | ||
|
|
fb7bca436b | ||
|
|
c19a0ceba2 | ||
|
|
1f26e452fc | ||
|
|
0af6b7ede9 | ||
|
|
6ad56075d6 | ||
|
|
b81ae6c894 | ||
|
|
546e9f1702 | ||
|
|
bb51c6aa42 | ||
|
|
78e8134ad5 | ||
|
|
bc2c36215d | ||
|
|
377547fa11 | ||
|
|
472c4fc03a | ||
|
|
9f8f726f12 | ||
|
|
701a9c6cdc | ||
|
|
0d40cf2101 | ||
|
|
dd265dadd7 | ||
|
|
fe5a8cb2ad | ||
|
|
b6caa1a7b2 | ||
|
|
55c9707639 | ||
|
|
d8808baa83 | ||
|
|
1978020d27 | ||
|
|
0e4b91b8d7 | ||
|
|
9c831dc59b | ||
|
|
b757e97c11 | ||
|
|
9df486a689 | ||
|
|
72d27c3c47 | ||
|
|
6c20fc936d | ||
|
|
5439ec38b6 | ||
|
|
8b8a64f870 | ||
|
|
92509f8e8a | ||
|
|
0221634a4d | ||
|
|
9d1f86fbc6 | ||
|
|
f29dec7b13 | ||
|
|
d5d0b01266 | ||
|
|
5abae617dc | ||
|
|
52d62da002 | ||
|
|
253d632709 | ||
|
|
383a5c3478 |
@@ -2,6 +2,8 @@
|
|||||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||||
[target.i686-pc-windows-msvc]
|
[target.i686-pc-windows-msvc]
|
||||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
||||||
|
[target.aarch64-pc-windows-msvc]
|
||||||
|
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||||
[target.'cfg(target_os="macos")']
|
[target.'cfg(target_os="macos")']
|
||||||
rustflags = [
|
rustflags = [
|
||||||
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
||||||
|
|||||||
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
|
||||||
|
#
|
||||||
|
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
|
||||||
|
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
|
||||||
|
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
|
||||||
|
# are required, so these changes are kept OUT of the committed sources and applied here instead.
|
||||||
|
#
|
||||||
|
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
|
||||||
|
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
|
||||||
|
# compile against the same Flutter/freezed version the arm64 build resolves.
|
||||||
|
#
|
||||||
|
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
|
||||||
|
#
|
||||||
|
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
|
||||||
|
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
|
||||||
|
# therefore CRLF-safe.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ThemeData API renames (Flutter 3.27+):
|
||||||
|
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
|
||||||
|
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
|
||||||
|
sed -i '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||||
|
backgroundColor: Colors.white,/' flutter/lib/common.dart
|
||||||
|
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||||
|
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
|
||||||
|
# Dependency bumps required by the newer Dart/Flutter:
|
||||||
|
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||||
|
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||||
|
|
||||||
|
# Fail loudly if any expected string drifted, so we never silently build unpatched:
|
||||||
|
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
|
||||||
|
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
|
||||||
|
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
|
||||||
|
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
|
||||||
|
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
|
||||||
|
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||||
|
|
||||||
|
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||||
40
.github/workflows/bridge.yml
vendored
40
.github/workflows/bridge.yml
vendored
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_EXPAND_VERSION: "1.0.95"
|
CARGO_EXPAND_VERSION: "1.0.95"
|
||||||
FLUTTER_VERSION: "3.22.3"
|
|
||||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||||
|
|
||||||
@@ -18,14 +17,25 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
job:
|
job:
|
||||||
|
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
|
||||||
- {
|
- {
|
||||||
target: x86_64-unknown-linux-gnu,
|
target: x86_64-unknown-linux-gnu,
|
||||||
os: ubuntu-22.04,
|
os: ubuntu-22.04,
|
||||||
extra-build-args: "",
|
extra-build-args: "",
|
||||||
|
flutter-version: "3.22.3",
|
||||||
|
artifact-name: "bridge-artifact",
|
||||||
|
}
|
||||||
|
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
|
||||||
|
- {
|
||||||
|
target: x86_64-unknown-linux-gnu,
|
||||||
|
os: ubuntu-22.04,
|
||||||
|
extra-build-args: "",
|
||||||
|
flutter-version: "3.44.0",
|
||||||
|
artifact-name: "bridge-artifact-flutter-3.44",
|
||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -49,28 +59,28 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: bridge-${{ matrix.job.os }}
|
prefix-key: bridge-${{ matrix.job.os }}
|
||||||
|
|
||||||
- name: Cache Bridge
|
- name: Cache Bridge
|
||||||
id: cache-bridge
|
id: cache-bridge
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/flutter_rust_bridge
|
path: /tmp/flutter_rust_bridge
|
||||||
key: vcpkg-${{ matrix.job.arch }}
|
key: bridge-${{ matrix.job.flutter-version }}
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ matrix.job.flutter-version }}
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install flutter rust bridge deps
|
- name: Install flutter rust bridge deps
|
||||||
@@ -78,7 +88,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
|
||||||
|
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
|
||||||
|
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
|
||||||
|
else
|
||||||
|
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
|
||||||
|
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
|
||||||
|
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||||
|
fi
|
||||||
|
pushd flutter && flutter pub get && popd
|
||||||
|
|
||||||
- name: Run flutter rust bridge
|
- name: Run flutter rust bridge
|
||||||
run: |
|
run: |
|
||||||
@@ -86,9 +104,9 @@ jobs:
|
|||||||
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: ${{ matrix.job.artifact-name }}
|
||||||
path: |
|
path: |
|
||||||
./src/bridge_generated.rs
|
./src/bridge_generated.rs
|
||||||
./src/bridge_generated.io.rs
|
./src/bridge_generated.io.rs
|
||||||
|
|||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -29,13 +29,13 @@ jobs:
|
|||||||
# name: Ensure 'cargo fmt' has been run
|
# name: Ensure 'cargo fmt' has been run
|
||||||
# runs-on: ubuntu-20.04
|
# runs-on: ubuntu-20.04
|
||||||
# steps:
|
# steps:
|
||||||
# - uses: actions-rs/toolchain@v1
|
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||||
# with:
|
# with:
|
||||||
# toolchain: stable
|
# toolchain: stable
|
||||||
# default: true
|
# default: true
|
||||||
# profile: minimal
|
# profile: minimal
|
||||||
# components: rustfmt
|
# components: rustfmt
|
||||||
# - uses: actions/checkout@v3
|
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
# - run: cargo fmt -- --check
|
# - run: cargo fmt -- --check
|
||||||
|
|
||||||
# min_version:
|
# min_version:
|
||||||
@@ -43,24 +43,24 @@ jobs:
|
|||||||
# runs-on: ubuntu-20.04
|
# runs-on: ubuntu-20.04
|
||||||
# steps:
|
# steps:
|
||||||
# - name: Checkout source code
|
# - name: Checkout source code
|
||||||
# uses: actions/checkout@v3
|
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
# with:
|
# with:
|
||||||
# submodules: recursive
|
# submodules: recursive
|
||||||
|
|
||||||
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
||||||
# uses: actions-rs/toolchain@v1
|
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||||
# with:
|
# with:
|
||||||
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
||||||
# default: true
|
# default: true
|
||||||
# profile: minimal # minimal component installation (ie, no documentation)
|
# profile: minimal # minimal component installation (ie, no documentation)
|
||||||
# components: clippy
|
# components: clippy
|
||||||
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
|
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
|
||||||
# uses: actions-rs/cargo@v1
|
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
# with:
|
# with:
|
||||||
# command: clippy
|
# command: clippy
|
||||||
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
||||||
# - name: Run tests
|
# - name: Run tests
|
||||||
# uses: actions-rs/cargo@v1
|
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
# with:
|
# with:
|
||||||
# command: test
|
# command: test
|
||||||
# args: --locked
|
# args: --locked
|
||||||
@@ -81,14 +81,15 @@ jobs:
|
|||||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||||
|
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
|
||||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- name: Free Disk Space (Ubuntu)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
|
# jlumbroso/free-disk-space@v1.3.1 is used in .github\workflows\flutter-build.yml
|
||||||
# But pinning to a specific version to avoid unexpected issues is preferred.
|
# But pinning to a specific version to avoid unexpected issues is preferred.
|
||||||
uses: jlumbroso/free-disk-space@v1.3.1
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||||
with:
|
with:
|
||||||
tool-cache: false
|
tool-cache: false
|
||||||
android: true
|
android: true
|
||||||
@@ -99,14 +100,14 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -156,7 +157,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
toolchain: stable
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
@@ -172,10 +173,10 @@ jobs:
|
|||||||
cargo -V
|
cargo -V
|
||||||
rustc -V
|
rustc -V
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
with:
|
with:
|
||||||
use-cross: ${{ matrix.job.use-cross }}
|
use-cross: ${{ matrix.job.use-cross }}
|
||||||
command: build
|
command: build
|
||||||
@@ -243,7 +244,7 @@ jobs:
|
|||||||
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
with:
|
with:
|
||||||
use-cross: ${{ matrix.job.use-cross }}
|
use-cross: ${{ matrix.job.use-cross }}
|
||||||
command: test
|
command: test
|
||||||
|
|||||||
4
.github/workflows/clear-cache.yml
vendored
4
.github/workflows/clear-cache.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clear cache
|
- name: Clear cache
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
console.log("About to clear")
|
console.log("About to clear")
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
console.log("Clear completed")
|
console.log("Clear completed")
|
||||||
|
|
||||||
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
|
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
|
||||||
uses: MyAlbum/purge-cache@v2
|
uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
|
||||||
with:
|
with:
|
||||||
accessed: true # Purge caches by their last accessed time (default)
|
accessed: true # Purge caches by their last accessed time (default)
|
||||||
created: false # Purge caches by their created time (default)
|
created: false # Purge caches by their created time (default)
|
||||||
|
|||||||
2
.github/workflows/fdroid.yml
vendored
2
.github/workflows/fdroid.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Publish RustDesk version file
|
- name: Publish RustDesk version file
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: "fdroid-version"
|
tag_name: "fdroid-version"
|
||||||
|
|||||||
337
.github/workflows/flutter-build.yml
vendored
337
.github/workflows/flutter-build.yml
vendored
@@ -27,6 +27,11 @@ env:
|
|||||||
LLVM_VERSION: "15.0.6"
|
LLVM_VERSION: "15.0.6"
|
||||||
FLUTTER_VERSION: "3.24.5"
|
FLUTTER_VERSION: "3.24.5"
|
||||||
ANDROID_FLUTTER_VERSION: "3.24.5"
|
ANDROID_FLUTTER_VERSION: "3.24.5"
|
||||||
|
# Windows arm64 only: the first stable Flutter to ship a native arm64 Windows Dart SDK +
|
||||||
|
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||||
|
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||||
|
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||||
|
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
|
||||||
# for arm64 linux because official Dart SDK does not work
|
# for arm64 linux because official Dart SDK does not work
|
||||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||||
@@ -39,7 +44,7 @@ env:
|
|||||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||||
VERSION: "1.4.6"
|
VERSION: "1.4.8"
|
||||||
NDK_VERSION: "r28c"
|
NDK_VERSION: "r28c"
|
||||||
#signing keys env variable checks
|
#signing keys env variable checks
|
||||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||||
@@ -53,14 +58,24 @@ jobs:
|
|||||||
|
|
||||||
build-RustDeskTempTopMostWindow:
|
build-RustDeskTempTopMostWindow:
|
||||||
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
|
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
|
||||||
with:
|
|
||||||
upload-artifact: ${{ inputs.upload-artifact }}
|
|
||||||
target: windows-2022
|
|
||||||
configuration: Release
|
|
||||||
platform: x64
|
|
||||||
target_version: Windows10
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job:
|
||||||
|
- {
|
||||||
|
target: windows-2022,
|
||||||
|
platform: x64,
|
||||||
|
}
|
||||||
|
- {
|
||||||
|
target: windows-11-arm,
|
||||||
|
platform: ARM64,
|
||||||
|
}
|
||||||
|
with:
|
||||||
|
upload-artifact: ${{ inputs.upload-artifact }}
|
||||||
|
target: ${{ matrix.job.target }}
|
||||||
|
configuration: Release
|
||||||
|
platform: ${{ matrix.job.platform }}
|
||||||
|
target_version: Windows10
|
||||||
|
|
||||||
build-for-windows-flutter:
|
build-for-windows-flutter:
|
||||||
name: ${{ matrix.job.target }}
|
name: ${{ matrix.job.target }}
|
||||||
@@ -76,68 +91,134 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc,
|
target: x86_64-pc-windows-msvc,
|
||||||
os: windows-2022,
|
os: windows-2022,
|
||||||
arch: x86_64,
|
arch: x86_64,
|
||||||
|
flutter-arch: x64,
|
||||||
vcpkg-triplet: x64-windows-static,
|
vcpkg-triplet: x64-windows-static,
|
||||||
|
build-args: "--vram",
|
||||||
|
}
|
||||||
|
- {
|
||||||
|
target: aarch64-pc-windows-msvc,
|
||||||
|
os: windows-11-arm,
|
||||||
|
arch: aarch64,
|
||||||
|
flutter-arch: arm64,
|
||||||
|
vcpkg-triplet: arm64-windows-static,
|
||||||
|
# vram is x86/x64-only (NVENC needs CUDA, Intel MediaSDK needs __rdtsc);
|
||||||
|
# no NV/Intel/AMD hardware exists on Windows-on-ARM, so vram stays disabled here.
|
||||||
|
build-args: "",
|
||||||
}
|
}
|
||||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
|
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
# arm64 is on Flutter 3.44, so it needs the bridge generated with the same Flutter
|
||||||
|
# (its *.freezed.dart must match the freezed the arm64 build resolves). x64 and every
|
||||||
|
# other platform keep the default 3.22.3-generated bridge.
|
||||||
|
name: ${{ matrix.job.arch == 'aarch64' && 'bridge-artifact-flutter-3.44' || 'bridge-artifact' }}
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Install LLVM and Clang
|
- name: Install LLVM and Clang
|
||||||
uses: KyleMayes/install-llvm-action@v1
|
uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
|
||||||
with:
|
with:
|
||||||
version: ${{ env.LLVM_VERSION }}
|
version: ${{ env.LLVM_VERSION }}
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
|
id: flutter
|
||||||
|
# arm64 builds with FLUTTER_WINDOWS_ARM_VERSION (>=3.44); x64 stays on FLUTTER_VERSION.
|
||||||
|
# subosito only ships an x64 Windows SDK (Flutter's release manifest lists x64 only),
|
||||||
|
# so it installs x64 on both arches. The arm64 runner re-bootstraps Dart to arm64 in
|
||||||
|
# the next step.
|
||||||
|
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ matrix.job.arch == 'aarch64' && env.FLUTTER_WINDOWS_ARM_VERSION || env.FLUTTER_VERSION }}
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- name: Force arm64 Dart SDK + engine
|
||||||
|
# The x64 SDK subosito installs bundles an x64 Dart with a matching engine-dart-sdk.stamp,
|
||||||
|
# so update_dart_sdk.ps1 short-circuits (stamp matches -> return) and keeps x64 Dart;
|
||||||
|
# `flutter build windows` then targets the Dart VM's arch = x64, even on this arm64 host.
|
||||||
|
# On this native-arm64 runner (PROCESSOR_ARCHITECTURE=ARM64), deleting the stamp and
|
||||||
|
# re-running update_dart_sdk.ps1 pulls the arm64 Dart (available since Flutter 3.44.0).
|
||||||
|
# https://github.com/flutter/flutter/issues/186730#issuecomment-4573214964
|
||||||
|
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||||
|
run: |
|
||||||
|
$flutterRoot = "${{ steps.flutter.outputs['CACHE-PATH'] }}"
|
||||||
|
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
|
||||||
|
Write-Host "Flutter root: $flutterRoot"
|
||||||
|
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
|
||||||
|
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
|
||||||
|
# Confirm the Dart we ended up with is arm64 ("on windows_arm64"); fail loudly if not.
|
||||||
|
$dartVer = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
|
||||||
|
Write-Host $dartVer
|
||||||
|
if ($dartVer -notmatch "windows_arm64") {
|
||||||
|
Write-Error "Expected an arm64 Dart SDK but got: $dartVer"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
& "$flutterRoot\bin\flutter.bat" precache --windows
|
||||||
|
# Fail fast if precache pulled the wrong-arch Windows engine: an arm64 Dart should
|
||||||
|
# fetch windows-arm64 engine artifacts. Bailing here saves the ~25min Rust build.
|
||||||
|
$engineDir = "$flutterRoot\bin\cache\artifacts\engine"
|
||||||
|
Write-Host "Engine artifacts present:"
|
||||||
|
Get-ChildItem $engineDir -Directory | Select-Object -ExpandProperty Name | Write-Host
|
||||||
|
if (-not (Test-Path "$engineDir\windows-arm64-release")) {
|
||||||
|
Write-Error "Expected windows-arm64-release engine artifacts but they are missing (wrong-arch SDK)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# https://github.com/flutter/flutter/issues/155685
|
# https://github.com/flutter/flutter/issues/155685
|
||||||
|
# x64 only: arm64 uses the stock native arm64 Windows engine, and the rustdesk/engine
|
||||||
|
# windows-x64-release.zip is built for the 3.24-era x64 engine (matches FLUTTER_VERSION).
|
||||||
- name: Replace engine with rustdesk custom flutter engine
|
- name: Replace engine with rustdesk custom flutter engine
|
||||||
|
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||||
run: |
|
run: |
|
||||||
flutter doctor -v
|
flutter doctor -v
|
||||||
flutter precache --windows
|
flutter precache --windows
|
||||||
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
|
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
|
||||||
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
|
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
|
||||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||||
|
|
||||||
- name: Patch flutter
|
- name: Patch flutter
|
||||||
|
# x64 stays on Flutter 3.24.5, which needs the dropdown filter patch.
|
||||||
|
# arm64 is on Flutter 3.44 (patched separately below) and does not use this patch.
|
||||||
|
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||||
cd $(dirname $(dirname $(which flutter)))
|
cd $(dirname $(dirname $(which flutter)))
|
||||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||||
|
|
||||||
|
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||||
|
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||||
|
# (shared with the 3.44 bridge job) so the committed sources stay on Flutter 3.24.5.
|
||||||
|
# `flutter build` then runs `pub get`, regenerating pubspec.lock for the bumped deps.
|
||||||
|
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||||
|
shell: bash
|
||||||
|
run: bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: ${{ matrix.job.os }}
|
prefix-key: ${{ matrix.job.os }}
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: C:\vcpkg
|
vcpkgDirectory: C:\vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -163,11 +244,19 @@ jobs:
|
|||||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
|
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Set SODIUM_LIB_DIR (arm64)
|
||||||
|
# libsodium-sys ships no arm64 Windows prebuilt lib; point it at the vcpkg-built one
|
||||||
|
# (only for arm64 — leaving it unset lets x64 use the crate's bundled lib).
|
||||||
|
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||||
|
shell: bash
|
||||||
|
run: echo "SODIUM_LIB_DIR=$VCPKG_ROOT/installed/${{ matrix.job.vcpkg-triplet }}/lib" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build rustdesk
|
- name: Build rustdesk
|
||||||
run: |
|
run: |
|
||||||
# Windows: build RustDesk
|
# Windows: build RustDesk
|
||||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
# --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
|
||||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec ${{ matrix.job.build-args }}
|
||||||
|
mv ./flutter/build/windows/${{ matrix.job.flutter-arch }}/runner/Release ./rustdesk
|
||||||
|
|
||||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||||
@@ -220,15 +309,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Download RustDeskTempTopMostWindow artifacts
|
- name: Download RustDeskTempTopMostWindow artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
if: ${{ inputs.upload-artifact }}
|
if: ${{ inputs.upload-artifact }}
|
||||||
with:
|
with:
|
||||||
name: topmostwindow-artifacts
|
name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
|
||||||
path: "./rustdesk"
|
path: "./rustdesk"
|
||||||
|
|
||||||
- name: Upload unsigned
|
- name: Upload unsigned
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||||
path: rustdesk
|
path: rustdesk
|
||||||
@@ -253,16 +342,21 @@ jobs:
|
|||||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
|
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
|
||||||
|
|
||||||
- name: Add MSBuild to PATH
|
- name: Add MSBuild to PATH
|
||||||
uses: microsoft/setup-msbuild@v2
|
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||||
|
|
||||||
- name: Build msi
|
- name: Build msi
|
||||||
|
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
|
||||||
|
# both x64 and ARM64 platform configs; WcaUtil/DUtil ship arm64 libs. msbuild platform
|
||||||
|
# is x64 / ARM64; the produced Package.msi is globbed since its bin/<platform>/ dir varies.
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
run: |
|
run: |
|
||||||
pushd ./res/msi
|
pushd ./res/msi
|
||||||
python preprocess.py --arp -d ../../rustdesk
|
python preprocess.py --arp -d ../../rustdesk
|
||||||
nuget restore msi.sln
|
nuget restore msi.sln
|
||||||
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
|
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||||
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||||
|
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||||
|
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||||
|
|
||||||
- name: Sign rustdesk self-extracted file
|
- name: Sign rustdesk self-extracted file
|
||||||
@@ -272,7 +366,7 @@ jobs:
|
|||||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
|
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
@@ -302,35 +396,35 @@ jobs:
|
|||||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022 }
|
# - { target: aarch64-pc-windows-msvc, os: windows-2022 }
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install LLVM and Clang
|
- name: Install LLVM and Clang
|
||||||
uses: rustdesk-org/install-llvm-action-32bit@master
|
uses: rustdesk-org/install-llvm-action-32bit@6aa7d9ad3df84dff01cd4596dd0fc880a7f47fce # no release tag; commit 2026-05-26
|
||||||
with:
|
with:
|
||||||
version: ${{ env.LLVM_VERSION }}
|
version: ${{ env.LLVM_VERSION }}
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
|
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: ${{ matrix.job.os }}-sciter
|
prefix-key: ${{ matrix.job.os }}-sciter
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: C:\vcpkg
|
vcpkgDirectory: C:\vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -363,7 +457,8 @@ jobs:
|
|||||||
python3 res/inline-sciter.py
|
python3 res/inline-sciter.py
|
||||||
# Patch sciter x86
|
# Patch sciter x86
|
||||||
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
|
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
|
||||||
cargo build --features inline,vram,hwcodec --release --bins
|
cargo update -p sciter-rs --precise 674e07d3066ca9a92ced3816203ab6b652629d1e
|
||||||
|
cargo build --locked --features inline,vram,hwcodec --release --bins
|
||||||
mkdir -p ./Release
|
mkdir -p ./Release
|
||||||
mv ./target/release/rustdesk.exe ./Release/rustdesk.exe
|
mv ./target/release/rustdesk.exe ./Release/rustdesk.exe
|
||||||
curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll
|
curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll
|
||||||
@@ -394,7 +489,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload unsigned
|
- name: Upload unsigned
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||||
path: Release
|
path: Release
|
||||||
@@ -424,7 +519,7 @@ jobs:
|
|||||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
|
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
@@ -449,7 +544,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
@@ -459,12 +554,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
brew install nasm yasm
|
brew install nasm yasm
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
@@ -475,7 +570,7 @@ jobs:
|
|||||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
doNotCache: false
|
doNotCache: false
|
||||||
@@ -499,19 +594,19 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: rustdesk-lib-cache-ios
|
prefix-key: rustdesk-lib-cache-ios
|
||||||
key: ${{ matrix.job.target }}
|
key: ${{ matrix.job.target }}
|
||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
@@ -519,10 +614,10 @@ jobs:
|
|||||||
- name: Build rustdesk lib
|
- name: Build rustdesk lib
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.job.target }}
|
rustup target add ${{ matrix.job.target }}
|
||||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||||
|
|
||||||
- name: Upload liblibrustdesk.a Artifacts
|
- name: Upload liblibrustdesk.a Artifacts
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: liblibrustdesk.a
|
name: liblibrustdesk.a
|
||||||
path: target/aarch64-apple-ios/release/liblibrustdesk.a
|
path: target/aarch64-apple-ios/release/liblibrustdesk.a
|
||||||
@@ -537,14 +632,14 @@ jobs:
|
|||||||
|
|
||||||
# - name: Upload Artifacts
|
# - name: Upload Artifacts
|
||||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
# uses: actions/upload-artifact@master
|
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
# with:
|
# with:
|
||||||
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||||
# path: flutter/build/ios/ipa/*.ipa
|
# path: flutter/build/ios/ipa/*.ipa
|
||||||
|
|
||||||
# - name: Publish ipa package
|
# - name: Publish ipa package
|
||||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
# uses: softprops/action-gh-release@v1
|
# uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
# with:
|
# with:
|
||||||
# prerelease: true
|
# prerelease: true
|
||||||
# tag_name: ${{ env.TAG_NAME }}
|
# tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -577,20 +672,20 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Import the codesign cert
|
- name: Import the codesign cert
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: apple-actions/import-codesign-certs@v1
|
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||||
@@ -604,7 +699,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Import notarize key
|
- name: Import notarize key
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: timheuer/base64-to-file@v1.2
|
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||||
with:
|
with:
|
||||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||||
fileName: rustdesk.json
|
fileName: rustdesk.json
|
||||||
@@ -643,7 +738,7 @@ jobs:
|
|||||||
nasm --version
|
nasm --version
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
@@ -662,24 +757,24 @@ jobs:
|
|||||||
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
|
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.MAC_RUST_VERSION }}
|
toolchain: ${{ env.MAC_RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: ${{ matrix.job.os }}
|
prefix-key: ${{ matrix.job.os }}
|
||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
doNotCache: false
|
doNotCache: false
|
||||||
@@ -731,7 +826,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload unsigned macOS app
|
- name: Upload unsigned macOS app
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-macos-${{ matrix.job.arch }}
|
name: rustdesk-unsigned-macos-${{ matrix.job.arch }}
|
||||||
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed
|
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed
|
||||||
@@ -763,7 +858,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish DMG package
|
- name: Publish DMG package
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -779,25 +874,25 @@ jobs:
|
|||||||
if: ${{ inputs.upload-artifact }}
|
if: ${{ inputs.upload-artifact }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-macos-x86_64
|
name: rustdesk-unsigned-macos-x86_64
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Download Artifacts
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-macos-aarch64
|
name: rustdesk-unsigned-macos-aarch64
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Download Artifacts
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-windows-x86_64
|
name: rustdesk-unsigned-windows-x86_64
|
||||||
path: ./windows-x86_64/
|
path: ./windows-x86_64/
|
||||||
|
|
||||||
- name: Download Artifacts
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-windows-x86
|
name: rustdesk-unsigned-windows-x86
|
||||||
path: ./windows-x86/
|
path: ./windows-x86/
|
||||||
@@ -807,7 +902,7 @@ jobs:
|
|||||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||||
|
|
||||||
- name: Publish unsigned app
|
- name: Publish unsigned app
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -844,7 +939,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- name: Free Disk Space (Ubuntu)
|
||||||
uses: jlumbroso/free-disk-space@main
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||||
with:
|
with:
|
||||||
tool-cache: false
|
tool-cache: false
|
||||||
android: false
|
android: false
|
||||||
@@ -855,7 +950,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
@@ -897,12 +992,12 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||||
@@ -912,14 +1007,14 @@ jobs:
|
|||||||
cd $(dirname $(dirname $(which flutter)))
|
cd $(dirname $(dirname $(which flutter)))
|
||||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||||
|
|
||||||
- uses: nttld/setup-ndk@v1
|
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||||
id: setup-ndk
|
id: setup-ndk
|
||||||
with:
|
with:
|
||||||
ndk-version: ${{ env.NDK_VERSION }}
|
ndk-version: ${{ env.NDK_VERSION }}
|
||||||
add-to-path: true
|
add-to-path: true
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -954,18 +1049,18 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
||||||
key: ${{ matrix.job.target }}
|
key: ${{ matrix.job.target }}
|
||||||
@@ -1001,7 +1096,7 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Upload Rustdesk library to Artifacts
|
- name: Upload Rustdesk library to Artifacts
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: librustdesk.so.${{ matrix.job.target }}
|
name: librustdesk.so.${{ matrix.job.target }}
|
||||||
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
|
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
|
||||||
@@ -1066,7 +1161,7 @@ jobs:
|
|||||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||||
|
|
||||||
- uses: r0adkll/sign-android-release@v1
|
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||||
name: Sign app APK
|
name: Sign app APK
|
||||||
if: env.ANDROID_SIGNING_KEY != null
|
if: env.ANDROID_SIGNING_KEY != null
|
||||||
id: sign-rustdesk
|
id: sign-rustdesk
|
||||||
@@ -1082,14 +1177,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||||
|
|
||||||
- name: Publish signed apk package
|
- name: Publish signed apk package
|
||||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1098,7 +1193,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish unsigned apk package
|
- name: Publish unsigned apk package
|
||||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1116,7 +1211,7 @@ jobs:
|
|||||||
suffix: ""
|
suffix: ""
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- name: Free Disk Space (Ubuntu)
|
||||||
uses: jlumbroso/free-disk-space@main
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||||
with:
|
with:
|
||||||
tool-cache: false
|
tool-cache: false
|
||||||
android: false
|
android: false
|
||||||
@@ -1127,7 +1222,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
@@ -1169,12 +1264,12 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||||
@@ -1185,32 +1280,32 @@ jobs:
|
|||||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Download Rustdesk library from Artifacts
|
- name: Download Rustdesk library from Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: librustdesk.so.aarch64-linux-android
|
name: librustdesk.so.aarch64-linux-android
|
||||||
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
|
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
|
||||||
|
|
||||||
- name: Download Rustdesk library from Artifacts
|
- name: Download Rustdesk library from Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: librustdesk.so.armv7-linux-androideabi
|
name: librustdesk.so.armv7-linux-androideabi
|
||||||
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
|
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
|
||||||
|
|
||||||
- name: Download Rustdesk library from Artifacts
|
- name: Download Rustdesk library from Artifacts
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: librustdesk.so.x86_64-linux-android
|
name: librustdesk.so.x86_64-linux-android
|
||||||
path: ./flutter/android/app/src/main/jniLibs/x86_64
|
path: ./flutter/android/app/src/main/jniLibs/x86_64
|
||||||
|
|
||||||
- name: Download Rustdesk library from Artifacts
|
- name: Download Rustdesk library from Artifacts
|
||||||
if: ${{ env.reltype == 'debug' }}
|
if: ${{ env.reltype == 'debug' }}
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: librustdesk.so.i686-linux-android
|
name: librustdesk.so.i686-linux-android
|
||||||
path: ./flutter/android/app/src/main/jniLibs/x86
|
path: ./flutter/android/app/src/main/jniLibs/x86
|
||||||
@@ -1250,7 +1345,7 @@ jobs:
|
|||||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||||
|
|
||||||
- uses: r0adkll/sign-android-release@v1
|
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||||
name: Sign app APK
|
name: Sign app APK
|
||||||
if: env.ANDROID_SIGNING_KEY != null
|
if: env.ANDROID_SIGNING_KEY != null
|
||||||
id: sign-rustdesk
|
id: sign-rustdesk
|
||||||
@@ -1266,14 +1361,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||||
|
|
||||||
- name: Publish signed apk package
|
- name: Publish signed apk package
|
||||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1282,7 +1377,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish unsigned apk package
|
- name: Publish unsigned apk package
|
||||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1316,7 +1411,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
@@ -1334,13 +1429,13 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Set Swap Space
|
- name: Set Swap Space
|
||||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||||
uses: pierotofy/set-swap-space@master
|
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||||
with:
|
with:
|
||||||
swap-size-gb: 12
|
swap-size-gb: 12
|
||||||
|
|
||||||
@@ -1350,7 +1445,7 @@ jobs:
|
|||||||
free -m
|
free -m
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
@@ -1369,14 +1464,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -1404,12 +1499,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore bridge files
|
- name: Restore bridge files
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||||
name: Build rustdesk
|
name: Build rustdesk
|
||||||
id: vcpkg
|
id: vcpkg
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
@@ -1491,7 +1586,7 @@ jobs:
|
|||||||
export JOBS=""
|
export JOBS=""
|
||||||
fi
|
fi
|
||||||
echo $JOBS
|
echo $JOBS
|
||||||
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||||
rm -rf target/release/deps target/release/build
|
rm -rf target/release/deps target/release/build
|
||||||
rm -rf ~/.cargo
|
rm -rf ~/.cargo
|
||||||
|
|
||||||
@@ -1583,7 +1678,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish debian/rpm package
|
- name: Publish debian/rpm package
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1592,7 +1687,7 @@ jobs:
|
|||||||
rustdesk-*.rpm
|
rustdesk-*.rpm
|
||||||
|
|
||||||
- name: Upload deb
|
- name: Upload deb
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||||
@@ -1611,7 +1706,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build archlinux package
|
- name: Build archlinux package
|
||||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: rustdesk-org/arch-makepkg-action@master
|
uses: rustdesk-org/arch-makepkg-action@04200739ed1d0bf6f2188b6736b26a767c57a7f9 # no release tag; commit 2026-05-26
|
||||||
with:
|
with:
|
||||||
packages:
|
packages:
|
||||||
scripts: |
|
scripts: |
|
||||||
@@ -1619,7 +1714,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish archlinux package
|
- name: Publish archlinux package
|
||||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1657,14 +1752,14 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -1682,7 +1777,7 @@ jobs:
|
|||||||
free -m
|
free -m
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
@@ -1693,7 +1788,7 @@ jobs:
|
|||||||
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
||||||
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
|
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||||
name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
|
name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
|
||||||
id: vcpkg
|
id: vcpkg
|
||||||
with:
|
with:
|
||||||
@@ -1821,7 +1916,7 @@ jobs:
|
|||||||
# build rustdesk
|
# build rustdesk
|
||||||
python3 ./res/inline-sciter.py
|
python3 ./res/inline-sciter.py
|
||||||
export CARGO_INCREMENTAL=0
|
export CARGO_INCREMENTAL=0
|
||||||
cargo build --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
||||||
# make debian package
|
# make debian package
|
||||||
mkdir -p ./Release
|
mkdir -p ./Release
|
||||||
mv ./target/release/rustdesk ./Release/rustdesk
|
mv ./target/release/rustdesk ./Release/rustdesk
|
||||||
@@ -1839,7 +1934,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish debian package
|
- name: Publish debian package
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1847,7 +1942,7 @@ jobs:
|
|||||||
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||||
|
|
||||||
- name: Upload deb
|
- name: Upload deb
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||||
@@ -1866,12 +1961,12 @@ jobs:
|
|||||||
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
|
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Download Binary
|
- name: Download Binary
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||||
path: .
|
path: .
|
||||||
@@ -1896,7 +1991,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish appimage package
|
- name: Publish appimage package
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -1939,12 +2034,12 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Download Binary
|
- name: Download Binary
|
||||||
uses: actions/download-artifact@master
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
|
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
|
||||||
path: .
|
path: .
|
||||||
@@ -1953,7 +2048,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
|
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
|
||||||
|
|
||||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||||
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
|
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
|
||||||
id: flatpak
|
id: flatpak
|
||||||
with:
|
with:
|
||||||
@@ -1981,7 +2076,7 @@ jobs:
|
|||||||
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
|
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
|
||||||
|
|
||||||
- name: Publish flatpak package
|
- name: Publish flatpak package
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -2000,7 +2095,7 @@ jobs:
|
|||||||
RELEASE_NAME: web-basic
|
RELEASE_NAME: web-basic
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -2010,7 +2105,7 @@ jobs:
|
|||||||
sudo apt-get install -y wget npm
|
sudo apt-get install -y wget npm
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
|
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
@@ -2054,7 +2149,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish web
|
- name: Publish web
|
||||||
if: env.UPLOAD_ARTIFACT == 'true'
|
if: env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
|||||||
36
.github/workflows/playground.yml
vendored
36
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
|||||||
TAG_NAME: "nightly"
|
TAG_NAME: "nightly"
|
||||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||||
VERSION: "1.4.6"
|
VERSION: "1.4.8"
|
||||||
NDK_VERSION: "r26d"
|
NDK_VERSION: "r26d"
|
||||||
#signing keys env variable checks
|
#signing keys env variable checks
|
||||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||||
@@ -79,21 +79,21 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
with:
|
with:
|
||||||
ref: ${{ matrix.job.ref }}
|
ref: ${{ matrix.job.ref }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Import the codesign cert
|
- name: Import the codesign cert
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: apple-actions/import-codesign-certs@v1
|
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Import notarize key
|
- name: Import notarize key
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: timheuer/base64-to-file@v1.2
|
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||||
with:
|
with:
|
||||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||||
fileName: rustdesk.json
|
fileName: rustdesk.json
|
||||||
@@ -129,19 +129,19 @@ jobs:
|
|||||||
brew install llvm create-dmg nasm pkg-config
|
brew install llvm create-dmg nasm pkg-config
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ matrix.job.flutter }}
|
flutter-version: ${{ matrix.job.flutter }}
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: ${{ matrix.job.os }}
|
prefix-key: ${{ matrix.job.os }}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ jobs:
|
|||||||
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||||
|
|
||||||
- name: Restore from cache and install vcpkg
|
- name: Restore from cache and install vcpkg
|
||||||
uses: lukka/run-vcpkg@v7
|
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
|
||||||
if: false
|
if: false
|
||||||
with:
|
with:
|
||||||
setupOnly: true
|
setupOnly: true
|
||||||
@@ -222,7 +222,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Publish DMG package
|
- name: Publish DMG package
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -247,7 +247,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
with:
|
with:
|
||||||
ref: ${{ matrix.job.ref }}
|
ref: ${{ matrix.job.ref }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -290,13 +290,13 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
@@ -310,14 +310,14 @@ jobs:
|
|||||||
pushd flutter ; flutter pub get ; popd
|
pushd flutter ; flutter pub get ; popd
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||||
|
|
||||||
- uses: nttld/setup-ndk@v1
|
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||||
id: setup-ndk
|
id: setup-ndk
|
||||||
with:
|
with:
|
||||||
ndk-version: ${{ env.NDK_VERSION }}
|
ndk-version: ${{ env.NDK_VERSION }}
|
||||||
add-to-path: true
|
add-to-path: true
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -395,7 +395,7 @@ jobs:
|
|||||||
mkdir -p signed-apk; pushd signed-apk
|
mkdir -p signed-apk; pushd signed-apk
|
||||||
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
|
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
|
||||||
|
|
||||||
- uses: r0adkll/sign-android-release@v1
|
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||||
name: Sign app APK
|
name: Sign app APK
|
||||||
if: env.ANDROID_SIGNING_KEY != null
|
if: env.ANDROID_SIGNING_KEY != null
|
||||||
id: sign-rustdesk
|
id: sign-rustdesk
|
||||||
@@ -410,7 +410,7 @@ jobs:
|
|||||||
BUILD_TOOLS_VERSION: "30.0.2"
|
BUILD_TOOLS_VERSION: "30.0.2"
|
||||||
|
|
||||||
- name: Publish signed apk package
|
- name: Publish signed apk package
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
|||||||
@@ -39,22 +39,21 @@ jobs:
|
|||||||
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
||||||
steps:
|
steps:
|
||||||
- name: Add MSBuild to PATH
|
- name: Add MSBuild to PATH
|
||||||
uses: microsoft/setup-msbuild@v2
|
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||||
|
|
||||||
- name: Download the source code
|
- name: Download the source code
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
||||||
|
|
||||||
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
|
|
||||||
- name: Build the project
|
- name: Build the project
|
||||||
run: |
|
run: |
|
||||||
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
|
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
|
||||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||||
|
|
||||||
- name: Archive build artifacts
|
- name: Archive build artifacts
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: ${{ inputs.upload-artifact }}
|
if: ${{ inputs.upload-artifact }}
|
||||||
with:
|
with:
|
||||||
name: topmostwindow-artifacts
|
name: topmostwindow-artifacts-${{ inputs.platform }}
|
||||||
path: |
|
path: |
|
||||||
./${{ env.build_output_dir }}/WindowInjection.dll
|
./${{ env.build_output_dir }}/WindowInjection.dll
|
||||||
|
|||||||
85
.github/workflows/wf-cliprdr-ci.yml
vendored
Normal file
85
.github/workflows/wf-cliprdr-ci.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: wf-cliprdr CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "libs/clipboard/src/windows/**"
|
||||||
|
- "tests/test_invariant_wf_cliprdr.c"
|
||||||
|
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- "libs/clipboard/src/windows/**"
|
||||||
|
- "tests/test_invariant_wf_cliprdr.c"
|
||||||
|
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: wf_cliprdr invariant test
|
||||||
|
runs-on: windows-2022
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up MSVC
|
||||||
|
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
|
with:
|
||||||
|
arch: x64
|
||||||
|
|
||||||
|
- name: Setup vcpkg with GitHub Actions binary cache
|
||||||
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
|
with:
|
||||||
|
vcpkgDirectory: C:\vcpkg
|
||||||
|
doNotCache: false
|
||||||
|
|
||||||
|
- name: Install vcpkg dependency
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||||
|
|
||||||
|
- name: Build test
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||||
|
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||||
|
|
||||||
|
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||||
|
$cmakeLists = @(
|
||||||
|
'cmake_minimum_required(VERSION 3.20)'
|
||||||
|
'project(test_invariant_wf_cliprdr C)'
|
||||||
|
''
|
||||||
|
'set(CMAKE_C_STANDARD 11)'
|
||||||
|
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||||
|
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||||
|
''
|
||||||
|
'find_package(check CONFIG REQUIRED)'
|
||||||
|
''
|
||||||
|
'add_executable(test_invariant_wf_cliprdr'
|
||||||
|
' "TEST_SOURCE"'
|
||||||
|
')'
|
||||||
|
''
|
||||||
|
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||||
|
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
|
||||||
|
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
|
||||||
|
')'
|
||||||
|
) -join [Environment]::NewLine
|
||||||
|
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
|
||||||
|
|
||||||
|
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
|
||||||
|
cmake --build (Join-Path $testRoot 'out') --config Release
|
||||||
|
|
||||||
|
- name: Run test
|
||||||
|
shell: pwsh
|
||||||
|
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,6 +55,4 @@ examples/**/target/
|
|||||||
vcpkg_installed
|
vcpkg_installed
|
||||||
flutter/lib/generated_plugin_registrant.dart
|
flutter/lib/generated_plugin_registrant.dart
|
||||||
libsciter.dylib
|
libsciter.dylib
|
||||||
flutter/web/
|
flutter/web/
|
||||||
# Local git worktrees
|
|
||||||
.worktrees/
|
|
||||||
48
AGENTS.md
48
AGENTS.md
@@ -53,30 +53,6 @@
|
|||||||
* Use `spawn_blocking` or dedicated threads for blocking work.
|
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||||
* Do not use `std::thread::sleep()` in async code.
|
* Do not use `std::thread::sleep()` in async code.
|
||||||
|
|
||||||
## Flutter Rust Bridge
|
|
||||||
|
|
||||||
* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally.
|
|
||||||
* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating.
|
|
||||||
* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn<T>` / `Future<T>` and the `dart:js` glue.
|
|
||||||
* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits.
|
|
||||||
|
|
||||||
## Web (Flutter Web) Architecture
|
|
||||||
|
|
||||||
Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split:
|
|
||||||
|
|
||||||
* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI.
|
|
||||||
* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`.
|
|
||||||
|
|
||||||
Implications when adding any session-runtime feature (keyboard, clipboard, audio, …):
|
|
||||||
|
|
||||||
* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm.
|
|
||||||
* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web.
|
|
||||||
* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge.
|
|
||||||
* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference.
|
|
||||||
* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`:
|
|
||||||
1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand;
|
|
||||||
2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation.
|
|
||||||
|
|
||||||
## Editing Hygiene
|
## Editing Hygiene
|
||||||
|
|
||||||
* Change only what is required.
|
* Change only what is required.
|
||||||
@@ -84,3 +60,27 @@ Implications when adding any session-runtime feature (keyboard, clipboard, audio
|
|||||||
* Do not refactor unrelated code.
|
* Do not refactor unrelated code.
|
||||||
* Do not make formatting-only changes.
|
* Do not make formatting-only changes.
|
||||||
* Keep naming/style consistent with nearby code.
|
* Keep naming/style consistent with nearby code.
|
||||||
|
|
||||||
|
## Localization (`src/lang/*.rs`)
|
||||||
|
|
||||||
|
Each file is a `HashMap<key, translation>`. Layout:
|
||||||
|
|
||||||
|
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
|
||||||
|
* `en.rs` holds only the keys whose English display text differs from the key itself.
|
||||||
|
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
|
||||||
|
|
||||||
|
### Finding the English source for a key
|
||||||
|
|
||||||
|
When filling an empty entry, determine the source English text with this rule:
|
||||||
|
|
||||||
|
* If `key` exists in `en.rs` **with a non-empty value**, that value is the source text (look it up in `en.rs`).
|
||||||
|
* Otherwise the **key string itself is the source text** (the key is already plain English).
|
||||||
|
|
||||||
|
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
|
||||||
|
|
||||||
|
### Translation hygiene
|
||||||
|
|
||||||
|
* Only fill empty values. Never change keys, and never touch existing non-empty translations.
|
||||||
|
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
|
||||||
|
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
|
||||||
|
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.
|
||||||
|
|||||||
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "arboard"
|
name = "arboard"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914"
|
source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clipboard-win",
|
"clipboard-win",
|
||||||
"core-graphics 0.23.2",
|
"core-graphics 0.23.2",
|
||||||
@@ -1324,7 +1324,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-master"
|
name = "clipboard-master"
|
||||||
version = "4.0.0-beta.6"
|
version = "4.0.0-beta.6"
|
||||||
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
|
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc",
|
"objc",
|
||||||
"objc-foundation",
|
"objc-foundation",
|
||||||
@@ -2329,7 +2329,7 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading 0.8.4",
|
"libloading 0.7.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2694,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "hwcodec"
|
name = "hwcodec"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen 0.59.2",
|
"bindgen 0.59.2",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -4494,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if 1.0.0",
|
"cfg-if 1.0.0",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.48.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "magnum-opus"
|
name = "magnum-opus"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
source = "git+https://github.com/rustdesk-org/magnum-opus#5cd2bf989c148662fa3a2d9d539a71d71fd1d256"
|
source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen 0.59.2",
|
"bindgen 0.59.2",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -5996,8 +5996,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parity-tokio-ipc"
|
name = "parity-tokio-ipc"
|
||||||
version = "0.7.3-5"
|
version = "0.7.3-6"
|
||||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6673,7 +6673,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6920,7 +6920,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rdev"
|
name = "rdev"
|
||||||
version = "0.5.0-2"
|
version = "0.5.0-2"
|
||||||
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
|
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cocoa 0.24.1",
|
"cocoa 0.24.1",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
@@ -7270,7 +7270,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustdesk"
|
name = "rustdesk"
|
||||||
version = "1.4.6"
|
version = "1.4.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-wakelock",
|
"android-wakelock",
|
||||||
"android_logger",
|
"android_logger",
|
||||||
@@ -7385,7 +7385,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustdesk-portable-packer"
|
name = "rustdesk-portable-packer"
|
||||||
version = "1.4.6"
|
version = "1.4.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli",
|
"brotli",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
@@ -7457,7 +7457,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.11.0",
|
"linux-raw-sys 0.11.0",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7514,7 +7514,7 @@ dependencies = [
|
|||||||
"security-framework 3.5.1",
|
"security-framework 3.5.1",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9733,9 +9733,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols-wlr"
|
name = "wayland-protocols-wlr"
|
||||||
version = "0.3.3"
|
version = "0.3.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
|
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.1",
|
"bitflags 2.9.1",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -10838,16 +10838,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wl-clipboard-rs"
|
name = "wl-clipboard-rs"
|
||||||
version = "0.9.0"
|
version = "0.9.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
|
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"os_pipe",
|
"os_pipe",
|
||||||
"rustix 0.38.34",
|
"rustix 1.1.2",
|
||||||
"tempfile",
|
"thiserror 2.0.17",
|
||||||
"thiserror 1.0.61",
|
|
||||||
"tree_magic_mini",
|
"tree_magic_mini",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustdesk"
|
name = "rustdesk"
|
||||||
version = "1.4.6"
|
version = "1.4.8"
|
||||||
authors = ["rustdesk <info@rustdesk.com>"]
|
authors = ["rustdesk <info@rustdesk.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build= "build.rs"
|
build= "build.rs"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.6
|
version: 1.4.8
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.6
|
version: 1.4.8
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
35
build.py
35
build.py
@@ -17,7 +17,8 @@ osx = platform.platform().startswith(
|
|||||||
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
||||||
exe_path = 'target/release/' + hbb_name
|
exe_path = 'target/release/' + hbb_name
|
||||||
if windows:
|
if windows:
|
||||||
flutter_build_dir = 'build/windows/x64/runner/Release/'
|
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
|
||||||
|
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
|
||||||
elif osx:
|
elif osx:
|
||||||
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
||||||
else:
|
else:
|
||||||
@@ -172,7 +173,7 @@ def generate_build_script_for_docker():
|
|||||||
# flutter_rust_bridge
|
# flutter_rust_bridge
|
||||||
dart pub global activate ffigen --version 5.0.1
|
dart pub global activate ffigen --version 5.0.1
|
||||||
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
|
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
|
||||||
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd
|
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
|
||||||
pushd flutter && flutter pub get && popd
|
pushd flutter && flutter pub get && popd
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||||
# install vcpkg
|
# install vcpkg
|
||||||
@@ -299,7 +300,7 @@ Version: %s
|
|||||||
Architecture: %s
|
Architecture: %s
|
||||||
Maintainer: rustdesk <info@rustdesk.com>
|
Maintainer: rustdesk <info@rustdesk.com>
|
||||||
Homepage: https://rustdesk.com
|
Homepage: https://rustdesk.com
|
||||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||||
Recommends: libayatana-appindicator3-1
|
Recommends: libayatana-appindicator3-1
|
||||||
Description: A remote control software.
|
Description: A remote control software.
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ def ffi_bindgen_function_refactor():
|
|||||||
|
|
||||||
def build_flutter_deb(version, features):
|
def build_flutter_deb(version, features):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
ffi_bindgen_function_refactor()
|
ffi_bindgen_function_refactor()
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build linux --release')
|
system2('flutter build linux --release')
|
||||||
@@ -405,12 +406,17 @@ def build_flutter_dmg(version, features):
|
|||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
||||||
system2(
|
system2(
|
||||||
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release')
|
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
|
||||||
# copy dylib
|
# copy dylib
|
||||||
system2(
|
system2(
|
||||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build macos --release')
|
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
|
||||||
|
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
|
||||||
|
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
|
||||||
|
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
|
||||||
|
system2(
|
||||||
|
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
|
||||||
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
||||||
'''
|
'''
|
||||||
system2(
|
system2(
|
||||||
@@ -422,7 +428,7 @@ def build_flutter_dmg(version, features):
|
|||||||
|
|
||||||
def build_flutter_arch_manjaro(version, features):
|
def build_flutter_arch_manjaro(version, features):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
ffi_bindgen_function_refactor()
|
ffi_bindgen_function_refactor()
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build linux --release')
|
system2('flutter build linux --release')
|
||||||
@@ -433,7 +439,7 @@ def build_flutter_arch_manjaro(version, features):
|
|||||||
|
|
||||||
def build_flutter_windows(version, features, skip_portable_pack):
|
def build_flutter_windows(version, features, skip_portable_pack):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
if not os.path.exists("target/release/librustdesk.dll"):
|
if not os.path.exists("target/release/librustdesk.dll"):
|
||||||
print("cargo build failed, please check rust source code.")
|
print("cargo build failed, please check rust source code.")
|
||||||
exit(-1)
|
exit(-1)
|
||||||
@@ -489,13 +495,13 @@ def main():
|
|||||||
if windows:
|
if windows:
|
||||||
# build virtual display dynamic library
|
# build virtual display dynamic library
|
||||||
os.chdir('libs/virtual_display/dylib')
|
os.chdir('libs/virtual_display/dylib')
|
||||||
system2('cargo build --release')
|
system2('cargo build --locked --release')
|
||||||
os.chdir('../../..')
|
os.chdir('../../..')
|
||||||
|
|
||||||
if flutter:
|
if flutter:
|
||||||
build_flutter_windows(version, features, args.skip_portable_pack)
|
build_flutter_windows(version, features, args.skip_portable_pack)
|
||||||
return
|
return
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
# system2('upx.exe target/release/rustdesk.exe')
|
# system2('upx.exe target/release/rustdesk.exe')
|
||||||
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
||||||
pa = os.environ.get('P')
|
pa = os.environ.get('P')
|
||||||
@@ -506,6 +512,7 @@ def main():
|
|||||||
'target\\release\\rustdesk.exe')
|
'target\\release\\rustdesk.exe')
|
||||||
else:
|
else:
|
||||||
print('Not signed')
|
print('Not signed')
|
||||||
|
os.makedirs(res_dir, exist_ok=True)
|
||||||
system2(
|
system2(
|
||||||
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
||||||
os.chdir('libs/portable')
|
os.chdir('libs/portable')
|
||||||
@@ -519,7 +526,7 @@ def main():
|
|||||||
if flutter:
|
if flutter:
|
||||||
build_flutter_arch_manjaro(version, features)
|
build_flutter_arch_manjaro(version, features)
|
||||||
else:
|
else:
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('git checkout src/ui/common.tis')
|
system2('git checkout src/ui/common.tis')
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
||||||
@@ -528,7 +535,7 @@ def main():
|
|||||||
version, version))
|
version, version))
|
||||||
# pacman -U ./rustdesk.pkg.tar.zst
|
# pacman -U ./rustdesk.pkg.tar.zst
|
||||||
elif os.path.isfile('/usr/bin/yum'):
|
elif os.path.isfile('/usr/bin/yum'):
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2(
|
system2(
|
||||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
||||||
@@ -538,7 +545,7 @@ def main():
|
|||||||
version, version))
|
version, version))
|
||||||
# yum localinstall rustdesk.rpm
|
# yum localinstall rustdesk.rpm
|
||||||
elif os.path.isfile('/usr/bin/zypper'):
|
elif os.path.isfile('/usr/bin/zypper'):
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2(
|
system2(
|
||||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
||||||
@@ -557,7 +564,7 @@ def main():
|
|||||||
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
||||||
build_flutter_deb(version, features)
|
build_flutter_deb(version, features)
|
||||||
else:
|
else:
|
||||||
system2('cargo bundle --release --features ' + features)
|
system2('cargo --locked bundle --release --features ' + features)
|
||||||
if osx:
|
if osx:
|
||||||
system2(
|
system2(
|
||||||
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||||
<a href="#빌드를 위한 원시 단계">빌드</a> •
|
<a href="#빌드를_위한_원시_단계">빌드</a> •
|
||||||
<a href="#Docker로 빌드하는 방법">Docker</a> •
|
<a href="#Docker로_빌드하는_방법">Docker</a> •
|
||||||
<a href="#파일 구조">구조</a> •
|
<a href="#파일_구조">구조</a> •
|
||||||
<a href="#스크린샷">스냇샷</a><br>
|
<a href="#스크린샷">스냅샷</a><br>
|
||||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
|
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
|
||||||
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
|||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||||
|
|
||||||
## 빌드를 위한 원시 단계
|
## 빌드를_위한_원시_단계
|
||||||
|
|
||||||
- Rust 개발 환경과 C++ 빌드 환경을 준비합니다
|
- Rust 개발 환경과 C++ 빌드 환경 준비
|
||||||
|
|
||||||
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
|
|||||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker로 빌드하는 방법
|
## Docker로_빌드하는_방법
|
||||||
|
|
||||||
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ target/release/rustdesk
|
|||||||
|
|
||||||
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
||||||
|
|
||||||
## 파일 구조
|
## 파일_구조
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
||||||
<a href="#servidores-públicos-grátis">Servidores</a> •
|
<a href="#compilar">Compilar</a> •
|
||||||
<a href="#compilação-crua">Compilar</a> •
|
<a href="#como-compilar-com-o-docker">Docker</a> •
|
||||||
<a href="#como-compilar-com-docker">Docker</a> •
|
|
||||||
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
||||||
<a href="#screenshots">Screenshots</a><br>
|
<a href="#capturas-de-tela">Capturas de Tela</a><br>
|
||||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
[<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Francês</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
|
||||||
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b>
|
<b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> [!Caution]
|
||||||
|
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
|
||||||
|
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
|
||||||
|
|
||||||
|
|
||||||
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||||
|
|
||||||
[](https://rustdesk.com/pricing.html)
|
[](https://rustdesk.com/pricing.html)
|
||||||
|
|
||||||
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
|

|
||||||
|
|
||||||
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
|
||||||
|
|
||||||
|
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
|
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
|
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
|
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Baixe no F-Droid"
|
||||||
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||||
|
alt="Baixe no Flathub"
|
||||||
|
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||||
|
|
||||||
## Dependências
|
## Dependências
|
||||||
|
|
||||||
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria.
|
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
|
||||||
|
|
||||||
|
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
|
||||||
|
|
||||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||||
|
|
||||||
## Compilação crua
|
## Passos básicos para compilar
|
||||||
|
|
||||||
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
|
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
|
||||||
|
|
||||||
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
||||||
|
|
||||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
- Windows: `vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static`
|
||||||
- Linux/MacOS: vcpkg install libvpx libyuv opus aom
|
- Linux/macOS: `vcpkg install libvpx libyuv opus aom`
|
||||||
|
|
||||||
- Execute `cargo run`
|
- Execute `cargo run`
|
||||||
|
|
||||||
## Como compilar no Linux
|
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
|
||||||
|
|
||||||
|
## Como Compilar no Linux
|
||||||
|
|
||||||
### Ubuntu 18 (Debian 10)
|
### Ubuntu 18 (Debian 10)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake
|
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora 28 (CentOS 8)
|
### Fedora 28 (CentOS 8)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch (Manjaro)
|
### Arch (Manjaro)
|
||||||
@@ -58,7 +85,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
|
|||||||
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||||
```
|
```
|
||||||
|
|
||||||
### Instale vcpkg
|
### Instalar o vcpkg
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/microsoft/vcpkg
|
git clone https://github.com/microsoft/vcpkg
|
||||||
@@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
|
|||||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||||
```
|
```
|
||||||
|
|
||||||
### Conserte libvpx (Para o Fedora)
|
### Corrigir o libvpx (Para Fedora)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd vcpkg/buildtrees/libvpx/src
|
cd vcpkg/buildtrees/libvpx/src
|
||||||
@@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
|||||||
cd
|
cd
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile
|
### Compilar
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
source $HOME/.cargo/env
|
source $HOME/.cargo/env
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
mkdir -p target/debug
|
mkdir -p target/debug
|
||||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
@@ -96,57 +123,57 @@ mv libsciter-gtk.so target/debug
|
|||||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Como compilar com Docker
|
## Como compilar com o Docker
|
||||||
|
|
||||||
Comece clonando o repositório e montando o container docker:
|
Comece clonando o repositório e construindo o contêiner Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
Então, sempre que precisar compilar a aplicação, execute este comando:
|
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||||
```
|
```
|
||||||
|
|
||||||
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com:
|
Note que a primeira compilação pode demorar mais até que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, poderá fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/debug/rustdesk
|
target/debug/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou, se estiver rodando um executável de release:
|
Ou, se estiver executando o executável de lançamento:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/release/rustdesk
|
target/release/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host.
|
Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
|
||||||
|
|
||||||
## Estrutura de arquivos
|
## Estrutura de Arquivos
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
|
||||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
|
||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
|
||||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
|
||||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
|
||||||
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
|
||||||
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
|
||||||
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
|
||||||
|
|
||||||
> [!Cuidadob]
|
## Capturas de Tela
|
||||||
> **Aviso de uso indevido:** <br>
|
|
||||||
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
|
|
||||||
|
|
||||||
## Screenshots
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ if [ -z $release ]; then
|
|||||||
fi
|
fi
|
||||||
set -f
|
set -f
|
||||||
#shellcheck disable=2086
|
#shellcheck disable=2086
|
||||||
VCPKG_ROOT=/vcpkg cargo build $argv
|
VCPKG_ROOT=/vcpkg cargo build --locked $argv
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 604 B |
7
flutter/assets/display_switcher.svg
Normal file
7
flutter/assets/display_switcher.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<g fill="#000000" fill-rule="evenodd">
|
||||||
|
<rect x="4" y="6" width="24" height="16" rx="3"/>
|
||||||
|
<rect x="14.5" y="22" width="3" height="2"/>
|
||||||
|
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -460,6 +460,7 @@ build)
|
|||||||
--target "${RUST_TARGET}" \
|
--target "${RUST_TARGET}" \
|
||||||
--bindgen \
|
--bindgen \
|
||||||
build \
|
build \
|
||||||
|
--locked \
|
||||||
--release \
|
--release \
|
||||||
--features "${RUSTDESK_FEATURES}"
|
--features "${RUSTDESK_FEATURES}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo build --features flutter --release --target x86_64-apple-ios --lib
|
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
|
||||||
|
|||||||
@@ -598,6 +598,22 @@ class MyTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies [fallbacks] as fontFamilyFallback to every text style in both
|
||||||
|
/// themes. Called once at startup on ARM64 Linux after a CJK font has been
|
||||||
|
/// loaded via FontLoader (see flutter/flutter#139293).
|
||||||
|
static void applyFontFallback(List<String> fallbacks) {
|
||||||
|
lightTheme = lightTheme.copyWith(
|
||||||
|
textTheme: lightTheme.textTheme.apply(fontFamilyFallback: fallbacks),
|
||||||
|
primaryTextTheme:
|
||||||
|
lightTheme.primaryTextTheme.apply(fontFamilyFallback: fallbacks),
|
||||||
|
);
|
||||||
|
darkTheme = darkTheme.copyWith(
|
||||||
|
textTheme: darkTheme.textTheme.apply(fontFamilyFallback: fallbacks),
|
||||||
|
primaryTextTheme:
|
||||||
|
darkTheme.primaryTextTheme.apply(fontFamilyFallback: fallbacks),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static ThemeMode currentThemeMode() {
|
static ThemeMode currentThemeMode() {
|
||||||
final preference = getThemeModePreference();
|
final preference = getThemeModePreference();
|
||||||
if (preference == ThemeMode.system) {
|
if (preference == ThemeMode.system) {
|
||||||
@@ -716,6 +732,17 @@ closeConnection({String? id}) {
|
|||||||
stateGlobal.isInMainPage = true;
|
stateGlobal.isInMainPage = true;
|
||||||
} else {
|
} else {
|
||||||
final controller = Get.find<DesktopTabController>();
|
final controller = Get.find<DesktopTabController>();
|
||||||
|
if (controller.tabType == DesktopTabType.terminal &&
|
||||||
|
controller.onCloseWindow != null) {
|
||||||
|
// Terminal windows are scoped to one peer. The optional id passed to
|
||||||
|
// closeConnection() is that peer id, not a terminal tab key
|
||||||
|
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||||
|
// the peer's whole terminal window, including all terminal tabs.
|
||||||
|
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||||
|
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller.closeBy(id);
|
controller.closeBy(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3702,14 +3729,54 @@ Widget loadPowered(BuildContext context) {
|
|||||||
).marginOnly(top: 6);
|
).marginOnly(top: 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// max 300 x 60
|
const _kDefaultLogoAsset = 'assets/logo.png';
|
||||||
Widget loadLogo() {
|
const _kLightLogoAsset = 'assets/logo_light.png';
|
||||||
return FutureBuilder<ByteData>(
|
const _kDarkLogoAsset = 'assets/logo_dark.png';
|
||||||
future: rootBundle.load('assets/logo.png'),
|
|
||||||
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
|
||||||
if (snapshot.hasData) {
|
return brightness == Brightness.dark
|
||||||
|
? [_kDarkLogoAsset, _kDefaultLogoAsset]
|
||||||
|
: [_kLightLogoAsset, _kDefaultLogoAsset];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _resolveLogoAsset(Brightness brightness) async {
|
||||||
|
for (final asset in _logoAssetCandidatesForBrightness(brightness)) {
|
||||||
|
try {
|
||||||
|
await rootBundle.load(asset);
|
||||||
|
return asset;
|
||||||
|
} on FlutterError {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Logo extends StatefulWidget {
|
||||||
|
const _Logo();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Logo> createState() => _LogoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogoState extends State<_Logo> {
|
||||||
|
final Map<Brightness, Future<String?>> _logoFutures = {};
|
||||||
|
|
||||||
|
Future<String?> _logoFutureFor(Brightness brightness) {
|
||||||
|
return _logoFutures.putIfAbsent(
|
||||||
|
brightness,
|
||||||
|
() => _resolveLogoAsset(brightness),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<String?>(
|
||||||
|
future: _logoFutureFor(Theme.of(context).brightness),
|
||||||
|
builder: (BuildContext context, AsyncSnapshot<String?> snapshot) {
|
||||||
|
final asset = snapshot.data;
|
||||||
|
if (asset != null) {
|
||||||
final image = Image.asset(
|
final image = Image.asset(
|
||||||
'assets/logo.png',
|
asset,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (ctx, error, stackTrace) {
|
errorBuilder: (ctx, error, stackTrace) {
|
||||||
return Container();
|
return Container();
|
||||||
@@ -3721,9 +3788,14 @@ Widget loadLogo() {
|
|||||||
).marginOnly(left: 12, right: 12, top: 12);
|
).marginOnly(left: 12, right: 12, top: 12);
|
||||||
}
|
}
|
||||||
return const Offstage();
|
return const Offstage();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// max 300 x 60
|
||||||
|
Widget loadLogo() => const _Logo();
|
||||||
|
|
||||||
Widget loadIcon(double size) {
|
Widget loadIcon(double size) {
|
||||||
return Image.asset('assets/icon.png',
|
return Image.asset('assets/icon.png',
|
||||||
width: size,
|
width: size,
|
||||||
@@ -4179,8 +4251,7 @@ Widget? buildAvatarWidget({
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||||
fallback ?? SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||||
import '../../../models/platform_model.dart';
|
import '../../../models/platform_model.dart';
|
||||||
@@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
|||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<Peer> mergeAutocompletePeers({
|
||||||
|
Iterable<Peer> addressBookPeers = const [],
|
||||||
|
Iterable<Peer> groupPeers = const [],
|
||||||
|
Iterable<Peer> lanPeers = const [],
|
||||||
|
Iterable<Peer> recentPeers = const [],
|
||||||
|
Iterable<String> restRecentPeerIds = const [],
|
||||||
|
}) {
|
||||||
|
final combinedPeers = <String, Peer>{};
|
||||||
|
|
||||||
|
void addPeer(Peer peer) {
|
||||||
|
if (peer.id.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final existingPeer = combinedPeers[peer.id];
|
||||||
|
if (existingPeer == null) {
|
||||||
|
combinedPeers[peer.id] = Peer.copy(peer);
|
||||||
|
} else if (peer.online) {
|
||||||
|
existingPeer.online = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final peer in addressBookPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in groupPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in lanPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in recentPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final id in restRecentPeerIds) {
|
||||||
|
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
|
||||||
|
combinedPeers[id] = Peer.fromJson({'id': id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedPeers.values.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool updateAutocompletePeerOnlineStates(
|
||||||
|
List<Peer> peers, {
|
||||||
|
required Set<String> onlines,
|
||||||
|
required Set<String> offlines,
|
||||||
|
}) {
|
||||||
|
var changed = false;
|
||||||
|
for (final peer in peers) {
|
||||||
|
if (onlines.contains(peer.id)) {
|
||||||
|
if (!peer.online) {
|
||||||
|
peer.online = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (offlines.contains(peer.id)) {
|
||||||
|
if (peer.online) {
|
||||||
|
peer.online = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<String> autocompleteOnlineQueryIds(
|
||||||
|
Iterable<Peer> options, {
|
||||||
|
required int limit,
|
||||||
|
}) {
|
||||||
|
final ids = <String>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
for (final peer in options) {
|
||||||
|
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenIds.add(peer.id);
|
||||||
|
ids.add(peer.id);
|
||||||
|
if (ids.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
class AllPeersLoader {
|
class AllPeersLoader {
|
||||||
List<Peer> peers = [];
|
List<Peer> peers = [];
|
||||||
|
|
||||||
bool _isPeersLoading = false;
|
bool _isPeersLoading = false;
|
||||||
bool _isPeersLoaded = false;
|
bool _isPeersLoaded = false;
|
||||||
|
Set<String> _lastQueryOnlineIds = {};
|
||||||
|
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
Timer? _queryOnlineTimer;
|
||||||
|
List<Peer> _lastQueryOnlineOptions = const [];
|
||||||
|
Set<String> _lastOnlineIds = {};
|
||||||
|
Set<String> _lastOfflineIds = {};
|
||||||
|
final Future<void> Function(List<String> ids) _queryOnlines;
|
||||||
|
final Duration _queryOnlineDebounce;
|
||||||
|
void Function(VoidCallback)? _setState;
|
||||||
|
bool _isCleared = false;
|
||||||
|
|
||||||
final String _listenerKey = 'AllPeersLoader';
|
final String _listenerKey = 'AllPeersLoader';
|
||||||
|
static const String _cbQueryOnlines = 'callback_query_onlines';
|
||||||
late void Function(VoidCallback) setState;
|
static const Duration _queryOnlineInterval = Duration(seconds: 5);
|
||||||
|
static const Duration _defaultQueryOnlineDebounce =
|
||||||
|
Duration(milliseconds: 300);
|
||||||
|
static const int _maxQueryOnlineOptions = 20;
|
||||||
|
|
||||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||||
bool get isPeersLoaded => _isPeersLoaded;
|
bool get isPeersLoaded => _isPeersLoaded;
|
||||||
|
|
||||||
AllPeersLoader();
|
AllPeersLoader({
|
||||||
|
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||||
|
@visibleForTesting Duration? queryOnlineDebounce,
|
||||||
|
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||||
|
_queryOnlineDebounce =
|
||||||
|
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||||
|
|
||||||
void init(void Function(VoidCallback) setState) {
|
void init(void Function(VoidCallback) setState) {
|
||||||
this.setState = setState;
|
_setState = setState;
|
||||||
|
_isCleared = false;
|
||||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||||
|
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
|
||||||
|
(evt) async {
|
||||||
|
_updateOnlineState(evt);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -33,6 +145,11 @@ class AllPeersLoader {
|
|||||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||||
|
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
|
||||||
|
_queryOnlineTimer?.cancel();
|
||||||
|
_lastQueryOnlineOptions = const [];
|
||||||
|
_setState = null;
|
||||||
|
_isCleared = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getAllPeers() async {
|
Future<void> getAllPeers() async {
|
||||||
@@ -59,50 +176,106 @@ class AllPeersLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _mergeAllPeers() {
|
void _mergeAllPeers() {
|
||||||
Map<String, dynamic> combinedPeers = {};
|
if (_isCleared) {
|
||||||
for (var p in gFFI.abModel.allPeers()) {
|
return;
|
||||||
if (!combinedPeers.containsKey(p.id)) {
|
|
||||||
combinedPeers[p.id] = p.toJson();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
peers = mergeAutocompletePeers(
|
||||||
if (!combinedPeers.containsKey(p.id)) {
|
addressBookPeers: gFFI.abModel.allPeers(),
|
||||||
combinedPeers[p.id] = p.toJson();
|
groupPeers: gFFI.groupModel.peers,
|
||||||
}
|
lanPeers: gFFI.lanPeersModel.peers,
|
||||||
}
|
recentPeers: gFFI.recentPeersModel.peers,
|
||||||
|
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
|
||||||
List<Peer> parsedPeers = [];
|
);
|
||||||
for (var peer in combinedPeers.values) {
|
_applyLastOnlineState(peers);
|
||||||
parsedPeers.add(Peer.fromJson(peer));
|
_scheduleSetState(() {
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
|
||||||
for (final peer in gFFI.lanPeersModel.peers) {
|
|
||||||
if (!peerIds.contains(peer.id)) {
|
|
||||||
parsedPeers.add(peer);
|
|
||||||
peerIds.add(peer.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final peer in gFFI.recentPeersModel.peers) {
|
|
||||||
if (!peerIds.contains(peer.id)) {
|
|
||||||
parsedPeers.add(peer);
|
|
||||||
peerIds.add(peer.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
|
||||||
if (!peerIds.contains(id)) {
|
|
||||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
|
||||||
peerIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peers = parsedPeers;
|
|
||||||
setState(() {
|
|
||||||
_isPeersLoading = false;
|
_isPeersLoading = false;
|
||||||
_isPeersLoaded = true;
|
_isPeersLoaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastOnlineIds = _splitPeerIds(evt['onlines']);
|
||||||
|
_lastOfflineIds = _splitPeerIds(evt['offlines']);
|
||||||
|
final peersChanged = _applyLastOnlineState(peers);
|
||||||
|
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
|
||||||
|
if (peersChanged || optionsChanged) {
|
||||||
|
_scheduleSetState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSetState(VoidCallback callback) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final setState = _setState;
|
||||||
|
if (setState == null) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
setState(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _applyLastOnlineState(List<Peer> peers) {
|
||||||
|
return updateAutocompletePeerOnlineStates(
|
||||||
|
peers,
|
||||||
|
onlines: _lastOnlineIds,
|
||||||
|
offlines: _lastOfflineIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _splitPeerIds(dynamic ids) {
|
||||||
|
if (ids is! String || ids.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return ids.split(',').where((id) => id.isNotEmpty).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
void queryOnlines(Iterable<Peer> options) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastQueryOnlineOptions = options.toList(growable: false);
|
||||||
|
final ids = autocompleteOnlineQueryIds(
|
||||||
|
_lastQueryOnlineOptions,
|
||||||
|
limit: _maxQueryOnlineOptions,
|
||||||
|
).toSet();
|
||||||
|
_queryOnlineTimer?.cancel();
|
||||||
|
_queryOnlineTimer = null;
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (setEquals(ids, _lastQueryOnlineIds) &&
|
||||||
|
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
|
||||||
|
try {
|
||||||
|
await _queryOnlines(ids.toList(growable: false));
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastQueryOnlineIds = ids;
|
||||||
|
_lastQueryOnlineTime = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('query autocomplete online state failed: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
|
||||||
|
_updateOnlineState(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool applyLastOnlineStateForTesting(List<Peer> peers) {
|
||||||
|
return _applyLastOnlineState(peers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutocompletePeerTile extends StatefulWidget {
|
class AutocompletePeerTile extends StatefulWidget {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../../../consts.dart';
|
|
||||||
import '../../../models/platform_model.dart';
|
|
||||||
import 'shortcut_utils.dart';
|
|
||||||
|
|
||||||
/// Read the bindings JSON and produce a human-readable shortcut string for
|
|
||||||
/// `actionId`, formatted for the current OS. Returns null if unbound, or —
|
|
||||||
/// when [requireEnabled] is true (the default) — when the master toggle is
|
|
||||||
/// off. The configuration page passes `requireEnabled: false` so users still
|
|
||||||
/// see what they have bound while the feature is disabled.
|
|
||||||
class ShortcutDisplay {
|
|
||||||
// Cache parsed JSON keyed by the raw string — called per visible action on
|
|
||||||
// every menu rebuild, so the jsonDecode is the real cost. Invalidation is
|
|
||||||
// automatic: a write changes the raw and we re-parse.
|
|
||||||
static String? _cachedRaw;
|
|
||||||
static Map<String, dynamic>? _cachedParsed;
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
static void resetCache() {
|
|
||||||
_cachedRaw = null;
|
|
||||||
_cachedParsed = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? formatFor(String actionId, {bool requireEnabled = true}) {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
if (raw.isEmpty) return null;
|
|
||||||
Map<String, dynamic>? parsed;
|
|
||||||
if (raw == _cachedRaw) {
|
|
||||||
parsed = _cachedParsed;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
parsed = null;
|
|
||||||
}
|
|
||||||
_cachedRaw = raw;
|
|
||||||
_cachedParsed = parsed;
|
|
||||||
}
|
|
||||||
if (parsed == null) return null;
|
|
||||||
if (requireEnabled && parsed['enabled'] != true) return null;
|
|
||||||
// When pass-through is on, the matcher returns early on every keystroke.
|
|
||||||
// Showing the bound combo next to a menu item would lie to the user — they
|
|
||||||
// would press it expecting the local action and instead the keys would go
|
|
||||||
// to the remote. Treat as unbound for display purposes.
|
|
||||||
if (requireEnabled && parsed['pass_through'] == true) return null;
|
|
||||||
final list = shortcutBindingMapsFrom(parsed['bindings']);
|
|
||||||
final found = list.firstWhere(
|
|
||||||
(b) => b['action'] == actionId,
|
|
||||||
orElse: () => {},
|
|
||||||
);
|
|
||||||
if (found.isEmpty) return null;
|
|
||||||
|
|
||||||
// Guard against a hand-edited / corrupt config where `key` is missing or
|
|
||||||
// not a string — silently treat the binding as unbound rather than
|
|
||||||
// crashing the toolbar render.
|
|
||||||
final keyValue = found['key'];
|
|
||||||
if (keyValue is! String) return null;
|
|
||||||
|
|
||||||
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
|
||||||
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
|
||||||
final modsRaw = found['mods'];
|
|
||||||
final mods = modsRaw is List
|
|
||||||
? modsRaw.whereType<String>().toList()
|
|
||||||
: const <String>[];
|
|
||||||
// Plain-text labels (Cmd / Ctrl / Alt / Shift) instead of Unicode glyphs
|
|
||||||
// (⌘ ⌃ ⌥ ⇧). Flutter Web's CanvasKit bundled fonts don't always carry the
|
|
||||||
// macOS modifier symbols, which renders as garbled boxes on Mac browsers;
|
|
||||||
// text is portable and readable on every platform.
|
|
||||||
//
|
|
||||||
// Order matches the canonical macOS order (Cmd, Control, Option, Shift)
|
|
||||||
// so the rendered hint reads naturally. `ctrl` only ever appears in
|
|
||||||
// saved bindings on macOS — Win/Linux collapses Ctrl into `primary`.
|
|
||||||
final parts = <String>[];
|
|
||||||
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
|
|
||||||
if (!mods.contains(m)) continue;
|
|
||||||
switch (m) {
|
|
||||||
case 'primary': parts.add(isMac ? 'Cmd' : 'Ctrl'); break;
|
|
||||||
case 'ctrl': parts.add(isMac ? 'Control' : 'Ctrl'); break;
|
|
||||||
case 'alt': parts.add(isMac ? 'Option' : 'Alt'); break;
|
|
||||||
case 'shift': parts.add('Shift'); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.add(_keyDisplay(keyValue));
|
|
||||||
return parts.join('+');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _keyDisplay(String key) {
|
|
||||||
switch (key) {
|
|
||||||
case 'delete': return 'Del';
|
|
||||||
case 'backspace': return 'Backspace';
|
|
||||||
case 'enter': return 'Enter';
|
|
||||||
case 'tab': return 'Tab';
|
|
||||||
case 'space': return 'Space';
|
|
||||||
case 'arrow_left': return 'Left';
|
|
||||||
case 'arrow_right':return 'Right';
|
|
||||||
case 'arrow_up': return 'Up';
|
|
||||||
case 'arrow_down': return 'Down';
|
|
||||||
case 'home': return 'Home';
|
|
||||||
case 'end': return 'End';
|
|
||||||
case 'page_up': return 'PgUp';
|
|
||||||
case 'page_down': return 'PgDn';
|
|
||||||
case 'insert': return 'Ins';
|
|
||||||
}
|
|
||||||
if (key.startsWith('digit')) return key.substring(5);
|
|
||||||
// F-keys ("f1".."f12") and single letters fall through to uppercase.
|
|
||||||
return key.toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
|
|
||||||
//
|
|
||||||
// Shared body widget for the Keyboard Shortcuts configuration page. Both the
|
|
||||||
// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile
|
|
||||||
// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this
|
|
||||||
// widget inside their own platform-styled Scaffold + AppBar shell.
|
|
||||||
//
|
|
||||||
// The body owns:
|
|
||||||
// * the top-level enable/disable toggle (mirrors the General-tab toggle —
|
|
||||||
// same JSON key, same semantics);
|
|
||||||
// * a grouped list of actions, each with its current binding plus
|
|
||||||
// edit / clear icons;
|
|
||||||
// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the
|
|
||||||
// canonical {enabled, bindings:[{action,mods,key}]} shape;
|
|
||||||
// * the recording-dialog round-trip and conflict-replace bookkeeping;
|
|
||||||
// * "Reset to defaults" (called from the platform AppBar).
|
|
||||||
//
|
|
||||||
// Platform shells supply only:
|
|
||||||
// * the AppBar (with a "Reset to defaults" action that calls
|
|
||||||
// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]);
|
|
||||||
// * surrounding padding / list-tile vs. dense-row visuals via the
|
|
||||||
// [compact] flag.
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../../common.dart';
|
|
||||||
import '../../../consts.dart';
|
|
||||||
import '../../../models/platform_model.dart';
|
|
||||||
import '../../../models/shortcut_model.dart';
|
|
||||||
import 'display.dart';
|
|
||||||
import 'recording_dialog.dart';
|
|
||||||
import 'shortcut_actions.dart';
|
|
||||||
import 'shortcut_utils.dart';
|
|
||||||
|
|
||||||
/// The shared body widget. Render this inside a platform-styled Scaffold.
|
|
||||||
///
|
|
||||||
/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile
|
|
||||||
/// touch-friendly ListTile layout (`false`).
|
|
||||||
///
|
|
||||||
/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells
|
|
||||||
/// use this to clarify that recording requires a physical keyboard.
|
|
||||||
///
|
|
||||||
/// [headerBanner] is an optional widget rendered above the toggle. Mobile
|
|
||||||
/// uses this to show the "Recording requires a physical keyboard" hint.
|
|
||||||
class KeyboardShortcutsPageBody extends StatefulWidget {
|
|
||||||
final bool compact;
|
|
||||||
final String? editButtonHint;
|
|
||||||
final Widget? headerBanner;
|
|
||||||
|
|
||||||
/// Whether to render the master Enable + Pass-through toggles inside the
|
|
||||||
/// body. Desktop shells set this to false because the General settings tab
|
|
||||||
/// already exposes both checkboxes (and is the only entry point to this
|
|
||||||
/// page on desktop). Mobile defaults to true: its entry point is a plain
|
|
||||||
/// nav tile in Settings, so this page is the only place the user can
|
|
||||||
/// flip the master switches.
|
|
||||||
final bool showMasterToggles;
|
|
||||||
|
|
||||||
const KeyboardShortcutsPageBody({
|
|
||||||
Key? key,
|
|
||||||
this.compact = true,
|
|
||||||
this.editButtonHint,
|
|
||||||
this.headerBanner,
|
|
||||||
this.showMasterToggles = true,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<KeyboardShortcutsPageBody> createState() =>
|
|
||||||
KeyboardShortcutsPageBodyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
|
|
||||||
/// their AppBar action.
|
|
||||||
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
|
|
||||||
// ----- Persistence helpers -----
|
|
||||||
|
|
||||||
Map<String, dynamic> _readJson() {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
parsed['bindings'] ??= <dynamic>[];
|
|
||||||
parsed['enabled'] ??= false;
|
|
||||||
return parsed;
|
|
||||||
} catch (_) {
|
|
||||||
return {'enabled': false, 'bindings': <dynamic>[]};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _writeJson(Map<String, dynamic> json) async {
|
|
||||||
await bind.mainSetLocalOption(
|
|
||||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
|
||||||
// Refresh the matcher cache so writes take effect immediately. On native
|
|
||||||
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
|
|
||||||
// matcher in flutter/web/js/.
|
|
||||||
bind.mainReloadKeyboardShortcuts();
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
|
|
||||||
/// is null, removes the existing entry. If the user is replacing a
|
|
||||||
/// conflicting binding, [clearActionId] points at the action whose
|
|
||||||
/// (now-stale) binding should be removed in the same write.
|
|
||||||
Future<void> _setBinding(
|
|
||||||
String actionId, {
|
|
||||||
Map<String, dynamic>? binding,
|
|
||||||
String? clearActionId,
|
|
||||||
}) async {
|
|
||||||
final json = _readJson();
|
|
||||||
final list = shortcutBindingMapsFrom(json['bindings']);
|
|
||||||
list.removeWhere((b) {
|
|
||||||
final a = b['action'];
|
|
||||||
return a == actionId || (clearActionId != null && a == clearActionId);
|
|
||||||
});
|
|
||||||
if (binding != null) {
|
|
||||||
list.add(binding);
|
|
||||||
}
|
|
||||||
json['bindings'] = list;
|
|
||||||
await _writeJson(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _setEnabled(bool v) async {
|
|
||||||
await ShortcutModel.setEnabled(v);
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _setPassThrough(bool v) async {
|
|
||||||
await ShortcutModel.setPassThrough(v);
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _resetToDefaults() async {
|
|
||||||
final json = _readJson();
|
|
||||||
// Single source of truth lives in `ShortcutModel.currentPlatformCapabilities`
|
|
||||||
// — the same helper feeds the first-enable seed pass, this Reset action,
|
|
||||||
// and the action-list filter below, so the three can never disagree on
|
|
||||||
// which actions belong on this platform.
|
|
||||||
json['bindings'] = filterDefaultBindingsForPlatform(
|
|
||||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
|
|
||||||
ShortcutModel.currentPlatformCapabilities(),
|
|
||||||
);
|
|
||||||
await _writeJson(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _labelFor(String actionId) {
|
|
||||||
// Intentionally walks the unfiltered list (via the recursive helper, so
|
|
||||||
// both direct entries and subgroup entries are covered) — a stale
|
|
||||||
// cross-platform binding (e.g. Toggle Toolbar carried over from
|
|
||||||
// desktop) should still resolve to its human-readable label in conflict
|
|
||||||
// warnings.
|
|
||||||
for (final entry in allActionEntries(kKeyboardShortcutActionGroups)) {
|
|
||||||
if (entry.id == actionId) return translate(entry.labelKey);
|
|
||||||
}
|
|
||||||
return actionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action groups visible on the current platform. Reads the same
|
|
||||||
/// capability set as the seed-defaults / reset-to-defaults paths from
|
|
||||||
/// `ShortcutModel.currentPlatformCapabilities`, so the UI lists exactly
|
|
||||||
/// the actions whose handlers the matcher can dispatch here.
|
|
||||||
List<KeyboardShortcutActionGroup> _groupsForCurrentPlatform() {
|
|
||||||
return filterKeyboardShortcutActionGroupsForPlatform(
|
|
||||||
ShortcutModel.currentPlatformCapabilities(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- UI handlers -----
|
|
||||||
|
|
||||||
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
|
|
||||||
final json = _readJson();
|
|
||||||
final bindings = shortcutBindingMapsFrom(json['bindings']);
|
|
||||||
final result = await showRecordingDialog(
|
|
||||||
context: context,
|
|
||||||
actionId: entry.id,
|
|
||||||
actionLabel: translate(entry.labelKey),
|
|
||||||
existingBindings: bindings,
|
|
||||||
actionLabelLookup: _labelFor,
|
|
||||||
);
|
|
||||||
if (result == null) return;
|
|
||||||
await _setBinding(
|
|
||||||
entry.id,
|
|
||||||
binding: result.binding,
|
|
||||||
clearActionId: result.clearActionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
|
|
||||||
await _setBinding(entry.id, binding: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public — invoked from the platform AppBar action.
|
|
||||||
Future<void> resetToDefaultsWithConfirm() async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text(translate('Reset to defaults')),
|
|
||||||
content: Text(translate('shortcut-reset-confirm-tip')),
|
|
||||||
actions: [
|
|
||||||
dialogButton('Cancel',
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false), isOutline: true),
|
|
||||||
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirmed == true) {
|
|
||||||
await _resetToDefaults();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- Build -----
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final enabled = ShortcutModel.isEnabled();
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
if (widget.headerBanner != null) ...[
|
|
||||||
widget.headerBanner!,
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (widget.showMasterToggles) ...[
|
|
||||||
_toggleRow(
|
|
||||||
enabled,
|
|
||||||
'Enable keyboard shortcuts in remote session',
|
|
||||||
(v) => _setEnabled(v),
|
|
||||||
),
|
|
||||||
if (enabled)
|
|
||||||
_toggleRow(
|
|
||||||
ShortcutModel.isPassThrough(),
|
|
||||||
'Pass-through to remote',
|
|
||||||
(v) => _setPassThrough(v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Text(
|
|
||||||
translate('shortcut-page-description'),
|
|
||||||
style: TextStyle(color: theme.hintColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Bindings list and configuration entry only show when shortcuts are
|
|
||||||
// enabled — there is nothing to configure while the matcher is off.
|
|
||||||
if (enabled)
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
for (final group in _groupsForCurrentPlatform())
|
|
||||||
_buildGroup(context, group),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _toggleRow(
|
|
||||||
bool value, String labelKey, Future<void> Function(bool) onChanged,
|
|
||||||
{String? tooltipKey}) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: value,
|
|
||||||
onChanged: (v) async {
|
|
||||||
if (v == null) return;
|
|
||||||
await onChanged(v);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () => onChanged(!value),
|
|
||||||
child: Text(translate(labelKey)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (tooltipKey != null) InfoTooltipIcon(tipKey: tooltipKey),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One indent unit per nesting level. Both "top item under top heading"
|
|
||||||
// and "subgroup heading under top group" are *one* level deeper than the
|
|
||||||
// top heading, so they share this indent — meaning a top-level direct
|
|
||||||
// item and a sibling subgroup heading line up at exactly the same x.
|
|
||||||
// Subgroup items are *two* levels deeper.
|
|
||||||
static const double _kIndentStep = 16.0;
|
|
||||||
|
|
||||||
/// Top-level group: heading at zero indent, then walk `children` in
|
|
||||||
/// declaration order. Direct entries get [_kIndentStep] of indent so
|
|
||||||
/// they read as "items under this heading"; subgroup headings sit at
|
|
||||||
/// the same indent (a subgroup is a sibling of the direct items, just
|
|
||||||
/// with its own nested entries below).
|
|
||||||
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildHeading(context, group.titleKey, isSub: false),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
for (final child in group.children)
|
|
||||||
switch (child) {
|
|
||||||
KeyboardShortcutActionEntry() => Padding(
|
|
||||||
padding: const EdgeInsets.only(left: _kIndentStep),
|
|
||||||
child: _buildEntryRow(context, child),
|
|
||||||
),
|
|
||||||
KeyboardShortcutActionSubgroup() =>
|
|
||||||
_buildSubgroup(context, child),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSubgroup(
|
|
||||||
BuildContext context, KeyboardShortcutActionSubgroup subgroup) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildHeading(context, subgroup.titleKey, isSub: true),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
for (final entry in subgroup.entries)
|
|
||||||
Padding(
|
|
||||||
// Two indent steps: one for "subgroup heading is nested under
|
|
||||||
// top heading" (matches the heading's own indent) and one for
|
|
||||||
// "this entry is under the subgroup heading".
|
|
||||||
padding: const EdgeInsets.only(left: _kIndentStep * 2),
|
|
||||||
child: _buildEntryRow(context, entry),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeading(BuildContext context, String titleKey,
|
|
||||||
{required bool isSub}) {
|
|
||||||
// Subgroup heading nests one step under the top heading — same indent
|
|
||||||
// as a top-level direct item, so the two line up at the same x.
|
|
||||||
final indent = isSub ? _kIndentStep : 0.0;
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(left: 8 + indent, right: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
translate(titleKey),
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: isSub ? FontWeight.w500 : FontWeight.w600,
|
|
||||||
color: isSub
|
|
||||||
? Theme.of(context).hintColor
|
|
||||||
: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Divider(thickness: isSub ? 0.5 : 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEntryRow(
|
|
||||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
|
||||||
return widget.compact
|
|
||||||
? _buildCompactRow(context, entry)
|
|
||||||
: _buildTouchRow(context, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
|
|
||||||
Widget _buildCompactRow(
|
|
||||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
|
||||||
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
|
|
||||||
final hasBinding = shortcut != null;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
|
||||||
child: Text(translate(entry.labelKey)),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 4,
|
|
||||||
child: Text(
|
|
||||||
shortcut ?? '—',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
|
||||||
? 'Consolas'
|
|
||||||
: 'monospace',
|
|
||||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
|
||||||
onPressed: () => _onEdit(entry),
|
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: hasBinding
|
|
||||||
? IconButton(
|
|
||||||
tooltip: translate('Clear'),
|
|
||||||
onPressed: () => _onClear(entry),
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mobile touch row: ListTile with title + subtitle + trailing icons.
|
|
||||||
Widget _buildTouchRow(
|
|
||||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
|
||||||
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
|
|
||||||
final hasBinding = shortcut != null;
|
|
||||||
return ListTile(
|
|
||||||
dense: false,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
title: Text(translate(entry.labelKey)),
|
|
||||||
subtitle: Text(
|
|
||||||
shortcut ?? '—',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
|
||||||
? 'Consolas'
|
|
||||||
: 'monospace',
|
|
||||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
|
||||||
onPressed: () => _onEdit(entry),
|
|
||||||
icon: const Icon(Icons.edit_outlined),
|
|
||||||
),
|
|
||||||
if (hasBinding)
|
|
||||||
IconButton(
|
|
||||||
tooltip: translate('Clear'),
|
|
||||||
onPressed: () => _onClear(entry),
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(width: 48),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Small help-icon tooltip used for inline explanations next to a checkbox /
|
|
||||||
/// row. Triggers on hover (desktop) and tap (mobile). Public so the desktop
|
|
||||||
/// General settings tab can reuse it.
|
|
||||||
class InfoTooltipIcon extends StatelessWidget {
|
|
||||||
final String tipKey;
|
|
||||||
const InfoTooltipIcon({Key? key, required this.tipKey}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Tooltip(
|
|
||||||
message: translate(tipKey),
|
|
||||||
triggerMode: TooltipTriggerMode.tap,
|
|
||||||
preferBelow: false,
|
|
||||||
waitDuration: const Duration(milliseconds: 250),
|
|
||||||
showDuration: const Duration(seconds: 6),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
child: Icon(
|
|
||||||
Icons.help_outline,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).hintColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart
|
|
||||||
//
|
|
||||||
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new
|
|
||||||
// key combination for a given action. The dialog listens for KeyDown events,
|
|
||||||
// extracts the modifier set + non-modifier key, validates that at least one
|
|
||||||
// modifier is present, and reports any conflict with another already-bound
|
|
||||||
// action.
|
|
||||||
//
|
|
||||||
// On Save, returns the new binding map ({action, mods, key}) plus the
|
|
||||||
// optional id of the action whose binding should be cleared (the conflict
|
|
||||||
// "Replace" path). On Cancel, returns null.
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
import '../../../common.dart';
|
|
||||||
import 'shortcut_utils.dart';
|
|
||||||
|
|
||||||
/// Result of the recording dialog.
|
|
||||||
class RecordingResult {
|
|
||||||
/// The new binding map to write: {action, mods, key}.
|
|
||||||
final Map<String, dynamic> binding;
|
|
||||||
|
|
||||||
/// If the chosen combo conflicted with another action, the user chose
|
|
||||||
/// "Replace" — the caller must clear this action's binding before writing
|
|
||||||
/// the new one.
|
|
||||||
final String? clearActionId;
|
|
||||||
|
|
||||||
RecordingResult(this.binding, this.clearActionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show the recording dialog.
|
|
||||||
///
|
|
||||||
/// [actionId] is the action being edited (used for the title and to detect
|
|
||||||
/// "binding to itself" — that's not a conflict).
|
|
||||||
/// [actionLabel] is the translated, user-facing action name.
|
|
||||||
/// [existingBindings] is the current bindings list (used for conflict detection).
|
|
||||||
/// [actionLabelLookup] resolves an actionId to its translated label, used in
|
|
||||||
/// the conflict warning.
|
|
||||||
Future<RecordingResult?> showRecordingDialog({
|
|
||||||
required BuildContext context,
|
|
||||||
required String actionId,
|
|
||||||
required String actionLabel,
|
|
||||||
required List<Map<String, dynamic>> existingBindings,
|
|
||||||
required String Function(String) actionLabelLookup,
|
|
||||||
}) {
|
|
||||||
return showDialog<RecordingResult>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (ctx) => _RecordingDialog(
|
|
||||||
actionId: actionId,
|
|
||||||
actionLabel: actionLabel,
|
|
||||||
existingBindings: existingBindings,
|
|
||||||
actionLabelLookup: actionLabelLookup,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecordingDialog extends StatefulWidget {
|
|
||||||
final String actionId;
|
|
||||||
final String actionLabel;
|
|
||||||
final List<Map<String, dynamic>> existingBindings;
|
|
||||||
final String Function(String) actionLabelLookup;
|
|
||||||
|
|
||||||
const _RecordingDialog({
|
|
||||||
required this.actionId,
|
|
||||||
required this.actionLabel,
|
|
||||||
required this.existingBindings,
|
|
||||||
required this.actionLabelLookup,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RecordingDialog> createState() => _RecordingDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecordingDialogState extends State<_RecordingDialog> {
|
|
||||||
final FocusNode _focusNode = FocusNode();
|
|
||||||
|
|
||||||
// Captured combo. null until the user presses something with a non-modifier.
|
|
||||||
Set<String> _mods = {};
|
|
||||||
String? _key;
|
|
||||||
|
|
||||||
// Human-readable label for the most recent press that we couldn't bind to
|
|
||||||
// (e.g. F13, media keys). null when the last press was either supported or
|
|
||||||
// a modifier-only press. Cleared whenever a supported key arrives, so a
|
|
||||||
// user who hits an unsupported key after a valid capture sees the warning
|
|
||||||
// until they press something else. Distinct from `_key == null` so the
|
|
||||||
// status line can tell the user *why* their press was ignored instead of
|
|
||||||
// silently doing nothing.
|
|
||||||
String? _unsupportedKey;
|
|
||||||
|
|
||||||
// Modifier LogicalKeyboardKeys we should *not* treat as "unsupported" when
|
|
||||||
// they fail to map to a key name. A modifier-only press is normal during
|
|
||||||
// combo capture (the user is building up their combo) — only non-modifier
|
|
||||||
// unmapped keys deserve the warning.
|
|
||||||
static final _modifierKeys = <LogicalKeyboardKey>{
|
|
||||||
LogicalKeyboardKey.shift,
|
|
||||||
LogicalKeyboardKey.shiftLeft,
|
|
||||||
LogicalKeyboardKey.shiftRight,
|
|
||||||
LogicalKeyboardKey.control,
|
|
||||||
LogicalKeyboardKey.controlLeft,
|
|
||||||
LogicalKeyboardKey.controlRight,
|
|
||||||
LogicalKeyboardKey.alt,
|
|
||||||
LogicalKeyboardKey.altLeft,
|
|
||||||
LogicalKeyboardKey.altRight,
|
|
||||||
LogicalKeyboardKey.meta,
|
|
||||||
LogicalKeyboardKey.metaLeft,
|
|
||||||
LogicalKeyboardKey.metaRight,
|
|
||||||
LogicalKeyboardKey.capsLock,
|
|
||||||
LogicalKeyboardKey.numLock,
|
|
||||||
LogicalKeyboardKey.scrollLock,
|
|
||||||
LogicalKeyboardKey.fn,
|
|
||||||
LogicalKeyboardKey.fnLock,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isMac =>
|
|
||||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
|
||||||
|
|
||||||
/// True when the captured combo includes at least one modifier. Lower bound
|
|
||||||
/// for any sensible binding — pure single-key bindings would swallow normal
|
|
||||||
/// typing the moment shortcuts are enabled. Beyond one mod the user is on
|
|
||||||
/// their own; the in-session pass-through toggle is the escape hatch when
|
|
||||||
/// a chosen combo collides with something needed on the remote.
|
|
||||||
bool get _hasRequiredPrefix => _mods.isNotEmpty;
|
|
||||||
|
|
||||||
/// Return the actionId that this combo currently conflicts with, or null.
|
|
||||||
/// The action being edited is not a conflict with itself.
|
|
||||||
String? get _conflictActionId {
|
|
||||||
if (_key == null || !_hasRequiredPrefix) return null;
|
|
||||||
for (final b in widget.existingBindings) {
|
|
||||||
final otherAction = b['action'] as String?;
|
|
||||||
if (otherAction == null || otherAction == widget.actionId) continue;
|
|
||||||
final otherKey = b['key'] as String?;
|
|
||||||
final otherMods = shortcutModSetFrom(b['mods']);
|
|
||||||
if (otherKey == _key &&
|
|
||||||
otherMods.length == _mods.length &&
|
|
||||||
otherMods.containsAll(_mods)) {
|
|
||||||
return otherAction;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
||||||
if (event is KeyDownEvent &&
|
|
||||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (event is! KeyDownEvent) return KeyEventResult.handled;
|
|
||||||
|
|
||||||
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
|
|
||||||
final logical = event.logicalKey;
|
|
||||||
final keyName = logicalKeyName(logical);
|
|
||||||
|
|
||||||
// Mirror of `normalize_modifiers` in src/keyboard/shortcuts.rs:
|
|
||||||
// * macOS: Cmd → primary, Ctrl → ctrl (distinct).
|
|
||||||
// * Win/Linux: Ctrl → primary, no separate Ctrl modifier.
|
|
||||||
// The two halves must agree on labels, otherwise saved bindings will not
|
|
||||||
// match the events the matcher sees at runtime.
|
|
||||||
final mods = <String>{};
|
|
||||||
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
|
|
||||||
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
|
|
||||||
if (_isMac) {
|
|
||||||
if (HardwareKeyboard.instance.isMetaPressed) mods.add('primary');
|
|
||||||
if (HardwareKeyboard.instance.isControlPressed) mods.add('ctrl');
|
|
||||||
} else {
|
|
||||||
if (HardwareKeyboard.instance.isControlPressed) mods.add('primary');
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_mods = mods;
|
|
||||||
// Only lock in the key when it's a non-modifier we recognize.
|
|
||||||
// Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key
|
|
||||||
// untouched, so the user can adjust modifiers after the fact.
|
|
||||||
if (keyName != null) {
|
|
||||||
_key = keyName;
|
|
||||||
_unsupportedKey = null;
|
|
||||||
} else if (!_modifierKeys.contains(logical)) {
|
|
||||||
// Non-modifier key we don't recognize (e.g. F13, media keys, IME
|
|
||||||
// compose keys). Surface a warning instead of silently dropping the
|
|
||||||
// press — the dialog otherwise looks unresponsive.
|
|
||||||
final label = logical.keyLabel.isNotEmpty
|
|
||||||
? logical.keyLabel
|
|
||||||
: (logical.debugName ?? 'this key');
|
|
||||||
_unsupportedKey = label;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSave() {
|
|
||||||
if (_key == null || !_hasRequiredPrefix) return;
|
|
||||||
final ordered = canonicalShortcutModsForSave(_mods);
|
|
||||||
final binding = <String, dynamic>{
|
|
||||||
'action': widget.actionId,
|
|
||||||
'mods': ordered,
|
|
||||||
'key': _key!,
|
|
||||||
};
|
|
||||||
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatPrefix() {
|
|
||||||
// Used in the "must include..." validation row; lists the modifier set
|
|
||||||
// a binding can pick from. Localised modifier glyphs aren't used here so
|
|
||||||
// the names stay greppable for users searching for "Option" / "Cmd".
|
|
||||||
if (_isMac) return 'Cmd / Control / Option / Shift';
|
|
||||||
return 'Ctrl / Alt / Shift';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatCombo() {
|
|
||||||
// Plain-text labels (see same rationale in display.dart::_keyDisplay).
|
|
||||||
final parts = <String>[];
|
|
||||||
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
|
|
||||||
if (!_mods.contains(m)) continue;
|
|
||||||
switch (m) {
|
|
||||||
case 'primary':
|
|
||||||
parts.add(_isMac ? 'Cmd' : 'Ctrl');
|
|
||||||
break;
|
|
||||||
case 'ctrl':
|
|
||||||
parts.add(_isMac ? 'Control' : 'Ctrl');
|
|
||||||
break;
|
|
||||||
case 'alt':
|
|
||||||
parts.add(_isMac ? 'Option' : 'Alt');
|
|
||||||
break;
|
|
||||||
case 'shift':
|
|
||||||
parts.add('Shift');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (_key != null) {
|
|
||||||
parts.add(_keyDisplay(_key!));
|
|
||||||
}
|
|
||||||
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
|
|
||||||
return parts.join('+');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _keyDisplay(String key) {
|
|
||||||
switch (key) {
|
|
||||||
case 'delete': return 'Del';
|
|
||||||
case 'backspace': return 'Backspace';
|
|
||||||
case 'enter': return 'Enter';
|
|
||||||
case 'tab': return 'Tab';
|
|
||||||
case 'space': return 'Space';
|
|
||||||
case 'arrow_left': return 'Left';
|
|
||||||
case 'arrow_right':return 'Right';
|
|
||||||
case 'arrow_up': return 'Up';
|
|
||||||
case 'arrow_down': return 'Down';
|
|
||||||
case 'home': return 'Home';
|
|
||||||
case 'end': return 'End';
|
|
||||||
case 'page_up': return 'PgUp';
|
|
||||||
case 'page_down': return 'PgDn';
|
|
||||||
case 'insert': return 'Ins';
|
|
||||||
}
|
|
||||||
if (key.startsWith('digit')) return key.substring(5);
|
|
||||||
return key.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final hasKey = _key != null;
|
|
||||||
final conflictId = _conflictActionId;
|
|
||||||
final hasConflict = conflictId != null;
|
|
||||||
// The Save button still fires for the previously-captured combo even if
|
|
||||||
// the user just hit an unsupported key — the captured state is what gets
|
|
||||||
// saved, the warning is just feedback that the latest press was rejected.
|
|
||||||
final canSave = hasKey && _hasRequiredPrefix;
|
|
||||||
|
|
||||||
Widget statusLine;
|
|
||||||
if (_unsupportedKey != null) {
|
|
||||||
// Most recent press was unsupported. Take precedence over the
|
|
||||||
// captured-combo states so the user gets explicit feedback that their
|
|
||||||
// last keystroke was ignored, regardless of whether a previous combo
|
|
||||||
// is still captured.
|
|
||||||
statusLine = Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.close, size: 16, color: Colors.red),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
translate('shortcut-key-not-supported')
|
|
||||||
.replaceAll('{}', _unsupportedKey!),
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else if (!hasKey) {
|
|
||||||
statusLine = Text(
|
|
||||||
translate('shortcut-recording-press-keys-tip'),
|
|
||||||
style: TextStyle(color: Theme.of(context).hintColor),
|
|
||||||
);
|
|
||||||
} else if (!_hasRequiredPrefix) {
|
|
||||||
statusLine = Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.close, size: 16, color: Colors.red),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
translate('shortcut-must-include-modifiers')
|
|
||||||
.replaceAll('{}', _formatPrefix()),
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else if (hasConflict) {
|
|
||||||
final otherLabel = widget.actionLabelLookup(conflictId);
|
|
||||||
statusLine = Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.warning_amber_outlined,
|
|
||||||
size: 16, color: Colors.orange.shade700),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'${translate('shortcut-already-bound-to')} "$otherLabel"',
|
|
||||||
style: TextStyle(color: Colors.orange.shade700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
statusLine = Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.check, size: 16, color: Colors.green),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(translate('Valid'), style: const TextStyle(color: Colors.green)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final saveLabel = hasConflict ? 'Replace' : 'Save';
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(
|
|
||||||
'${translate('Set Shortcut')}: ${widget.actionLabel}',
|
|
||||||
),
|
|
||||||
content: Focus(
|
|
||||||
focusNode: _focusNode,
|
|
||||||
autofocus: true,
|
|
||||||
onKeyEvent: _onKeyEvent,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(minWidth: 380),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(translate('shortcut-recording-instruction')),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_formatCombo(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: hasKey
|
|
||||||
? Theme.of(context).textTheme.titleLarge?.color
|
|
||||||
: Theme.of(context).hintColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
statusLine,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
dialogButton('Cancel',
|
|
||||||
onPressed: () => Navigator.of(context).pop(), isOutline: true),
|
|
||||||
dialogButton(saveLabel, onPressed: canSave ? _onSave : null),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import 'shortcut_constants.dart';
|
|
||||||
import 'shortcut_utils.dart';
|
|
||||||
|
|
||||||
/// Marker for the union of [KeyboardShortcutActionEntry] /
|
|
||||||
/// [KeyboardShortcutActionSubgroup] — anything a top-level
|
|
||||||
/// [KeyboardShortcutActionGroup] can directly contain. Sealed so renderers
|
|
||||||
/// and filters can `switch` on it without a default branch.
|
|
||||||
sealed class KeyboardShortcutActionGroupChild {
|
|
||||||
const KeyboardShortcutActionGroupChild();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One configurable action — id + i18n key for its label.
|
|
||||||
class KeyboardShortcutActionEntry extends KeyboardShortcutActionGroupChild {
|
|
||||||
final String id;
|
|
||||||
final String labelKey;
|
|
||||||
const KeyboardShortcutActionEntry(this.id, this.labelKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A nested subgroup (e.g. "View Mode" under "Display"). Renders with extra
|
|
||||||
/// indent so its items are visually distinguished from the parent group's
|
|
||||||
/// direct items.
|
|
||||||
class KeyboardShortcutActionSubgroup extends KeyboardShortcutActionGroupChild {
|
|
||||||
final String titleKey;
|
|
||||||
final List<KeyboardShortcutActionEntry> entries;
|
|
||||||
const KeyboardShortcutActionSubgroup(this.titleKey, this.entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A top-level group ("Display", "Keyboard", "Chat", …). `children` is an
|
|
||||||
/// *ordered* mix of direct entries and subgroups, so layouts like
|
|
||||||
/// "subgroups first → direct items → trailing subgroup" — exactly the
|
|
||||||
/// shape `_DisplayMenu` uses (Privacy mode lives after the cursor / display
|
|
||||||
/// toggles direct items) — are first-class instead of needing a wrapper
|
|
||||||
/// "Display Settings" subgroup just to insert the items.
|
|
||||||
class KeyboardShortcutActionGroup {
|
|
||||||
final String titleKey;
|
|
||||||
final List<KeyboardShortcutActionGroupChild> children;
|
|
||||||
const KeyboardShortcutActionGroup(this.titleKey, this.children);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical action group definitions used by both the desktop and mobile
|
|
||||||
/// configuration pages. The order of groups, subgroups, and entries here
|
|
||||||
/// is the order the user sees in the UI, and mirrors the corresponding
|
|
||||||
/// toolbar submenu (`_DisplayMenu` / `_KeyboardMenu` in
|
|
||||||
/// `desktop/widgets/remote_toolbar.dart`) child order — modulo entries
|
|
||||||
/// without shortcut counterparts (e.g. `_screenAdjustor.adjustWindow`,
|
|
||||||
/// `scrollStyle`, `_ResolutionsMenu`, `localKeyboardType`).
|
|
||||||
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
|
|
||||||
KeyboardShortcutActionGroup('Monitor', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSwitchDisplayAll, 'All monitors'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionGroup('Control Actions', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSendClipboardKeystrokes, 'Send clipboard keystrokes'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionResetCanvas, 'Reset canvas'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionRestartRemote, 'Restart remote device'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleBlockInput, 'Block user input'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleRecording, 'Toggle session recording'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take screenshot'),
|
|
||||||
]),
|
|
||||||
// Display: subgroups (View Mode → Image Quality → Codec → Virtual display)
|
|
||||||
// first, then the direct items (cursor toggles + display toggles), then
|
|
||||||
// Privacy mode subgroup last — matching `_DisplayMenu.menuChildrenGetter`
|
|
||||||
// exactly. Rebalancing this order should also rebalance the toolbar.
|
|
||||||
KeyboardShortcutActionGroup('Display', [
|
|
||||||
KeyboardShortcutActionSubgroup('View Mode', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionViewModeOriginal, 'Scale original'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionViewModeAdaptive, 'Scale adaptive'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionViewModeCustom, 'Scale custom'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionSubgroup('Image Quality', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionImageQualityBest, 'Good image quality'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionImageQualityBalanced, 'Balanced'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionImageQualityLow, 'Optimize reaction time'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionSubgroup('Codec', [
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecAuto, 'Auto'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecVp8, 'VP8'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecVp9, 'VP9'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecAv1, 'AV1'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecH264, 'H264'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCodecH265, 'H265'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionSubgroup('Virtual display', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionPlugOutAllVirtualDisplays, 'Plug out all'),
|
|
||||||
]),
|
|
||||||
// Direct items: cursorToggles + display toggles, in toolbar order.
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleShowRemoteCursor, 'Show remote cursor'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleFollowRemoteCursor, 'Follow remote cursor'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleFollowRemoteWindow, 'Follow remote window focus'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleZoomCursor, 'Zoom cursor'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleQualityMonitor, 'Show quality monitor'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionToggleMute, 'Mute'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleEnableFileCopyPaste, 'Enable file copy and paste'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleDisableClipboard, 'Disable clipboard'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleLockAfterSessionEnd, 'Lock after session end'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleTrueColor, 'True color (4:4:4)'),
|
|
||||||
// Privacy mode at the bottom — mirrors `_DisplayMenu` where it's the
|
|
||||||
// last submenu added (line ~1023 of remote_toolbar.dart, after toggles).
|
|
||||||
KeyboardShortcutActionSubgroup('Privacy mode', [
|
|
||||||
// Reuse toolbar's existing impl-name i18n keys. The handler at
|
|
||||||
// runtime matches `privacy_mode_impl_mag_tip` /
|
|
||||||
// `privacy_mode_impl_virtual_display_tip` against the peer's
|
|
||||||
// advertised impls — same logic the toolbar's `toolbarPrivacyMode`
|
|
||||||
// submenu uses.
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionPrivacyMode1, 'privacy_mode_impl_mag_tip'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionPrivacyMode2, 'privacy_mode_impl_virtual_display_tip'),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
// Keyboard: Keyboard mode subgroup first, then direct items
|
|
||||||
// (inputSource → viewMode → showMyCursor → toolbarKeyboardToggles),
|
|
||||||
// matching `_KeyboardMenu.menuChildrenGetter`.
|
|
||||||
KeyboardShortcutActionGroup('Keyboard', [
|
|
||||||
KeyboardShortcutActionSubgroup('Keyboard mode', [
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionKeyboardModeLegacy, 'Legacy mode'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionKeyboardModeMap, 'Map mode'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionKeyboardModeTranslate, 'Translate mode'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleInputSource, 'Toggle input source'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionToggleViewOnly, 'View Mode'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleShowMyCursor, 'Show my cursor'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleSwapCtrlCmd, 'Swap control-command key'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleRelativeMouseMode, 'Relative mouse mode'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleReverseMouseWheel, 'Reverse mouse wheel'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleSwapLeftRightMouse, 'swap-left-right-mouse'),
|
|
||||||
]),
|
|
||||||
KeyboardShortcutActionGroup('Chat', [
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionToggleChat, 'Text chat'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionToggleVoiceCall, 'Voice call'),
|
|
||||||
]),
|
|
||||||
// "Other" collects single-icon toolbar buttons that have no dropdown
|
|
||||||
// (Pin, Close), plus actions with no toolbar entry at all (Fullscreen —
|
|
||||||
// driven by callback, not menu; Toggle Toolbar / tab navigation — tab
|
|
||||||
// right-click menu, not toolbar). Combined into one group rather than
|
|
||||||
// several 1-item groups for cleaner visual hierarchy.
|
|
||||||
KeyboardShortcutActionGroup('Other', [
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionPinToolbar, 'Pin Toolbar'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionToggleFullscreen, 'Toggle fullscreen'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionToggleToolbar, 'Toggle toolbar'),
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close tab'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSwitchTabNext, 'Switch to next tab'),
|
|
||||||
KeyboardShortcutActionEntry(
|
|
||||||
kShortcutActionSwitchTabPrev, 'Switch to previous tab'),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Walk the (filtered or unfiltered) group tree and yield every
|
|
||||||
/// [KeyboardShortcutActionEntry], regardless of whether it sits as a direct
|
|
||||||
/// child of a top-level group or inside a subgroup. Useful for label
|
|
||||||
/// lookups, ghost-action tests, and any consumer that just wants the flat
|
|
||||||
/// list of action ids.
|
|
||||||
Iterable<KeyboardShortcutActionEntry> allActionEntries(
|
|
||||||
Iterable<KeyboardShortcutActionGroup> groups,
|
|
||||||
) sync* {
|
|
||||||
for (final group in groups) {
|
|
||||||
for (final child in group.children) {
|
|
||||||
switch (child) {
|
|
||||||
case KeyboardShortcutActionEntry():
|
|
||||||
yield child;
|
|
||||||
case KeyboardShortcutActionSubgroup():
|
|
||||||
yield* child.entries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return [kKeyboardShortcutActionGroups] with actions that aren't supported
|
|
||||||
/// on the current platform stripped out. Subgroups whose every entry was
|
|
||||||
/// filtered are dropped; top-level groups whose every child (direct entry
|
|
||||||
/// or subgroup) was dropped are themselves dropped.
|
|
||||||
///
|
|
||||||
/// Mirrors the capability flags used by [filterDefaultBindingsForPlatform]
|
|
||||||
/// so the configuration UI shows only what the matcher can actually
|
|
||||||
/// dispatch on this platform.
|
|
||||||
///
|
|
||||||
/// Note: callers should still walk the unfiltered
|
|
||||||
/// [kKeyboardShortcutActionGroups] for label lookups (e.g. conflict
|
|
||||||
/// warnings about a stale cross-platform binding), so an action bound on
|
|
||||||
/// desktop and carried over to mobile still has a human-readable name in
|
|
||||||
/// dialogs.
|
|
||||||
List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
|
|
||||||
ShortcutPlatformCapabilities cap,
|
|
||||||
) {
|
|
||||||
bool allowed(String id) {
|
|
||||||
if (!cap.includeFullscreenShortcut &&
|
|
||||||
id == kShortcutActionToggleFullscreen) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeScreenshotShortcut &&
|
|
||||||
id == kShortcutActionToggleRelativeMouseMode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
|
|
||||||
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeCloseTabShortcut && id == kShortcutActionCloseTab) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeSwitchSidesShortcut && id == kShortcutActionSwitchSides) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeRecordingShortcut && id == kShortcutActionToggleRecording) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeResetCanvasShortcut && id == kShortcutActionResetCanvas) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includePinToolbarShortcut && id == kShortcutActionPinToolbar) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeViewModeShortcut &&
|
|
||||||
(id == kShortcutActionViewModeOriginal ||
|
|
||||||
id == kShortcutActionViewModeAdaptive ||
|
|
||||||
id == kShortcutActionViewModeCustom)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeInputSourceShortcut &&
|
|
||||||
id == kShortcutActionToggleInputSource) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!cap.includeVoiceCallShortcut && id == kShortcutActionToggleVoiceCall) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final out = <KeyboardShortcutActionGroup>[];
|
|
||||||
for (final group in kKeyboardShortcutActionGroups) {
|
|
||||||
final filteredChildren = <KeyboardShortcutActionGroupChild>[];
|
|
||||||
for (final child in group.children) {
|
|
||||||
switch (child) {
|
|
||||||
case KeyboardShortcutActionEntry():
|
|
||||||
if (allowed(child.id)) filteredChildren.add(child);
|
|
||||||
case KeyboardShortcutActionSubgroup():
|
|
||||||
final entries =
|
|
||||||
child.entries.where((e) => allowed(e.id)).toList();
|
|
||||||
if (entries.isNotEmpty) {
|
|
||||||
filteredChildren.add(
|
|
||||||
KeyboardShortcutActionSubgroup(child.titleKey, entries));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filteredChildren.isNotEmpty) {
|
|
||||||
out.add(KeyboardShortcutActionGroup(group.titleKey, filteredChildren));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/// Keyboard shortcut action IDs - must match
|
|
||||||
/// src/keyboard/shortcuts.rs::action_id.
|
|
||||||
const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del';
|
|
||||||
const kShortcutActionToggleFullscreen = 'toggle_fullscreen';
|
|
||||||
const kShortcutActionSwitchDisplayNext = 'switch_display_next';
|
|
||||||
const kShortcutActionSwitchDisplayPrev = 'switch_display_prev';
|
|
||||||
const kShortcutActionSwitchDisplayAll = 'switch_display_all';
|
|
||||||
const kShortcutActionScreenshot = 'screenshot';
|
|
||||||
const kShortcutActionInsertLock = 'insert_lock';
|
|
||||||
const kShortcutActionRefresh = 'refresh';
|
|
||||||
const kShortcutActionToggleBlockInput = 'toggle_block_input';
|
|
||||||
const kShortcutActionToggleRecording = 'toggle_recording';
|
|
||||||
const kShortcutActionSwitchSides = 'switch_sides';
|
|
||||||
const kShortcutActionCloseTab = 'close_tab';
|
|
||||||
const kShortcutActionToggleToolbar = 'toggle_toolbar';
|
|
||||||
const kShortcutActionRestartRemote = 'restart_remote';
|
|
||||||
const kShortcutActionResetCanvas = 'reset_canvas';
|
|
||||||
const kShortcutActionSwitchTabNext = 'switch_tab_next';
|
|
||||||
const kShortcutActionSwitchTabPrev = 'switch_tab_prev';
|
|
||||||
const kShortcutActionToggleMute = 'toggle_mute';
|
|
||||||
const kShortcutActionPinToolbar = 'pin_toolbar';
|
|
||||||
const kShortcutActionViewModeOriginal = 'view_mode_original';
|
|
||||||
const kShortcutActionViewModeAdaptive = 'view_mode_adaptive';
|
|
||||||
const kShortcutActionToggleChat = 'toggle_chat';
|
|
||||||
const kShortcutActionToggleQualityMonitor = 'toggle_quality_monitor';
|
|
||||||
const kShortcutActionToggleShowRemoteCursor = 'toggle_show_remote_cursor';
|
|
||||||
const kShortcutActionToggleShowMyCursor = 'toggle_show_my_cursor';
|
|
||||||
const kShortcutActionToggleDisableClipboard = 'toggle_disable_clipboard';
|
|
||||||
const kShortcutActionPrivacyMode1 = 'privacy_mode_1';
|
|
||||||
const kShortcutActionPrivacyMode2 = 'privacy_mode_2';
|
|
||||||
// Keyboard mode (Map / Translate / Legacy).
|
|
||||||
const kShortcutActionKeyboardModeMap = 'keyboard_mode_map';
|
|
||||||
const kShortcutActionKeyboardModeTranslate = 'keyboard_mode_translate';
|
|
||||||
const kShortcutActionKeyboardModeLegacy = 'keyboard_mode_legacy';
|
|
||||||
// Codec preference (Auto + the four optional codecs the toolbar surfaces).
|
|
||||||
const kShortcutActionCodecAuto = 'codec_auto';
|
|
||||||
const kShortcutActionCodecVp8 = 'codec_vp8';
|
|
||||||
const kShortcutActionCodecVp9 = 'codec_vp9';
|
|
||||||
const kShortcutActionCodecAv1 = 'codec_av1';
|
|
||||||
const kShortcutActionCodecH264 = 'codec_h264';
|
|
||||||
const kShortcutActionCodecH265 = 'codec_h265';
|
|
||||||
// Plug out every virtual display in one shot — toolbar exposes this in
|
|
||||||
// both IDD modes (RustDesk and Amyuni). Per-index virtual-display toggles
|
|
||||||
// (RustDesk IDD's 4 checkboxes) and the +/- count buttons (Amyuni-only)
|
|
||||||
// are NOT exposed as shortcuts: per-index is too granular, and +/- has
|
|
||||||
// no toolbar counterpart on RustDesk IDD peers.
|
|
||||||
const kShortcutActionPlugOutAllVirtualDisplays =
|
|
||||||
'plug_out_all_virtual_displays';
|
|
||||||
const kShortcutActionToggleRelativeMouseMode = 'toggle_relative_mouse_mode';
|
|
||||||
const kShortcutActionToggleFollowRemoteCursor = 'toggle_follow_remote_cursor';
|
|
||||||
const kShortcutActionToggleFollowRemoteWindow = 'toggle_follow_remote_window';
|
|
||||||
const kShortcutActionToggleZoomCursor = 'toggle_zoom_cursor';
|
|
||||||
const kShortcutActionToggleReverseMouseWheel = 'toggle_reverse_mouse_wheel';
|
|
||||||
const kShortcutActionToggleSwapLeftRightMouse = 'toggle_swap_left_right_mouse';
|
|
||||||
const kShortcutActionToggleLockAfterSessionEnd = 'toggle_lock_after_session_end';
|
|
||||||
const kShortcutActionToggleTrueColor = 'toggle_true_color';
|
|
||||||
const kShortcutActionToggleSwapCtrlCmd = 'toggle_swap_ctrl_cmd';
|
|
||||||
const kShortcutActionToggleEnableFileCopyPaste = 'toggle_enable_file_copy_paste';
|
|
||||||
const kShortcutActionViewModeCustom = 'view_mode_custom';
|
|
||||||
const kShortcutActionImageQualityBest = 'image_quality_best';
|
|
||||||
const kShortcutActionImageQualityBalanced = 'image_quality_balanced';
|
|
||||||
const kShortcutActionImageQualityLow = 'image_quality_low';
|
|
||||||
const kShortcutActionSendClipboardKeystrokes = 'send_clipboard_keystrokes';
|
|
||||||
const kShortcutActionToggleInputSource = 'toggle_input_source';
|
|
||||||
const kShortcutActionToggleVoiceCall = 'toggle_voice_call';
|
|
||||||
const kShortcutActionToggleViewOnly = 'toggle_view_only';
|
|
||||||
|
|
||||||
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
|
|
||||||
const kShortcutEventName = 'shortcut_triggered';
|
|
||||||
|
|
||||||
/// Canonical default keyboard-shortcut bindings, mirroring Rust's
|
|
||||||
/// `default_bindings()` in `src/keyboard/shortcuts.rs`. Used by:
|
|
||||||
/// * the Web bridge (`flutter/lib/web/bridge.dart::mainGetDefaultKeyboardShortcuts`)
|
|
||||||
/// — Web has no Rust at runtime, so the seed list is read from this Dart
|
|
||||||
/// constant instead of going through FFI.
|
|
||||||
/// * the configuration page when seeding defaults on first enable, after
|
|
||||||
/// [filterDefaultBindingsForPlatform] has trimmed platform-specific
|
|
||||||
/// entries.
|
|
||||||
///
|
|
||||||
/// Parity with Rust is unit-tested on both sides against
|
|
||||||
/// `flutter/test/fixtures/default_keyboard_shortcuts.json` — see the
|
|
||||||
/// `kDefaultShortcutBindings matches fixture` test in
|
|
||||||
/// `flutter/test/keyboard_shortcuts_test.dart` and
|
|
||||||
/// `default_bindings_match_fixture_json` in `src/keyboard/shortcuts.rs`.
|
|
||||||
/// Any change here MUST also update the fixture and the Rust source, or CI
|
|
||||||
/// will fail in the side that drifted.
|
|
||||||
final List<Map<String, Object>> kDefaultShortcutBindings = [
|
|
||||||
for (final entry in <List<Object>>[
|
|
||||||
[kShortcutActionSendCtrlAltDel, 'delete'],
|
|
||||||
[kShortcutActionToggleFullscreen, 'enter'],
|
|
||||||
[kShortcutActionSwitchDisplayNext, 'arrow_right'],
|
|
||||||
[kShortcutActionSwitchDisplayPrev, 'arrow_left'],
|
|
||||||
[kShortcutActionScreenshot, 'p'],
|
|
||||||
[kShortcutActionToggleShowRemoteCursor, 'm'],
|
|
||||||
[kShortcutActionToggleMute, 's'],
|
|
||||||
[kShortcutActionToggleBlockInput, 'i'],
|
|
||||||
[kShortcutActionToggleChat, 'c'],
|
|
||||||
])
|
|
||||||
{
|
|
||||||
'action': entry[0],
|
|
||||||
'mods': const ['primary', 'alt', 'shift'],
|
|
||||||
'key': entry[1],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
import 'shortcut_constants.dart';
|
|
||||||
|
|
||||||
List<String> canonicalShortcutModsForSave(Set<String> mods) {
|
|
||||||
return <String>[
|
|
||||||
if (mods.contains('primary')) 'primary',
|
|
||||||
if (mods.contains('ctrl')) 'ctrl',
|
|
||||||
if (mods.contains('alt')) 'alt',
|
|
||||||
if (mods.contains('shift')) 'shift',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> shortcutBindingMapsFrom(dynamic rawBindings) {
|
|
||||||
if (rawBindings is! Iterable) return <Map<String, dynamic>>[];
|
|
||||||
final bindings = <Map<String, dynamic>>[];
|
|
||||||
for (final raw in rawBindings) {
|
|
||||||
if (raw is! Map) continue;
|
|
||||||
final binding = <String, dynamic>{};
|
|
||||||
for (final entry in raw.entries) {
|
|
||||||
final key = entry.key;
|
|
||||||
if (key is String) {
|
|
||||||
binding[key] = entry.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (binding.isNotEmpty) {
|
|
||||||
bindings.add(binding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> shortcutModSetFrom(dynamic rawMods) {
|
|
||||||
if (rawMods is! Iterable) return <String>{};
|
|
||||||
return rawMods.whereType<String>().toSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isSwitchTabShortcutAction(String? actionId) {
|
|
||||||
return actionId == kShortcutActionSwitchTabNext ||
|
|
||||||
actionId == kShortcutActionSwitchTabPrev;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a [LogicalKeyboardKey] to the canonical key name used in saved
|
|
||||||
/// bindings, or `null` for keys we don't accept as shortcuts.
|
|
||||||
///
|
|
||||||
/// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and
|
|
||||||
/// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep
|
|
||||||
/// the three in lockstep. Cross-language parity is enforced by:
|
|
||||||
/// * `flutter/test/fixtures/supported_shortcut_keys.json` — the
|
|
||||||
/// authoritative list of names this function must produce.
|
|
||||||
/// * Dart `supported keys` test in `keyboard_shortcuts_test.dart` —
|
|
||||||
/// asserts the (LogicalKeyboardKey → name) mapping covers the fixture.
|
|
||||||
/// * Rust `supported_keys_match_fixture` test in `shortcuts.rs` — the
|
|
||||||
/// Rust-side mirror against the same fixture.
|
|
||||||
/// A drift in any of the three breaks one of the two tests.
|
|
||||||
String? logicalKeyName(LogicalKeyboardKey k) {
|
|
||||||
// Singletons that map 1:1.
|
|
||||||
if (k == LogicalKeyboardKey.delete) return 'delete';
|
|
||||||
if (k == LogicalKeyboardKey.backspace) return 'backspace';
|
|
||||||
// Numpad Enter shares the "enter" name with the main Return key — matches
|
|
||||||
// the Rust matcher (`Return | KpReturn` → "enter") and matches user
|
|
||||||
// expectation that the two physical Enters are interchangeable.
|
|
||||||
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
|
||||||
return 'enter';
|
|
||||||
}
|
|
||||||
if (k == LogicalKeyboardKey.tab) return 'tab';
|
|
||||||
if (k == LogicalKeyboardKey.space) return 'space';
|
|
||||||
if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left';
|
|
||||||
if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right';
|
|
||||||
if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up';
|
|
||||||
if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down';
|
|
||||||
if (k == LogicalKeyboardKey.home) return 'home';
|
|
||||||
if (k == LogicalKeyboardKey.end) return 'end';
|
|
||||||
if (k == LogicalKeyboardKey.pageUp) return 'page_up';
|
|
||||||
if (k == LogicalKeyboardKey.pageDown) return 'page_down';
|
|
||||||
if (k == LogicalKeyboardKey.insert) return 'insert';
|
|
||||||
|
|
||||||
// Letter / digit / F-key tables. `LogicalKeyboardKey` constants are
|
|
||||||
// `static final` (not `const`), so the maps can't be `const` — but they
|
|
||||||
// initialize once per process and the lookup is O(1).
|
|
||||||
final letters = <LogicalKeyboardKey, String>{
|
|
||||||
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
|
|
||||||
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
|
|
||||||
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
|
|
||||||
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
|
|
||||||
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
|
|
||||||
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
|
|
||||||
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
|
|
||||||
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
|
|
||||||
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
|
|
||||||
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
|
|
||||||
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
|
|
||||||
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
|
|
||||||
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
|
|
||||||
};
|
|
||||||
final letter = letters[k];
|
|
||||||
if (letter != null) return letter;
|
|
||||||
|
|
||||||
final digits = <LogicalKeyboardKey, String>{
|
|
||||||
LogicalKeyboardKey.digit0: 'digit0',
|
|
||||||
LogicalKeyboardKey.digit1: 'digit1',
|
|
||||||
LogicalKeyboardKey.digit2: 'digit2',
|
|
||||||
LogicalKeyboardKey.digit3: 'digit3',
|
|
||||||
LogicalKeyboardKey.digit4: 'digit4',
|
|
||||||
LogicalKeyboardKey.digit5: 'digit5',
|
|
||||||
LogicalKeyboardKey.digit6: 'digit6',
|
|
||||||
LogicalKeyboardKey.digit7: 'digit7',
|
|
||||||
LogicalKeyboardKey.digit8: 'digit8',
|
|
||||||
LogicalKeyboardKey.digit9: 'digit9',
|
|
||||||
};
|
|
||||||
final digit = digits[k];
|
|
||||||
if (digit != null) return digit;
|
|
||||||
|
|
||||||
final fkeys = <LogicalKeyboardKey, String>{
|
|
||||||
LogicalKeyboardKey.f1: 'f1', LogicalKeyboardKey.f2: 'f2',
|
|
||||||
LogicalKeyboardKey.f3: 'f3', LogicalKeyboardKey.f4: 'f4',
|
|
||||||
LogicalKeyboardKey.f5: 'f5', LogicalKeyboardKey.f6: 'f6',
|
|
||||||
LogicalKeyboardKey.f7: 'f7', LogicalKeyboardKey.f8: 'f8',
|
|
||||||
LogicalKeyboardKey.f9: 'f9', LogicalKeyboardKey.f10: 'f10',
|
|
||||||
LogicalKeyboardKey.f11: 'f11', LogicalKeyboardKey.f12: 'f12',
|
|
||||||
};
|
|
||||||
return fkeys[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bundle of "is this shortcut available on the current platform" flags.
|
|
||||||
///
|
|
||||||
/// Production code reaches a single source of truth via
|
|
||||||
/// [ShortcutModel.currentPlatformCapabilities] (which encodes the per-runtime
|
|
||||||
/// rules in one place); tests construct one directly with whichever flags
|
|
||||||
/// they want to exercise. Two filter functions consume this:
|
|
||||||
/// [filterDefaultBindingsForPlatform] (for trimming default-binding JSON
|
|
||||||
/// before it hits LocalConfig) and [filterKeyboardShortcutActionGroupsForPlatform]
|
|
||||||
/// (for trimming the configuration UI's action list). Both must agree on the
|
|
||||||
/// same capability set, otherwise a default binding could be seeded for an
|
|
||||||
/// action the user has no UI to manage.
|
|
||||||
class ShortcutPlatformCapabilities {
|
|
||||||
final bool includeFullscreenShortcut;
|
|
||||||
final bool includeScreenshotShortcut;
|
|
||||||
final bool includeTabShortcuts;
|
|
||||||
final bool includeToolbarShortcut;
|
|
||||||
final bool includeCloseTabShortcut;
|
|
||||||
final bool includeSwitchSidesShortcut;
|
|
||||||
final bool includeRecordingShortcut;
|
|
||||||
final bool includeResetCanvasShortcut;
|
|
||||||
final bool includePinToolbarShortcut;
|
|
||||||
final bool includeViewModeShortcut;
|
|
||||||
final bool includeInputSourceShortcut;
|
|
||||||
final bool includeVoiceCallShortcut;
|
|
||||||
|
|
||||||
const ShortcutPlatformCapabilities({
|
|
||||||
required this.includeFullscreenShortcut,
|
|
||||||
required this.includeScreenshotShortcut,
|
|
||||||
required this.includeTabShortcuts,
|
|
||||||
required this.includeToolbarShortcut,
|
|
||||||
required this.includeCloseTabShortcut,
|
|
||||||
required this.includeSwitchSidesShortcut,
|
|
||||||
required this.includeRecordingShortcut,
|
|
||||||
required this.includeResetCanvasShortcut,
|
|
||||||
required this.includePinToolbarShortcut,
|
|
||||||
required this.includeViewModeShortcut,
|
|
||||||
required this.includeInputSourceShortcut,
|
|
||||||
required this.includeVoiceCallShortcut,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
|
|
||||||
Iterable<dynamic> bindings,
|
|
||||||
ShortcutPlatformCapabilities cap,
|
|
||||||
) {
|
|
||||||
final filtered = <Map<String, dynamic>>[];
|
|
||||||
for (final binding in shortcutBindingMapsFrom(bindings)) {
|
|
||||||
final action = binding['action'] as String?;
|
|
||||||
if (!cap.includeFullscreenShortcut &&
|
|
||||||
action == kShortcutActionToggleFullscreen) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeScreenshotShortcut &&
|
|
||||||
action == kShortcutActionToggleRelativeMouseMode) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeToolbarShortcut &&
|
|
||||||
action == kShortcutActionToggleToolbar) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeCloseTabShortcut && action == kShortcutActionCloseTab) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeSwitchSidesShortcut &&
|
|
||||||
action == kShortcutActionSwitchSides) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeRecordingShortcut &&
|
|
||||||
action == kShortcutActionToggleRecording) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeResetCanvasShortcut &&
|
|
||||||
action == kShortcutActionResetCanvas) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includePinToolbarShortcut && action == kShortcutActionPinToolbar) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeViewModeShortcut &&
|
|
||||||
(action == kShortcutActionViewModeOriginal ||
|
|
||||||
action == kShortcutActionViewModeAdaptive ||
|
|
||||||
action == kShortcutActionViewModeCustom)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeInputSourceShortcut &&
|
|
||||||
action == kShortcutActionToggleInputSource) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cap.includeVoiceCallShortcut &&
|
|
||||||
action == kShortcutActionToggleVoiceCall) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
filtered.add(binding);
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,35 @@ const kOpSvgList = [
|
|||||||
'microsoft'
|
'microsoft'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
class _OidcProviderBranding {
|
||||||
|
final String label;
|
||||||
|
final String iconKey;
|
||||||
|
|
||||||
|
const _OidcProviderBranding({
|
||||||
|
required this.label,
|
||||||
|
required this.iconKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_OidcProviderBranding _oidcProviderBranding(String op) {
|
||||||
|
switch (op.toLowerCase()) {
|
||||||
|
case 'azure':
|
||||||
|
return _OidcProviderBranding(
|
||||||
|
label: 'Microsoft',
|
||||||
|
iconKey: 'microsoft',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _OidcProviderBranding(
|
||||||
|
label: {
|
||||||
|
'github': 'GitHub',
|
||||||
|
'gitlab': 'GitLab',
|
||||||
|
}[op.toLowerCase()] ??
|
||||||
|
toCapitalized(op),
|
||||||
|
iconKey: op.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _IconOP extends StatelessWidget {
|
class _IconOP extends StatelessWidget {
|
||||||
final String op;
|
final String op;
|
||||||
final String? icon;
|
final String? icon;
|
||||||
@@ -74,11 +103,8 @@ class ButtonOP extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final opLabel = {
|
final branding = _oidcProviderBranding(op);
|
||||||
'github': 'GitHub',
|
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||||
'gitlab': 'GitLab'
|
|
||||||
}[op.toLowerCase()] ??
|
|
||||||
toCapitalized(op);
|
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
Container(
|
Container(
|
||||||
height: height,
|
height: height,
|
||||||
@@ -95,7 +121,7 @@ class ButtonOP extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 30,
|
width: 30,
|
||||||
child: _IconOP(
|
child: _IconOP(
|
||||||
op: op,
|
op: branding.iconKey,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
margin: EdgeInsets.only(right: 5),
|
margin: EdgeInsets.only(right: 5),
|
||||||
),
|
),
|
||||||
@@ -103,8 +129,7 @@ class ButtonOP extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Center(
|
child: Center(child: Text(buttonLabel)),
|
||||||
child: Text(translate("Continue with {$opLabel}"))),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -532,9 +532,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
// Official
|
// Official
|
||||||
TapGestureRecognizer:
|
TapGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||||
() => TapGestureRecognizer(
|
() => TapGestureRecognizer(), (instance) {
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
), (instance) {
|
|
||||||
instance
|
instance
|
||||||
..onTapDown = onTapDown
|
..onTapDown = onTapDown
|
||||||
..onTapUp = onTapUp
|
..onTapUp = onTapUp
|
||||||
@@ -542,18 +540,14 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
}),
|
}),
|
||||||
DoubleTapGestureRecognizer:
|
DoubleTapGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||||
() => DoubleTapGestureRecognizer(
|
() => DoubleTapGestureRecognizer(), (instance) {
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
), (instance) {
|
|
||||||
instance
|
instance
|
||||||
..onDoubleTapDown = onDoubleTapDown
|
..onDoubleTapDown = onDoubleTapDown
|
||||||
..onDoubleTap = onDoubleTap;
|
..onDoubleTap = onDoubleTap;
|
||||||
}),
|
}),
|
||||||
LongPressGestureRecognizer:
|
LongPressGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||||
() => LongPressGestureRecognizer(
|
() => LongPressGestureRecognizer(), (instance) {
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
), (instance) {
|
|
||||||
instance
|
instance
|
||||||
..onLongPressDown = onLongPressDown
|
..onLongPressDown = onLongPressDown
|
||||||
..onLongPressUp = onLongPressUp
|
..onLongPressUp = onLongPressUp
|
||||||
@@ -563,9 +557,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
// Customized
|
// Customized
|
||||||
HoldTapMoveGestureRecognizer:
|
HoldTapMoveGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||||
() => HoldTapMoveGestureRecognizer(
|
() => HoldTapMoveGestureRecognizer(),
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
),
|
|
||||||
(instance) => instance
|
(instance) => instance
|
||||||
..onHoldDragStart = onHoldDragStart
|
..onHoldDragStart = onHoldDragStart
|
||||||
..onHoldDragUpdate = onHoldDragUpdate
|
..onHoldDragUpdate = onHoldDragUpdate
|
||||||
@@ -573,18 +565,14 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
..onHoldDragEnd = onHoldDragEnd),
|
..onHoldDragEnd = onHoldDragEnd),
|
||||||
DoubleFinerTapGestureRecognizer:
|
DoubleFinerTapGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||||
() => DoubleFinerTapGestureRecognizer(
|
() => DoubleFinerTapGestureRecognizer(), (instance) {
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
), (instance) {
|
|
||||||
instance
|
instance
|
||||||
..onDoubleFinerTap = onDoubleFinerTap
|
..onDoubleFinerTap = onDoubleFinerTap
|
||||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||||
}),
|
}),
|
||||||
CustomTouchGestureRecognizer:
|
CustomTouchGestureRecognizer:
|
||||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||||
() => CustomTouchGestureRecognizer(
|
() => CustomTouchGestureRecognizer(), (instance) {
|
||||||
supportedDevices: kTouchBasedDeviceKinds,
|
|
||||||
), (instance) {
|
|
||||||
instance.onOneFingerPanStart =
|
instance.onOneFingerPanStart =
|
||||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||||
instance
|
instance
|
||||||
|
|||||||
@@ -11,83 +11,97 @@ import 'package:flutter_hbb/consts.dart';
|
|||||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/models/model.dart';
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
bool isEditOsPassword = false;
|
bool isEditOsPassword = false;
|
||||||
|
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
|
||||||
|
const String kWaylandKeyboardIssueUrl =
|
||||||
|
'https://github.com/rustdesk/rustdesk/issues/14586';
|
||||||
|
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
|
||||||
|
|
||||||
/// Action IDs that `toolbarControls` is the sole registrar for. Wiped on
|
Future<bool> openWaylandKeyboardIssueUrl() {
|
||||||
/// every call so stale closures don't outlive the menu entry that owned
|
return launchUrl(
|
||||||
/// them. Actions registered by `registerSessionShortcutActions` MUST NOT
|
Uri.parse(kWaylandKeyboardIssueUrl),
|
||||||
/// appear here. `kShortcutActionToggleRecording` is platform-conditional
|
mode: LaunchMode.externalApplication,
|
||||||
/// and handled separately in the unregister pass below.
|
);
|
||||||
const _kToolbarOwnedActionIds = <String>[
|
}
|
||||||
kShortcutActionSendCtrlAltDel,
|
|
||||||
kShortcutActionRestartRemote,
|
|
||||||
kShortcutActionInsertLock,
|
|
||||||
kShortcutActionToggleBlockInput,
|
|
||||||
kShortcutActionSwitchSides,
|
|
||||||
kShortcutActionRefresh,
|
|
||||||
kShortcutActionScreenshot,
|
|
||||||
kShortcutActionResetCanvas,
|
|
||||||
kShortcutActionSendClipboardKeystrokes,
|
|
||||||
];
|
|
||||||
|
|
||||||
const _kToolbarViewStyleActionIds = <String>[
|
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||||
kShortcutActionViewModeOriginal,
|
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
|
||||||
kShortcutActionViewModeAdaptive,
|
}
|
||||||
kShortcutActionViewModeCustom,
|
|
||||||
];
|
|
||||||
|
|
||||||
const _kToolbarImageQualityActionIds = <String>[
|
void setWaylandKeyboardPromptSuppressedForConnection(
|
||||||
kShortcutActionImageQualityBest,
|
String connectionId, bool suppressed) {
|
||||||
kShortcutActionImageQualityBalanced,
|
if (suppressed) {
|
||||||
kShortcutActionImageQualityLow,
|
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
|
||||||
];
|
} else {
|
||||||
|
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const _kToolbarCodecActionIds = <String>[
|
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||||
kShortcutActionCodecAuto,
|
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||||
kShortcutActionCodecVp8,
|
}
|
||||||
kShortcutActionCodecVp9,
|
|
||||||
kShortcutActionCodecAv1,
|
|
||||||
kShortcutActionCodecH264,
|
|
||||||
kShortcutActionCodecH265,
|
|
||||||
];
|
|
||||||
|
|
||||||
const _kToolbarCursorActionIds = <String>[
|
bool shouldShowWaylandKeyboardPrompt({
|
||||||
kShortcutActionToggleShowRemoteCursor,
|
required String connectionId,
|
||||||
kShortcutActionToggleFollowRemoteCursor,
|
required bool isWaylandPeer,
|
||||||
kShortcutActionToggleFollowRemoteWindow,
|
required bool allowWaylandKeyboardRemembered,
|
||||||
kShortcutActionToggleZoomCursor,
|
}) {
|
||||||
];
|
return isWaylandPeer &&
|
||||||
|
!allowWaylandKeyboardRemembered &&
|
||||||
|
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
const _kToolbarDisplayToggleActionIds = <String>[
|
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
|
||||||
kShortcutActionToggleQualityMonitor,
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
kShortcutActionToggleMute,
|
return Container(
|
||||||
kShortcutActionToggleEnableFileCopyPaste,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
kShortcutActionToggleDisableClipboard,
|
decoration: BoxDecoration(
|
||||||
kShortcutActionToggleLockAfterSessionEnd,
|
borderRadius: BorderRadius.circular(999),
|
||||||
kShortcutActionToggleTrueColor,
|
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
|
||||||
];
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const _kToolbarKeyboardToggleActionIds = <String>[
|
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
|
||||||
kShortcutActionToggleSwapCtrlCmd,
|
return privacyModeImpl == kPrivacyModeImplMag ||
|
||||||
kShortcutActionToggleSwapLeftRightMouse,
|
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
|
||||||
];
|
}
|
||||||
|
|
||||||
|
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
|
||||||
|
// covers every local monitor with privacy overlay windows, so remote display
|
||||||
|
// switching does not weaken local privacy protection.
|
||||||
|
//
|
||||||
|
// Keep this separate from the capture backend capability. The legacy Windows
|
||||||
|
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
|
||||||
|
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
|
||||||
|
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||||
|
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
|
||||||
|
return pi.platform == kPeerPlatformMacOS ||
|
||||||
|
(pi.platform == kPeerPlatformWindows &&
|
||||||
|
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
|
||||||
|
versionCmp(pi.version, '1.4.8') >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
class TTextMenu {
|
class TTextMenu {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
Widget? trailingIcon;
|
Widget? trailingIcon;
|
||||||
bool divider;
|
bool divider;
|
||||||
final String? actionId;
|
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
{required this.child,
|
{required this.child,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
this.trailingIcon,
|
this.trailingIcon,
|
||||||
this.divider = false,
|
this.divider = false});
|
||||||
this.actionId});
|
|
||||||
|
|
||||||
Widget getChild() {
|
Widget getChild() {
|
||||||
if (trailingIcon != null) {
|
if (trailingIcon != null) {
|
||||||
@@ -109,73 +123,20 @@ class TRadioMenu<T> {
|
|||||||
final T value;
|
final T value;
|
||||||
final T groupValue;
|
final T groupValue;
|
||||||
final ValueChanged<T?>? onChanged;
|
final ValueChanged<T?>? onChanged;
|
||||||
final String? actionId;
|
|
||||||
|
|
||||||
TRadioMenu(
|
TRadioMenu(
|
||||||
{required this.child,
|
{required this.child,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.groupValue,
|
required this.groupValue,
|
||||||
required this.onChanged,
|
required this.onChanged});
|
||||||
this.actionId});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TToggleMenu {
|
class TToggleMenu {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final bool value;
|
final bool value;
|
||||||
final ValueChanged<bool?>? onChanged;
|
final ValueChanged<bool?>? onChanged;
|
||||||
final String? actionId;
|
|
||||||
TToggleMenu(
|
TToggleMenu(
|
||||||
{required this.child,
|
{required this.child, required this.value, required this.onChanged});
|
||||||
required this.value,
|
|
||||||
required this.onChanged,
|
|
||||||
this.actionId});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register each tagged entry's `onChanged` with the session [ShortcutModel].
|
|
||||||
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
|
|
||||||
List<TToggleMenu> _registerToggleMenuShortcuts(
|
|
||||||
FFI ffi,
|
|
||||||
List<TToggleMenu> menus, {
|
|
||||||
List<String> ownedActionIds = const [],
|
|
||||||
}) {
|
|
||||||
for (final actionId in ownedActionIds) {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
}
|
|
||||||
for (final menu in menus) {
|
|
||||||
final actionId = menu.actionId;
|
|
||||||
if (actionId == null) continue;
|
|
||||||
final onChanged = menu.onChanged;
|
|
||||||
if (onChanged == null) {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
} else {
|
|
||||||
final value = menu.value;
|
|
||||||
ffi.shortcutModel.register(actionId, () => onChanged(!value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return menus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Radio variant of [_registerToggleMenuShortcuts].
|
|
||||||
List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>(
|
|
||||||
FFI ffi,
|
|
||||||
List<TRadioMenu<T>> menus, {
|
|
||||||
List<String> ownedActionIds = const [],
|
|
||||||
}) {
|
|
||||||
for (final actionId in ownedActionIds) {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
}
|
|
||||||
for (final menu in menus) {
|
|
||||||
final actionId = menu.actionId;
|
|
||||||
if (actionId == null) continue;
|
|
||||||
final onChanged = menu.onChanged;
|
|
||||||
if (onChanged == null) {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
} else {
|
|
||||||
final value = menu.value;
|
|
||||||
ffi.shortcutModel.register(actionId, () => onChanged(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return menus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOsPasswordEditIcon(
|
handleOsPasswordEditIcon(
|
||||||
@@ -202,23 +163,179 @@ handleOsPasswordAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void showWaylandKeyboardInputWarningDialog(
|
||||||
|
{required String id,
|
||||||
|
required String connectionId,
|
||||||
|
required FFI ffi,
|
||||||
|
required Future<void> Function() onEnable}) {
|
||||||
|
bool remember = false;
|
||||||
|
bool consentInProgress = false;
|
||||||
|
bool dialogClosed = false;
|
||||||
|
|
||||||
|
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
|
||||||
|
void safeSetState(VoidCallback fn) {
|
||||||
|
if (dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setState(fn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Ignore setState after dialog disposal: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeDialog() {
|
||||||
|
if (dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogClosed = true;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enableAndContinue() async {
|
||||||
|
if (consentInProgress || dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consentInProgress = true;
|
||||||
|
safeSetState(() {});
|
||||||
|
try {
|
||||||
|
await onEnable();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Failed to enable Wayland keyboard input consent: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
consentInProgress = false;
|
||||||
|
safeSetState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ffi.inputModel.keyboardInputAllowed = true;
|
||||||
|
var rememberPersisted = true;
|
||||||
|
if (remember) {
|
||||||
|
try {
|
||||||
|
await bind.mainSetPeerOption(
|
||||||
|
id: id,
|
||||||
|
key: kPeerOptionAllowWaylandKeyboard,
|
||||||
|
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
|
||||||
|
} catch (e) {
|
||||||
|
rememberPersisted = false;
|
||||||
|
debugPrint('Failed to persist Wayland keyboard input consent: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always suppress prompt for current connection after explicit consent.
|
||||||
|
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
|
||||||
|
closeDialog();
|
||||||
|
if (remember && !rememberPersisted) {
|
||||||
|
// It's a rare edge case that persisting the user's choice fails.
|
||||||
|
// Failed to persist the user's choice, but still allow keyboard input for current session.
|
||||||
|
showToast(translate('Failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
if (consentInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: null,
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
msgboxContent(
|
||||||
|
'',
|
||||||
|
'wayland-keyboard-input-disabled-tip',
|
||||||
|
'wayland-keyboard-input-consent-tip',
|
||||||
|
),
|
||||||
|
SizedBox(height: isMobile ? 2 : 6),
|
||||||
|
if (isMobile) ...[
|
||||||
|
Text(
|
||||||
|
translate('wayland-keyboard-input-applies-to-tip'),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
).marginOnly(bottom: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
waylandKeyboardScopeChip(
|
||||||
|
context, translate('Send clipboard keystrokes')),
|
||||||
|
waylandKeyboardScopeChip(
|
||||||
|
context, translate('wayland-soft-keyboard-input-label')),
|
||||||
|
],
|
||||||
|
).marginOnly(bottom: 10),
|
||||||
|
],
|
||||||
|
TextButton(
|
||||||
|
onPressed: consentInProgress
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
final opened = await openWaylandKeyboardIssueUrl();
|
||||||
|
if (!opened) {
|
||||||
|
// Opening this optional help link almost never fails in
|
||||||
|
// normal desktop environments. Keep the result handled
|
||||||
|
// for review hygiene, but avoid a low-value user toast.
|
||||||
|
debugPrint('Failed to open Wayland keyboard issue URL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to open Wayland keyboard issue URL: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.blue,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
translate('Why this happens'),
|
||||||
|
style: const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
).marginOnly(bottom: 6),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: remember,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(translate('remember-wayland-keyboard-choice-tip')),
|
||||||
|
onChanged: consentInProgress
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
safeSetState(() => remember = v == true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton(
|
||||||
|
'Cancel',
|
||||||
|
onPressed: consentInProgress ? null : cancel,
|
||||||
|
isOutline: true,
|
||||||
|
),
|
||||||
|
dialogButton(
|
||||||
|
'OK',
|
||||||
|
onPressed:
|
||||||
|
consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onCancel: consentInProgress ? null : cancel,
|
||||||
|
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||||
|
);
|
||||||
|
}, clickMaskDismiss: false, backDismiss: false);
|
||||||
|
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
|
||||||
|
}
|
||||||
|
|
||||||
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final perms = ffiModel.permissions;
|
final perms = ffiModel.permissions;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||||
|
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||||
// Wipe stale registrations from previous menu builds before re-registering
|
|
||||||
// below; runs unconditionally so mid-session enable works without reconnect.
|
|
||||||
for (final actionId in _kToolbarOwnedActionIds) {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
}
|
|
||||||
// toggle_recording is mobile-only here; desktop's registration is owned by
|
|
||||||
// `registerSessionShortcutActions` and must not be touched.
|
|
||||||
if (!(isDesktop || isWeb)) {
|
|
||||||
ffi.shortcutModel.unregister(kShortcutActionToggleRecording);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TTextMenu> v = [];
|
List<TTextMenu> v = [];
|
||||||
// elevation
|
// elevation
|
||||||
@@ -268,20 +385,67 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Text(translate('Send clipboard keystrokes')),
|
child: Text(translate('Send clipboard keystrokes')),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
Future<void> sendClipboardKeystrokes() async {
|
||||||
if (data != null && data.text != null) {
|
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
bind.sessionInputString(
|
if (data != null && data.text != null) {
|
||||||
sessionId: sessionId, value: data.text ?? "");
|
bind.sessionInputString(
|
||||||
|
sessionId: sessionId, value: data.text ?? "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
actionId: kShortcutActionSendClipboardKeystrokes));
|
final allowWaylandKeyboard =
|
||||||
|
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
|
||||||
|
if (shouldShowWaylandKeyboardPrompt(
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
isWaylandPeer: isWaylandPeer,
|
||||||
|
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||||
|
)) {
|
||||||
|
ffi.inputModel.keyboardInputAllowed = false;
|
||||||
|
showWaylandKeyboardInputWarningDialog(
|
||||||
|
id: id,
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
ffi: ffi,
|
||||||
|
onEnable: sendClipboardKeystrokes,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendClipboardKeystrokes();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (isDefaultConn &&
|
||||||
|
isWaylandPeer &&
|
||||||
|
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
|
||||||
|
isWaylandKeyboardPromptSuppressedForConnection(
|
||||||
|
sessionId.toString()))) {
|
||||||
|
v.add(TTextMenu(
|
||||||
|
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
|
||||||
|
onPressed: () async {
|
||||||
|
var persistedCleared = false;
|
||||||
|
try {
|
||||||
|
await bind.mainSetPeerOption(
|
||||||
|
id: id,
|
||||||
|
key: kPeerOptionAllowWaylandKeyboard,
|
||||||
|
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
|
||||||
|
persistedCleared = true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to clear persisted Wayland keyboard permission: $e');
|
||||||
|
} finally {
|
||||||
|
clearWaylandKeyboardPromptSuppressedForConnection(
|
||||||
|
sessionId.toString());
|
||||||
|
ffi.inputModel.keyboardInputAllowed = false;
|
||||||
|
if (isMobile) {
|
||||||
|
await ffi.invokeMethod("enable_soft_keyboard", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
// reset canvas
|
// reset canvas
|
||||||
if (isDefaultConn && isMobile) {
|
if (isDefaultConn && isMobile) {
|
||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Text(translate('Reset canvas')),
|
child: Text(translate('Reset canvas')),
|
||||||
onPressed: () => ffi.cursorModel.reset(),
|
onPressed: () => ffi.cursorModel.reset()));
|
||||||
actionId: kShortcutActionResetCanvas));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/rustdesk/rustdesk/pull/9731
|
// https://github.com/rustdesk/rustdesk/pull/9731
|
||||||
@@ -357,8 +521,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text('${translate("Insert Ctrl + Alt + Del")}'),
|
child: Text('${translate("Insert Ctrl + Alt + Del")}'),
|
||||||
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId),
|
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)),
|
||||||
actionId: kShortcutActionSendCtrlAltDel),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// restart
|
// restart
|
||||||
@@ -371,8 +534,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('Restart remote device')),
|
child: Text(translate('Restart remote device')),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager),
|
showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)),
|
||||||
actionId: kShortcutActionRestartRemote),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// insertLock
|
// insertLock
|
||||||
@@ -380,8 +542,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('Insert Lock')),
|
child: Text(translate('Insert Lock')),
|
||||||
onPressed: () => bind.sessionLockScreen(sessionId: sessionId),
|
onPressed: () => bind.sessionLockScreen(sessionId: sessionId)),
|
||||||
actionId: kShortcutActionInsertLock),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// blockUserInput
|
// blockUserInput
|
||||||
@@ -399,8 +560,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
value: '${blockInput.value ? 'un' : ''}block-input');
|
value: '${blockInput.value ? 'un' : ''}block-input');
|
||||||
blockInput.value = !blockInput.value;
|
blockInput.value = !blockInput.value;
|
||||||
},
|
}));
|
||||||
actionId: kShortcutActionToggleBlockInput));
|
|
||||||
}
|
}
|
||||||
// switchSides
|
// switchSides
|
||||||
if (isDefaultConn &&
|
if (isDefaultConn &&
|
||||||
@@ -412,15 +572,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Text(translate('Switch Sides')),
|
child: Text(translate('Switch Sides')),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager),
|
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager)));
|
||||||
actionId: kShortcutActionSwitchSides));
|
|
||||||
}
|
}
|
||||||
// refresh
|
// refresh
|
||||||
if (pi.version.isNotEmpty) {
|
if (pi.version.isNotEmpty) {
|
||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Text(translate('Refresh')),
|
child: Text(translate('Refresh')),
|
||||||
onPressed: () => sessionRefreshVideo(sessionId, pi),
|
onPressed: () => sessionRefreshVideo(sessionId, pi),
|
||||||
actionId: kShortcutActionRefresh,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// record
|
// record
|
||||||
@@ -442,8 +600,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onPressed: () => ffi.recordingModel.toggle(),
|
onPressed: () => ffi.recordingModel.toggle()));
|
||||||
actionId: kShortcutActionToggleRecording));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// to-do:
|
// to-do:
|
||||||
@@ -460,14 +617,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
onPressed: ffi.ffiModel.timerScreenshot != null
|
onPressed: ffi.ffiModel.timerScreenshot != null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
// Live cooldown check: the menu rebuilds onPressed=null
|
|
||||||
// whenever toolbarControls runs and finds timerScreenshot
|
|
||||||
// != null, but the keyboard-shortcut callback holds onto
|
|
||||||
// the originally-enabled closure across cooldown periods
|
|
||||||
// (toolbarControls only re-runs on menu open). Without
|
|
||||||
// this guard the second shortcut press during the 30s
|
|
||||||
// cooldown still fires sessionTakeScreenshot.
|
|
||||||
if (ffi.ffiModel.timerScreenshot != null) return;
|
|
||||||
if (pi.currentDisplay == kAllDisplayValue) {
|
if (pi.currentDisplay == kAllDisplayValue) {
|
||||||
msgBox(
|
msgBox(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -485,7 +634,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actionId: kShortcutActionScreenshot,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,17 +644,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
|
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Register tagged TTextMenu callbacks. The else-unregister is defense in
|
|
||||||
// depth for actionIds tagged but missing from `_kToolbarOwnedActionIds`.
|
|
||||||
for (final menu in v) {
|
|
||||||
final actionId = menu.actionId;
|
|
||||||
if (actionId == null) continue;
|
|
||||||
if (menu.onPressed != null) {
|
|
||||||
ffi.shortcutModel.register(actionId, menu.onPressed!);
|
|
||||||
} else {
|
|
||||||
ffi.shortcutModel.unregister(actionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,26 +658,23 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
|||||||
.then((_) => ffi.canvasModel.updateViewStyle());
|
.then((_) => ffi.canvasModel.updateViewStyle());
|
||||||
}
|
}
|
||||||
|
|
||||||
return _registerRadioMenuShortcuts(ffi, [
|
return [
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Scale original')),
|
child: Text(translate('Scale original')),
|
||||||
value: kRemoteViewStyleOriginal,
|
value: kRemoteViewStyleOriginal,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged),
|
||||||
actionId: kShortcutActionViewModeOriginal),
|
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Scale adaptive')),
|
child: Text(translate('Scale adaptive')),
|
||||||
value: kRemoteViewStyleAdaptive,
|
value: kRemoteViewStyleAdaptive,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged),
|
||||||
actionId: kShortcutActionViewModeAdaptive),
|
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Scale custom')),
|
child: Text(translate('Scale custom')),
|
||||||
value: kRemoteViewStyleCustom,
|
value: kRemoteViewStyleCustom,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged)
|
||||||
actionId: kShortcutActionViewModeCustom)
|
];
|
||||||
], ownedActionIds: _kToolbarViewStyleActionIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
||||||
@@ -552,25 +686,22 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
|||||||
await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value);
|
await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _registerRadioMenuShortcuts(ffi, [
|
return [
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Good image quality')),
|
child: Text(translate('Good image quality')),
|
||||||
value: kRemoteImageQualityBest,
|
value: kRemoteImageQualityBest,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged),
|
||||||
actionId: kShortcutActionImageQualityBest),
|
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Balanced')),
|
child: Text(translate('Balanced')),
|
||||||
value: kRemoteImageQualityBalanced,
|
value: kRemoteImageQualityBalanced,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged),
|
||||||
actionId: kShortcutActionImageQualityBalanced),
|
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Optimize reaction time')),
|
child: Text(translate('Optimize reaction time')),
|
||||||
value: kRemoteImageQualityLow,
|
value: kRemoteImageQualityLow,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged),
|
||||||
actionId: kShortcutActionImageQualityLow),
|
|
||||||
TRadioMenu<String>(
|
TRadioMenu<String>(
|
||||||
child: Text(translate('Custom')),
|
child: Text(translate('Custom')),
|
||||||
value: kRemoteImageQualityCustom,
|
value: kRemoteImageQualityCustom,
|
||||||
@@ -580,7 +711,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
|||||||
customImageQualityDialog(ffi.sessionId, id, ffi);
|
customImageQualityDialog(ffi.sessionId, id, ffi);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
], ownedActionIds: _kToolbarImageQualityActionIds);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TRadioMenu<String>>> toolbarCodec(
|
Future<List<TRadioMenu<String>>> toolbarCodec(
|
||||||
@@ -607,10 +738,7 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
|
|||||||
}
|
}
|
||||||
final visible =
|
final visible =
|
||||||
codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]);
|
codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]);
|
||||||
if (!visible) {
|
if (!visible) return [];
|
||||||
return _registerRadioMenuShortcuts<String>(ffi, [],
|
|
||||||
ownedActionIds: _kToolbarCodecActionIds);
|
|
||||||
}
|
|
||||||
onChanged(String? value) async {
|
onChanged(String? value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionPeerOption(
|
await bind.sessionPeerOption(
|
||||||
@@ -618,14 +746,12 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
|
|||||||
bind.sessionChangePreferCodec(sessionId: sessionId);
|
bind.sessionChangePreferCodec(sessionId: sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
TRadioMenu<String> radio(
|
TRadioMenu<String> radio(String label, String value, bool enabled) {
|
||||||
String label, String value, bool enabled, String actionId) {
|
|
||||||
return TRadioMenu<String>(
|
return TRadioMenu<String>(
|
||||||
child: Text(label),
|
child: Text(label),
|
||||||
value: value,
|
value: value,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
onChanged: enabled ? onChanged : null,
|
onChanged: enabled ? onChanged : null);
|
||||||
actionId: actionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var autoLabel = translate('Auto');
|
var autoLabel = translate('Auto');
|
||||||
@@ -633,14 +759,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
|
|||||||
ffi.qualityMonitorModel.data.codecFormat != null) {
|
ffi.qualityMonitorModel.data.codecFormat != null) {
|
||||||
autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})';
|
autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})';
|
||||||
}
|
}
|
||||||
return _registerRadioMenuShortcuts(ffi, [
|
return [
|
||||||
radio(autoLabel, 'auto', true, kShortcutActionCodecAuto),
|
radio(autoLabel, 'auto', true),
|
||||||
if (codecs[0]) radio('VP8', 'vp8', codecs[0], kShortcutActionCodecVp8),
|
if (codecs[0]) radio('VP8', 'vp8', codecs[0]),
|
||||||
radio('VP9', 'vp9', true, kShortcutActionCodecVp9),
|
radio('VP9', 'vp9', true),
|
||||||
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
|
if (codecs[1]) radio('AV1', 'av1', codecs[1]),
|
||||||
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
|
if (codecs[2]) radio('H264', 'h264', codecs[2]),
|
||||||
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
|
if (codecs[3]) radio('H265', 'h265', codecs[3]),
|
||||||
], ownedActionIds: _kToolbarCodecActionIds);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TToggleMenu>> toolbarCursor(
|
Future<List<TToggleMenu>> toolbarCursor(
|
||||||
@@ -665,7 +791,6 @@ Future<List<TToggleMenu>> toolbarCursor(
|
|||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
child: Text(translate('Show remote cursor')),
|
child: Text(translate('Show remote cursor')),
|
||||||
value: state.value,
|
value: state.value,
|
||||||
actionId: kShortcutActionToggleShowRemoteCursor,
|
|
||||||
onChanged: enabled && !lockState.value
|
onChanged: enabled && !lockState.value
|
||||||
? (value) async {
|
? (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
@@ -702,7 +827,6 @@ Future<List<TToggleMenu>> toolbarCursor(
|
|||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
child: Text(translate('Follow remote cursor')),
|
child: Text(translate('Follow remote cursor')),
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleFollowRemoteCursor,
|
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -731,7 +855,6 @@ Future<List<TToggleMenu>> toolbarCursor(
|
|||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
child: Text(translate('Follow remote window focus')),
|
child: Text(translate('Follow remote window focus')),
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleFollowRemoteWindow,
|
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -749,7 +872,6 @@ Future<List<TToggleMenu>> toolbarCursor(
|
|||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
child: Text(translate('Zoom cursor')),
|
child: Text(translate('Zoom cursor')),
|
||||||
value: peerState.value,
|
value: peerState.value,
|
||||||
actionId: kShortcutActionToggleZoomCursor,
|
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -758,8 +880,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return _registerToggleMenuShortcuts(ffi, v,
|
return v;
|
||||||
ownedActionIds: _kToolbarCursorActionIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TToggleMenu>> toolbarDisplayToggle(
|
Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||||
@@ -775,7 +896,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
final option = 'show-quality-monitor';
|
final option = 'show-quality-monitor';
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option),
|
value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option),
|
||||||
actionId: kShortcutActionToggleQualityMonitor,
|
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -789,7 +909,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleMute,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
bind.sessionToggleOption(sessionId: sessionId, value: option);
|
bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -814,7 +933,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
sessionId: sessionId, arg: kOptionEnableFileCopyPaste);
|
sessionId: sessionId, arg: kOptionEnableFileCopyPaste);
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleEnableFileCopyPaste,
|
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
@@ -833,7 +951,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
if (ffiModel.viewOnly) value = true;
|
if (ffiModel.viewOnly) value = true;
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleDisableClipboard,
|
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
@@ -850,7 +967,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleLockAfterSessionEnd,
|
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
@@ -860,8 +976,10 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
child: Text(translate('Lock after session end'))));
|
child: Text(translate('Lock after session end'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final privacyModeState = PrivacyModeState.find(id);
|
||||||
if (pi.isSupportMultiDisplay &&
|
if (pi.isSupportMultiDisplay &&
|
||||||
PrivacyModeState.find(id).isEmpty &&
|
(privacyModeState.isEmpty ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||||
pi.displaysCount.value > 1 &&
|
pi.displaysCount.value > 1 &&
|
||||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||||
final value =
|
final value =
|
||||||
@@ -901,7 +1019,6 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleTrueColor,
|
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||||
@@ -926,8 +1043,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
},
|
},
|
||||||
child: Text(translate('View Mode'))));
|
child: Text(translate('View Mode'))));
|
||||||
}
|
}
|
||||||
return _registerToggleMenuShortcuts(ffi, v,
|
return v;
|
||||||
ownedActionIds: _kToolbarDisplayToggleActionIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
|
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
|
||||||
@@ -937,23 +1053,38 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
|
final hasPrivacyModePermission =
|
||||||
|
ffiModel.permissions['privacy_mode'] != false;
|
||||||
|
|
||||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
// Backend revocation already attempts to turn privacy mode off.
|
||||||
final enabled = !ffi.ffiModel.viewOnly;
|
// Still keep this menu when privacy mode is active, so users can turn it off
|
||||||
|
// if there is a sync delay, version mismatch, or off attempt failure.
|
||||||
|
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
|
||||||
|
return []; // No permission and not active, hide options.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
|
||||||
|
if (!turnOn ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
|
||||||
|
(ffiModel.pi.currentDisplay == 0 &&
|
||||||
|
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
|
||||||
|
'Please switch to Display 1 first', '', ffi.dialogManager);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
|
||||||
|
String targetImplKey) {
|
||||||
|
final enabled = !ffiModel.viewOnly &&
|
||||||
|
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||||
return TToggleMenu(
|
return TToggleMenu(
|
||||||
value: privacyModeState.isNotEmpty,
|
value: privacyModeState.isNotEmpty,
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
if (ffiModel.pi.currentDisplay != 0 &&
|
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
|
||||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
|
||||||
msgBox(
|
|
||||||
sessionId,
|
|
||||||
'custom-nook-nocancel-hasclose',
|
|
||||||
'info',
|
|
||||||
'Please switch to Display 1 first',
|
|
||||||
'',
|
|
||||||
ffi.dialogManager);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final option = 'privacy-mode';
|
final option = 'privacy-mode';
|
||||||
@@ -971,7 +1102,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
getDefaultMenu((sid, opt) async {
|
getDefaultMenu((sid, opt) async {
|
||||||
bind.sessionToggleOption(sessionId: sid, value: opt);
|
bind.sessionToggleOption(sessionId: sid, value: opt);
|
||||||
togglePrivacyModeTime = DateTime.now();
|
togglePrivacyModeTime = DateTime.now();
|
||||||
})
|
}, kPrivacyModeImplMag)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (privacyModeImpls.isEmpty) {
|
if (privacyModeImpls.isEmpty) {
|
||||||
@@ -985,21 +1116,35 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
bind.sessionTogglePrivacyMode(
|
bind.sessionTogglePrivacyMode(
|
||||||
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
||||||
togglePrivacyModeTime = DateTime.now();
|
togglePrivacyModeTime = DateTime.now();
|
||||||
})
|
}, implKey)
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return privacyModeImpls.map((e) {
|
final visibleImpls = hasPrivacyModePermission
|
||||||
|
? privacyModeImpls
|
||||||
|
: privacyModeImpls.where((e) {
|
||||||
|
final implKey = (e as List<dynamic>)[0] as String;
|
||||||
|
return privacyModeState.value == implKey;
|
||||||
|
}).toList();
|
||||||
|
return visibleImpls.map((e) {
|
||||||
final implKey = (e as List<dynamic>)[0] as String;
|
final implKey = (e as List<dynamic>)[0] as String;
|
||||||
final implName = (e)[1] as String;
|
final implName = (e)[1] as String;
|
||||||
|
final enabled = !ffiModel.viewOnly &&
|
||||||
|
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||||
return TToggleMenu(
|
return TToggleMenu(
|
||||||
child: Text(translate(implName)),
|
child: Text(translate(implName)),
|
||||||
value: privacyModeState.value == implKey,
|
value: privacyModeState.value == implKey,
|
||||||
onChanged: (value) {
|
onChanged: enabled
|
||||||
if (value == null) return;
|
? (value) {
|
||||||
togglePrivacyModeTime = DateTime.now();
|
if (value == null) return;
|
||||||
bind.sessionTogglePrivacyMode(
|
if (value && !hasPrivacyModePermission) return;
|
||||||
sessionId: sessionId, implKey: implKey, on: value);
|
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
togglePrivacyModeTime = DateTime.now();
|
||||||
|
bind.sessionTogglePrivacyMode(
|
||||||
|
sessionId: sessionId, implKey: implKey, on: value);
|
||||||
|
}
|
||||||
|
: null);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1171,6 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
final enabled = !ffi.ffiModel.viewOnly;
|
final enabled = !ffi.ffiModel.viewOnly;
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleSwapCtrlCmd,
|
|
||||||
onChanged: enabled ? onChanged : null,
|
onChanged: enabled ? onChanged : null,
|
||||||
child: Text(translate('Swap control-command key'))));
|
child: Text(translate('Swap control-command key'))));
|
||||||
}
|
}
|
||||||
@@ -1092,27 +1236,10 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
final enabled = !ffi.ffiModel.viewOnly;
|
final enabled = !ffi.ffiModel.viewOnly;
|
||||||
v.add(TToggleMenu(
|
v.add(TToggleMenu(
|
||||||
value: value,
|
value: value,
|
||||||
actionId: kShortcutActionToggleSwapLeftRightMouse,
|
|
||||||
onChanged: enabled ? onChanged : null,
|
onChanged: enabled ? onChanged : null,
|
||||||
child: Text(translate('swap-left-right-mouse'))));
|
child: Text(translate('swap-left-right-mouse'))));
|
||||||
}
|
}
|
||||||
return _registerToggleMenuShortcuts(ffi, v,
|
return v;
|
||||||
ownedActionIds: _kToolbarKeyboardToggleActionIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drive each toolbar helper for its registration side effect, so a shortcut
|
|
||||||
/// fires from the first keystroke without needing the user to open the
|
|
||||||
/// matching submenu. Mobile gets `toolbarKeyboardToggles` via
|
|
||||||
/// `toolbarDisplayToggle`'s `isMobile` branch — calling it explicitly there
|
|
||||||
/// would double-register.
|
|
||||||
void registerToolbarShortcuts(BuildContext context, String id, FFI ffi) {
|
|
||||||
if (isDesktop) toolbarKeyboardToggles(ffi);
|
|
||||||
unawaited(toolbarCursor(context, id, ffi));
|
|
||||||
unawaited(toolbarDisplayToggle(context, id, ffi));
|
|
||||||
unawaited(toolbarViewStyle(context, id, ffi));
|
|
||||||
unawaited(toolbarImageQuality(context, id, ffi));
|
|
||||||
unawaited(toolbarCodec(context, id, ffi));
|
|
||||||
toolbarPrivacyMode(PrivacyModeState.find(id), context, id, ffi);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool showVirtualDisplayMenu(FFI ffi) {
|
bool showVirtualDisplayMenu(FFI ffi) {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import 'package:flutter_hbb/common.dart';
|
|||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart';
|
|
||||||
|
|
||||||
const int kMaxVirtualDisplayCount = 4;
|
const int kMaxVirtualDisplayCount = 4;
|
||||||
const int kAllVirtualDisplay = -1;
|
const int kAllVirtualDisplay = -1;
|
||||||
|
|
||||||
@@ -31,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
|||||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||||
"supported_privacy_mode_impl";
|
"supported_privacy_mode_impl";
|
||||||
|
|
||||||
|
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
|
||||||
|
const String kPrivacyModeImplExcludeFromCapture =
|
||||||
|
'privacy_mode_impl_exclude_from_capture';
|
||||||
|
|
||||||
const String kPeerPlatformWindows = "Windows";
|
const String kPeerPlatformWindows = "Windows";
|
||||||
const String kPeerPlatformLinux = "Linux";
|
const String kPeerPlatformLinux = "Linux";
|
||||||
const String kPeerPlatformMacOS = "Mac OS";
|
const String kPeerPlatformMacOS = "Mac OS";
|
||||||
@@ -116,6 +118,9 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
|||||||
const String kOptionEnableTunnel = "enable-tunnel";
|
const String kOptionEnableTunnel = "enable-tunnel";
|
||||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||||
const String kOptionEnableBlockInput = "enable-block-input";
|
const String kOptionEnableBlockInput = "enable-block-input";
|
||||||
|
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||||
|
const String kOptionEnablePermChangeInAcceptWindow =
|
||||||
|
"enable-perm-change-in-accept-window";
|
||||||
const String kOptionAllowRemoteConfigModification =
|
const String kOptionAllowRemoteConfigModification =
|
||||||
"allow-remote-config-modification";
|
"allow-remote-config-modification";
|
||||||
const String kOptionVerificationMethod = "verification-method";
|
const String kOptionVerificationMethod = "verification-method";
|
||||||
@@ -141,6 +146,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
|
|||||||
const String kOptionCodecPreference = "codec-preference";
|
const String kOptionCodecPreference = "codec-preference";
|
||||||
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
||||||
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
||||||
|
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
|
||||||
|
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
|
||||||
|
const String kOptionAllowMultiEdgeToolbarDock =
|
||||||
|
"allow-multi-edge-toolbar-dock";
|
||||||
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
||||||
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
||||||
const String kOptionPeerSorting = "peer-sorting";
|
const String kOptionPeerSorting = "peer-sorting";
|
||||||
@@ -165,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
|||||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||||
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||||
|
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||||
|
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||||
|
|
||||||
// network options
|
// network options
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
.contains(textToFind) ||
|
.contains(textToFind) ||
|
||||||
peer.alias.toLowerCase().contains(textToFind))
|
peer.alias.toLowerCase().contains(textToFind))
|
||||||
.toList();
|
.toList();
|
||||||
|
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||||
}
|
}
|
||||||
return _autocompleteOpts;
|
return _autocompleteOpts;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart
|
|
||||||
//
|
|
||||||
// Desktop shell for the Keyboard Shortcuts configuration page. Users land
|
|
||||||
// here from the General settings tab. The page exposes:
|
|
||||||
// * A top-level enable/disable toggle (mirrors the General-tab toggle —
|
|
||||||
// same JSON key, same semantics).
|
|
||||||
// * A grouped, scrollable list of actions, each with a current binding and
|
|
||||||
// edit / clear icons.
|
|
||||||
// * An AppBar "Reset to defaults" action with a confirmation dialog.
|
|
||||||
//
|
|
||||||
// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the
|
|
||||||
// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and
|
|
||||||
// Web matchers consume.
|
|
||||||
//
|
|
||||||
// The body — group definitions, JSON I/O, conflict-replace flow,
|
|
||||||
// recording-dialog round-trip — lives in
|
|
||||||
// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the
|
|
||||||
// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
|
|
||||||
import '../../common.dart';
|
|
||||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
|
||||||
|
|
||||||
class DesktopKeyboardShortcutsPage extends StatefulWidget {
|
|
||||||
const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DesktopKeyboardShortcutsPage> createState() =>
|
|
||||||
_DesktopKeyboardShortcutsPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DesktopKeyboardShortcutsPageState
|
|
||||||
extends State<DesktopKeyboardShortcutsPage> {
|
|
||||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final foregroundColor =
|
|
||||||
AppBarTheme.of(context).titleTextStyle?.color ?? Colors.white;
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(translate('Keyboard Shortcuts')),
|
|
||||||
actions: [
|
|
||||||
TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(foregroundColor: foregroundColor),
|
|
||||||
onPressed: () =>
|
|
||||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
|
||||||
icon: const Icon(Icons.restore),
|
|
||||||
label: Text(translate('Reset to defaults')),
|
|
||||||
).marginOnly(right: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: KeyboardShortcutsPageBody(
|
|
||||||
key: _bodyKey,
|
|
||||||
compact: true,
|
|
||||||
// Desktop's General settings tab already exposes the Enable +
|
|
||||||
// Pass-through checkboxes (it's the only entry point to this page),
|
|
||||||
// so we hide the duplicates here. Mobile shells keep the default
|
|
||||||
// (true) because their entry tile doesn't carry the toggles.
|
|
||||||
showMasterToggles: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,12 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart';
|
|||||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/page_body.dart';
|
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart';
|
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/printer_model.dart';
|
import 'package:flutter_hbb/models/printer_model.dart';
|
||||||
import 'package:flutter_hbb/models/server_model.dart';
|
import 'package:flutter_hbb/models/server_model.dart';
|
||||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/plugin/manager.dart';
|
import 'package:flutter_hbb/plugin/manager.dart';
|
||||||
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
||||||
@@ -410,6 +407,7 @@ class _GeneralState extends State<_General> {
|
|||||||
final RxBool serviceStop =
|
final RxBool serviceStop =
|
||||||
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
||||||
RxBool serviceBtnEnabled = true.obs;
|
RxBool serviceBtnEnabled = true.obs;
|
||||||
|
final GlobalKey _minToolbarOptionKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -424,50 +422,11 @@ class _GeneralState extends State<_General> {
|
|||||||
if (!isWeb) audio(context),
|
if (!isWeb) audio(context),
|
||||||
if (!isWeb) record(context),
|
if (!isWeb) record(context),
|
||||||
if (!isWeb) WaylandCard(),
|
if (!isWeb) WaylandCard(),
|
||||||
other(),
|
other()
|
||||||
if (!bind.isIncomingOnly()) keyboardShortcuts(),
|
|
||||||
],
|
],
|
||||||
).marginOnly(bottom: _kListViewBottomMargin);
|
).marginOnly(bottom: _kListViewBottomMargin);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget keyboardShortcuts() {
|
|
||||||
// The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three
|
|
||||||
// flags + the bindings list: {enabled, pass_through, bindings}. When the
|
|
||||||
// master is off, the pass-through toggle and the Configure entry are
|
|
||||||
// hidden — both are meaningless without an active matcher.
|
|
||||||
return StatefulBuilder(builder: (context, setLocalState) {
|
|
||||||
final enabled = ShortcutModel.isEnabled();
|
|
||||||
return _Card(title: 'Keyboard Shortcuts', children: [
|
|
||||||
_OptionCheckBox(
|
|
||||||
context,
|
|
||||||
'Enable keyboard shortcuts in remote session',
|
|
||||||
kShortcutLocalConfigKey,
|
|
||||||
isServer: false,
|
|
||||||
optGetter: ShortcutModel.isEnabled,
|
|
||||||
optSetter: (_, v) async {
|
|
||||||
await ShortcutModel.setEnabled(v);
|
|
||||||
setLocalState(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (enabled) ...[
|
|
||||||
_OptionCheckBox(
|
|
||||||
context,
|
|
||||||
'Pass-through to remote',
|
|
||||||
kShortcutLocalConfigKey,
|
|
||||||
isServer: false,
|
|
||||||
optGetter: ShortcutModel.isPassThrough,
|
|
||||||
optSetter: (_, v) async {
|
|
||||||
await ShortcutModel.setPassThrough(v);
|
|
||||||
setLocalState(() {});
|
|
||||||
},
|
|
||||||
trailing: const InfoTooltipIcon(tipKey: 'shortcut-passthrough-tip'),
|
|
||||||
),
|
|
||||||
_ShortcutsConfigureRow(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget theme() {
|
Widget theme() {
|
||||||
final current = MyTheme.getThemeModePreference().toShortString();
|
final current = MyTheme.getThemeModePreference().toShortString();
|
||||||
onChanged(String value) async {
|
onChanged(String value) async {
|
||||||
@@ -530,6 +489,16 @@ class _GeneralState extends State<_General> {
|
|||||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||||
kOptionEnableConfirmClosingTabs,
|
kOptionEnableConfirmClosingTabs,
|
||||||
isServer: false),
|
isServer: false),
|
||||||
|
if (!bind.isIncomingOnly())
|
||||||
|
_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'allow-remote-toolbar-docking-any-edge',
|
||||||
|
kOptionAllowMultiEdgeToolbarDock,
|
||||||
|
isServer: false,
|
||||||
|
update: (_) {
|
||||||
|
reloadAllWindows();
|
||||||
|
},
|
||||||
|
),
|
||||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||||
if (!isWeb) wallpaper(),
|
if (!isWeb) wallpaper(),
|
||||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||||
@@ -637,6 +606,47 @@ class _GeneralState extends State<_General> {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
children.add(_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'Show monitor switch button on the main toolbar',
|
||||||
|
kOptionAllowMonitorSwitchMainToolbar,
|
||||||
|
isServer: false,
|
||||||
|
update: (enabled) async {
|
||||||
|
if (!enabled) {
|
||||||
|
await mainSetLocalBoolOption(
|
||||||
|
kOptionAllowMonitorSwitchMinToolbar, false);
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
reloadAllWindows();
|
||||||
|
if (enabled) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final ctx = _minToolbarOptionKey.currentContext;
|
||||||
|
if (ctx != null) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
ctx,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||||
|
children.add(KeyedSubtree(
|
||||||
|
key: _minToolbarOptionKey,
|
||||||
|
child: _OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'Show on the minimized toolbar',
|
||||||
|
kOptionAllowMonitorSwitchMinToolbar,
|
||||||
|
isServer: false,
|
||||||
|
update: (_) {
|
||||||
|
reloadAllWindows();
|
||||||
|
},
|
||||||
|
).marginOnly(left: _kCheckBoxLeftMargin * 3),
|
||||||
|
));
|
||||||
|
}
|
||||||
return _Card(title: 'Other', children: children);
|
return _Card(title: 'Other', children: children);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,6 +1114,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
_OptionCheckBox(context, 'Enable blocking user input',
|
_OptionCheckBox(context, 'Enable blocking user input',
|
||||||
kOptionEnableBlockInput,
|
kOptionEnableBlockInput,
|
||||||
enabled: enabled, fakeValue: fakeValue),
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
|
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||||
|
_OptionCheckBox(
|
||||||
|
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||||
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||||
kOptionAllowRemoteConfigModification,
|
kOptionAllowRemoteConfigModification,
|
||||||
enabled: enabled, fakeValue: fakeValue),
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
@@ -2534,8 +2548,6 @@ Widget _OptionCheckBox(
|
|||||||
bool isServer = true,
|
bool isServer = true,
|
||||||
bool Function()? optGetter,
|
bool Function()? optGetter,
|
||||||
Future<void> Function(String, bool)? optSetter,
|
Future<void> Function(String, bool)? optSetter,
|
||||||
// Optional widget rendered between the label and the trailing space.
|
|
||||||
Widget? trailing,
|
|
||||||
}) {
|
}) {
|
||||||
getOpt() => optGetter != null
|
getOpt() => optGetter != null
|
||||||
? optGetter()
|
? optGetter()
|
||||||
@@ -2579,23 +2591,11 @@ Widget _OptionCheckBox(
|
|||||||
offstage: !ref.value || checkedIcon == null,
|
offstage: !ref.value || checkedIcon == null,
|
||||||
child: checkedIcon?.marginOnly(right: 5),
|
child: checkedIcon?.marginOnly(right: 5),
|
||||||
),
|
),
|
||||||
// Without `trailing`, keep the original Expanded(Text) layout.
|
Expanded(
|
||||||
if (trailing == null)
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
translate(label),
|
|
||||||
style: TextStyle(color: disabledTextColor(context, enabled)),
|
|
||||||
))
|
|
||||||
else ...[
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
child: Text(
|
||||||
translate(label),
|
translate(label),
|
||||||
style: TextStyle(color: disabledTextColor(context, enabled)),
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
||||||
),
|
))
|
||||||
),
|
|
||||||
trailing,
|
|
||||||
const Spacer(),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
).marginOnly(left: _kCheckBoxLeftMargin),
|
).marginOnly(left: _kCheckBoxLeftMargin),
|
||||||
@@ -3002,37 +3002,6 @@ class _CountDownButtonState extends State<_CountDownButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tappable row that pushes the shortcut configuration page.
|
|
||||||
class _ShortcutsConfigureRow extends StatelessWidget {
|
|
||||||
// ignore: unused_element
|
|
||||||
const _ShortcutsConfigureRow({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
|
||||||
builder: (_) => const DesktopKeyboardShortcutsPage(),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(translate('Configure shortcuts...')),
|
|
||||||
),
|
|
||||||
Icon(Icons.arrow_forward_ios,
|
|
||||||
size: 16, color: disabledTextColor(context, true))
|
|
||||||
.marginOnly(right: 4),
|
|
||||||
],
|
|
||||||
).marginOnly(
|
|
||||||
left: _kCheckBoxLeftMargin,
|
|
||||||
top: 6,
|
|
||||||
bottom: 6,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region dialogs
|
//#region dialogs
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
|||||||
late final TextEditingController controller;
|
late final TextEditingController controller;
|
||||||
final RxBool startmenu = true.obs;
|
final RxBool startmenu = true.obs;
|
||||||
final RxBool desktopicon = true.obs;
|
final RxBool desktopicon = true.obs;
|
||||||
final RxBool printer = true.obs;
|
final RxBool printer = false.obs;
|
||||||
final RxBool showProgress = false.obs;
|
final RxBool showProgress = false.obs;
|
||||||
final RxBool btnEnabled = true.obs;
|
final RxBool btnEnabled = true.obs;
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
|||||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||||
printer.value = installOptions['PRINTER'] != '0';
|
printer.value = installOptions['PRINTER'] == '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import '../../common/widgets/toolbar.dart';
|
|||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/input_model.dart';
|
import '../../models/input_model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../models/shortcut_model.dart';
|
|
||||||
import '../../common/shared_state.dart';
|
import '../../common/shared_state.dart';
|
||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
import '../widgets/remote_toolbar.dart';
|
import '../widgets/remote_toolbar.dart';
|
||||||
@@ -102,6 +101,9 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
|
Worker? _waylandKeyboardModeWorker;
|
||||||
|
bool _waylandKeyboardModeNormalized = false;
|
||||||
|
bool _waylandKeyboardModeNormalizing = false;
|
||||||
|
|
||||||
SessionID get sessionId => _ffi.sessionId;
|
SessionID get sessionId => _ffi.sessionId;
|
||||||
|
|
||||||
@@ -127,18 +129,6 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||||
_ffi.recordingModel
|
_ffi.recordingModel
|
||||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||||
// Seed shortcut action callbacks once the session is ready, so that
|
|
||||||
// global keyboard shortcuts work even if the user never opens the
|
|
||||||
// toolbar menu. The returned list is intentionally discarded — the
|
|
||||||
// side effect of registering callbacks (inside toolbarControls) is
|
|
||||||
// what we want here.
|
|
||||||
if (mounted) {
|
|
||||||
toolbarControls(context, widget.id, _ffi);
|
|
||||||
registerSessionShortcutActions(_ffi,
|
|
||||||
tabController: widget.tabController,
|
|
||||||
toolbarState: widget.toolbarState);
|
|
||||||
registerToolbarShortcuts(context, widget.id, _ffi);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||||
_ffi.start(
|
_ffi.start(
|
||||||
@@ -191,6 +181,48 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||||
_ffi.inputModel.onRelativeMouseModeDisabled =
|
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||||
_cancelPointerLockCenterDebounceTimer;
|
_cancelPointerLockCenterDebounceTimer;
|
||||||
|
|
||||||
|
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
|
||||||
|
if (isSet) {
|
||||||
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (_ffi.ffiModel.pi.isSet.value) {
|
||||||
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
|
||||||
|
if (!mounted ||
|
||||||
|
_waylandKeyboardModeNormalized ||
|
||||||
|
_waylandKeyboardModeNormalizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_waylandKeyboardModeNormalizing = true;
|
||||||
|
try {
|
||||||
|
final pi = _ffi.ffiModel.pi;
|
||||||
|
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
|
||||||
|
final mapSupported = bind.sessionIsKeyboardModeSupported(
|
||||||
|
sessionId: sessionId, mode: kKeyMapMode);
|
||||||
|
if (!mapSupported) return;
|
||||||
|
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (current == kKeyMapMode) {
|
||||||
|
_waylandKeyboardModeNormalized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bind.sessionSetKeyboardMode(
|
||||||
|
sessionId: sessionId, value: kKeyMapMode);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _ffi.inputModel.updateKeyboardMode();
|
||||||
|
if (!mounted) return;
|
||||||
|
_waylandKeyboardModeNormalized = true;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Failed to normalize Wayland keyboard mode: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
} finally {
|
||||||
|
_waylandKeyboardModeNormalizing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel the pointer lock center debounce timer
|
/// Cancel the pointer lock center debounce timer
|
||||||
@@ -331,6 +363,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
|
|
||||||
_pointerLockCenterDebounceTimer?.cancel();
|
_pointerLockCenterDebounceTimer?.cancel();
|
||||||
_pointerLockCenterDebounceTimer = null;
|
_pointerLockCenterDebounceTimer = null;
|
||||||
|
_waylandKeyboardModeWorker?.dispose();
|
||||||
// Clear callback reference to prevent memory leaks and stale references
|
// Clear callback reference to prevent memory leaks and stale references
|
||||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||||
@@ -344,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.imageModel.disposeImage();
|
_ffi.imageModel.disposeImage();
|
||||||
_ffi.cursorModel.disposeImages();
|
_ffi.cursorModel.disposeImages();
|
||||||
_rawKeyFocusNode.dispose();
|
_rawKeyFocusNode.dispose();
|
||||||
|
if (closeSession) {
|
||||||
|
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||||
|
}
|
||||||
await _ffi.close(closeSession: closeSession);
|
await _ffi.close(closeSession: closeSession);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_ffi.dialogManager.dismissAll();
|
_ffi.dialogManager.dismissAll();
|
||||||
|
|||||||
@@ -610,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget {
|
|||||||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||||
late final client = widget.client;
|
late final client = widget.client;
|
||||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||||
Function(bool)? onTap, String tooltipText) {
|
Function(bool)? onTap, String tooltipText,
|
||||||
|
{required bool canModify}) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||||
waitDuration: Duration.zero,
|
waitDuration: Duration.zero,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
color: enabled
|
||||||
|
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||||
|
: Colors.grey[700],
|
||||||
borderRadius: BorderRadius.circular(10.0),
|
borderRadius: BorderRadius.circular(10.0),
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () =>
|
onTap: canModify
|
||||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
? () =>
|
||||||
|
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||||
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
@@ -643,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final crossAxisCount = 4;
|
final crossAxisCount = 4;
|
||||||
final spacing = 10.0;
|
final spacing = 10.0;
|
||||||
|
final canModifyPermission =
|
||||||
|
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||||
|
'N';
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 160.0,
|
height: 160.0,
|
||||||
@@ -689,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable audio'),
|
translate('Enable audio'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.recording,
|
client.recording,
|
||||||
@@ -703,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable recording session'),
|
translate('Enable recording session'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -719,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable keyboard/mouse'),
|
translate('Enable keyboard/mouse'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.clipboard,
|
client.clipboard,
|
||||||
@@ -733,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable clipboard'),
|
translate('Enable clipboard'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.audio,
|
client.audio,
|
||||||
@@ -747,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable audio'),
|
translate('Enable audio'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.file,
|
client.file,
|
||||||
@@ -761,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable file copy and paste'),
|
translate('Enable file copy and paste'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.restart,
|
client.restart,
|
||||||
@@ -775,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable remote restart'),
|
translate('Enable remote restart'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.recording,
|
client.recording,
|
||||||
@@ -789,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable recording session'),
|
translate('Enable recording session'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
// only windows support block input
|
// only windows support block input
|
||||||
if (isWindows)
|
if (isWindows)
|
||||||
@@ -805,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable blocking user input'),
|
translate('Enable blocking user input'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
|
),
|
||||||
|
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||||
|
buildPermissionIcon(
|
||||||
|
client.privacyMode,
|
||||||
|
Icons.visibility_off,
|
||||||
|
(enabled) {
|
||||||
|
bind.cmSwitchPermission(
|
||||||
|
connId: client.id,
|
||||||
|
name: "privacy_mode",
|
||||||
|
enabled: enabled);
|
||||||
|
setState(() {
|
||||||
|
client.privacyMode = enabled;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
translate('Enable privacy mode'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
|
|||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final int terminalId;
|
final int terminalId;
|
||||||
|
|
||||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||||
final String tabKey;
|
final String tabKey;
|
||||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
static const EdgeInsets _defaultTerminalPadding =
|
||||||
|
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
double? _cellHeight;
|
double? _cellHeight;
|
||||||
@@ -155,13 +159,27 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
// extra space left after dividing the available height by the height of a single
|
// extra space left after dividing the available height by the height of a single
|
||||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||||
EdgeInsets _calculatePadding(double heightPx) {
|
EdgeInsets _calculatePadding(double heightPx) {
|
||||||
if (_cellHeight == null) {
|
final cellHeight = _cellHeight;
|
||||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
if (!heightPx.isFinite ||
|
||||||
|
heightPx <= 0 ||
|
||||||
|
cellHeight == null ||
|
||||||
|
!cellHeight.isFinite ||
|
||||||
|
cellHeight <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final rows = (heightPx / cellHeight).floor();
|
||||||
|
if (rows <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final extraSpace = heightPx - rows * cellHeight;
|
||||||
|
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
}
|
}
|
||||||
final rows = (heightPx / _cellHeight!).floor();
|
|
||||||
final extraSpace = heightPx - rows * _cellHeight!;
|
|
||||||
final topBottom = extraSpace / 2.0;
|
final topBottom = extraSpace / 2.0;
|
||||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
return EdgeInsets.symmetric(
|
||||||
|
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||||
|
vertical: topBottom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
.setTitle(getWindowNameWithId(id));
|
.setTitle(getWindowNameWithId(id));
|
||||||
};
|
};
|
||||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||||
|
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||||
tabController.add(_createTerminalTab(
|
tabController.add(_createTerminalTab(
|
||||||
peerId: params['id'],
|
peerId: params['id'],
|
||||||
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
_windowClosing = true;
|
_windowClosing = true;
|
||||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||||
|
// Keep the cleanup target lookup below synchronous before its first await:
|
||||||
|
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||||
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||||
|
var peerId = args['peer_id'] as String? ?? '';
|
||||||
|
if (peerId.isEmpty) {
|
||||||
|
if (tabController.state.value.tabs.isEmpty ||
|
||||||
|
tabController.state.value.selected >=
|
||||||
|
tabController.state.value.tabs.length) {
|
||||||
|
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
|
final parsed = _parseTabKey(currentTab.key);
|
||||||
|
if (parsed == null) return;
|
||||||
|
peerId = parsed.$1;
|
||||||
|
}
|
||||||
|
final existingTerminalIds = tabController.state.value.tabs
|
||||||
|
.map((tab) => _parseTabKey(tab.key))
|
||||||
|
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||||
|
.map((parsed) => parsed!.$2)
|
||||||
|
.toSet();
|
||||||
|
if (existingTerminalIds.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (final terminalId in sortedSessions) {
|
for (final terminalId in sortedSessions) {
|
||||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
if (!existingTerminalIds.add(terminalId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addNewTerminal(peerId, terminalId: terminalId);
|
||||||
// A delay is required to ensure the UI has sufficient time to update
|
// A delay is required to ensure the UI has sufficient time to update
|
||||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||||
// may be called prematurely while the tab widget is still in the tab controller.
|
// may be called prematurely while the tab widget is still in the tab controller.
|
||||||
@@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _closeWindowFromConnection() async {
|
||||||
|
await _closeAllTabs();
|
||||||
|
await WindowController.fromWindowId(windowId()).close();
|
||||||
|
}
|
||||||
|
|
||||||
int windowId() {
|
int windowId() {
|
||||||
return widget.params["windowId"];
|
return widget.params["windowId"];
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ class DesktopTabController {
|
|||||||
/// index, key
|
/// index, key
|
||||||
Function(int, String)? onRemoved;
|
Function(int, String)? onRemoved;
|
||||||
Function(String)? onSelected;
|
Function(String)? onSelected;
|
||||||
|
Future<void> Function()? onCloseWindow;
|
||||||
|
|
||||||
DesktopTabController(
|
DesktopTabController(
|
||||||
{required this.tabType, this.onRemoved, this.onSelected});
|
{required this.tabType, this.onRemoved, this.onSelected});
|
||||||
@@ -592,13 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBar() {
|
Widget _buildBar() {
|
||||||
|
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// custom double tap handler
|
// custom double tap handler
|
||||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
onTap: !isIncomingHomePage && showMaximize
|
||||||
showMaximize
|
|
||||||
? () {
|
? () {
|
||||||
final current = DateTime.now().millisecondsSinceEpoch;
|
final current = DateTime.now().millisecondsSinceEpoch;
|
||||||
final elapsed = current - _lastClickTime;
|
final elapsed = current - _lastClickTime;
|
||||||
@@ -609,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
.then((value) => stateGlobal.setMaximized(value));
|
.then((value) => stateGlobal.setMaximized(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||||
onPanStart: (_) => startDragging(isMainWindow),
|
onPanStart: (_) => startDragging(isMainWindow),
|
||||||
onPanCancel: () {
|
onPanCancel: () {
|
||||||
// We want to disable dragging of the tab area in the tab bar.
|
// We want to disable dragging of the tab area in the tab bar.
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import 'common.dart';
|
|||||||
import 'consts.dart';
|
import 'consts.dart';
|
||||||
import 'mobile/pages/home_page.dart';
|
import 'mobile/pages/home_page.dart';
|
||||||
import 'mobile/pages/server_page.dart';
|
import 'mobile/pages/server_page.dart';
|
||||||
|
import 'mobile/widgets/deploy_dialog.dart';
|
||||||
import 'models/platform_model.dart';
|
import 'models/platform_model.dart';
|
||||||
|
import 'native/font_manager.dart'
|
||||||
|
if (dart.library.html) 'web/font_manager.dart';
|
||||||
|
|
||||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
||||||
@@ -36,10 +39,15 @@ import 'package:flutter_hbb/plugin/handlers.dart'
|
|||||||
int? kWindowId;
|
int? kWindowId;
|
||||||
WindowType? kWindowType;
|
WindowType? kWindowType;
|
||||||
late List<String> kBootArgs;
|
late List<String> kBootArgs;
|
||||||
|
bool _cjkFontLoaded = false;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
earlyAssert();
|
earlyAssert();
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
_cjkFontLoaded = await loadSystemCJKFonts();
|
||||||
|
if (_cjkFontLoaded) {
|
||||||
|
MyTheme.applyFontFallback([kLinuxCjkFontFamily]);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint("launch args: $args");
|
debugPrint("launch args: $args");
|
||||||
kBootArgs = List.from(args);
|
kBootArgs = List.from(args);
|
||||||
@@ -382,6 +390,7 @@ void _runApp(
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
child = _keepScaleBuilder(context, child);
|
child = _keepScaleBuilder(context, child);
|
||||||
child = botToastBuilder(context, child);
|
child = botToastBuilder(context, child);
|
||||||
|
if (_cjkFontLoaded) child = _mergeCjkFallback(context, child);
|
||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -532,6 +541,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
: (context, child) {
|
: (context, child) {
|
||||||
child = _keepScaleBuilder(context, child);
|
child = _keepScaleBuilder(context, child);
|
||||||
child = botToastBuilder(context, child);
|
child = botToastBuilder(context, child);
|
||||||
|
if (_cjkFontLoaded) child = _mergeCjkFallback(context, child);
|
||||||
if ((isDesktop && desktopType == DesktopType.main) ||
|
if ((isDesktop && desktopType == DesktopType.main) ||
|
||||||
isWebDesktop) {
|
isWebDesktop) {
|
||||||
child = keyListenerBuilder(context, child);
|
child = keyListenerBuilder(context, child);
|
||||||
@@ -575,6 +585,27 @@ _registerEventHandler() {
|
|||||||
NativeUiHandler.instance.onEvent(evt);
|
NativeUiHandler.instance.onEvent(evt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (isAndroid) {
|
||||||
|
platformFFI.registerEventHandler(
|
||||||
|
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
showDeployPromptDialog();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges the theme's fontFamilyFallback into [DefaultTextStyle] so that
|
||||||
|
/// bare [Text] widgets (and those with inherit:true styles) also pick up the
|
||||||
|
/// CJK fallback font loaded on ARM64 Linux.
|
||||||
|
Widget _mergeCjkFallback(BuildContext context, Widget? child) {
|
||||||
|
final result = child ?? Container();
|
||||||
|
final fallback = Theme.of(context).textTheme.bodyMedium?.fontFamilyFallback;
|
||||||
|
if (fallback == null || fallback.isEmpty) return result;
|
||||||
|
return DefaultTextStyle.merge(
|
||||||
|
style: TextStyle(fontFamilyFallback: fallback),
|
||||||
|
child: result,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
.contains(textToFind) ||
|
.contains(textToFind) ||
|
||||||
peer.alias.toLowerCase().contains(textToFind))
|
peer.alias.toLowerCase().contains(textToFind))
|
||||||
.toList();
|
.toList();
|
||||||
|
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||||
}
|
}
|
||||||
return _autocompleteOpts;
|
return _autocompleteOpts;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
|
|
||||||
//
|
|
||||||
// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors
|
|
||||||
// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch-
|
|
||||||
// friendly layout (ListTile rows instead of dense rows) and a hint banner
|
|
||||||
// that explains the recording flow only works with a physical keyboard.
|
|
||||||
//
|
|
||||||
// All actual logic — group definitions, JSON I/O, conflict-replace flow,
|
|
||||||
// recording-dialog round-trip, "Reset to defaults" — lives in the shared
|
|
||||||
// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only
|
|
||||||
// supplies the AppBar, the AppBar action, and the platform hint banner.
|
|
||||||
//
|
|
||||||
// Mobile keyboard detection limitation: Flutter has no reliable
|
|
||||||
// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards
|
|
||||||
// don't generate the `KeyDownEvent`s the recording dialog listens for, so
|
|
||||||
// in practice the dialog only does anything useful when the user actually
|
|
||||||
// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector).
|
|
||||||
// For V1 we don't try to detect attachment — we just surface the
|
|
||||||
// requirement as an in-page hint instead of disabling the Edit button.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../common.dart';
|
|
||||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
|
||||||
|
|
||||||
class MobileKeyboardShortcutsPage extends StatefulWidget {
|
|
||||||
const MobileKeyboardShortcutsPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MobileKeyboardShortcutsPage> createState() =>
|
|
||||||
_MobileKeyboardShortcutsPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MobileKeyboardShortcutsPageState
|
|
||||||
extends State<MobileKeyboardShortcutsPage> {
|
|
||||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(translate('Keyboard Shortcuts')),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
tooltip: translate('Reset to defaults'),
|
|
||||||
onPressed: () =>
|
|
||||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
|
||||||
icon: const Icon(Icons.restore),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: KeyboardShortcutsPageBody(
|
|
||||||
key: _bodyKey,
|
|
||||||
compact: false,
|
|
||||||
editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'),
|
|
||||||
headerBanner: _PhysicalKeyboardHintBanner(theme: theme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A muted info banner shown above the master toggle on mobile. We can't
|
|
||||||
/// reliably detect whether a physical keyboard is attached, so instead of
|
|
||||||
/// disabling the Edit button we surface the requirement up front.
|
|
||||||
class _PhysicalKeyboardHintBanner extends StatelessWidget {
|
|
||||||
final ThemeData theme;
|
|
||||||
const _PhysicalKeyboardHintBanner({required this.theme});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final color = theme.colorScheme.primary.withOpacity(0.08);
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.info_outline,
|
|
||||||
size: 18, color: theme.colorScheme.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
translate('shortcut-mobile-physical-keyboard-tip'),
|
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import '../../common/widgets/remote_input.dart';
|
|||||||
import '../../models/input_model.dart';
|
import '../../models/input_model.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../models/shortcut_model.dart';
|
|
||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
import '../widgets/dialog.dart';
|
import '../widgets/dialog.dart';
|
||||||
import '../widgets/custom_scale_widget.dart';
|
import '../widgets/custom_scale_widget.dart';
|
||||||
@@ -76,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
final FocusNode _physicalFocusNode = FocusNode();
|
final FocusNode _physicalFocusNode = FocusNode();
|
||||||
var _showEdit = false; // use soft keyboard
|
var _showEdit = false; // use soft keyboard
|
||||||
|
|
||||||
|
Worker? _waylandKeyboardGateWorker;
|
||||||
|
bool _waylandKeyboardGateInitialized = false;
|
||||||
|
|
||||||
InputModel get inputModel => gFFI.inputModel;
|
InputModel get inputModel => gFFI.inputModel;
|
||||||
SessionID get sessionId => gFFI.sessionId;
|
SessionID get sessionId => gFFI.sessionId;
|
||||||
|
|
||||||
@@ -120,25 +122,35 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
_disableAndroidSoftKeyboard(
|
_disableAndroidSoftKeyboard(
|
||||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||||
// Seed shortcut action callbacks once the session is ready, so that
|
|
||||||
// global keyboard shortcuts work even if the user never opens the
|
|
||||||
// toolbar menu. The returned list is intentionally discarded — the
|
|
||||||
// side effect of registering callbacks (inside toolbarControls) is
|
|
||||||
// what we want here.
|
|
||||||
if (mounted) {
|
|
||||||
toolbarControls(context, widget.id, gFFI);
|
|
||||||
// Mobile has no DesktopTabController, so tab-switch shortcuts will
|
|
||||||
// log a no-handler debug line if a user binds one.
|
|
||||||
registerSessionShortcutActions(gFFI);
|
|
||||||
registerToolbarShortcuts(context, widget.id, gFFI);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
|
inputModel.keyboardInputAllowed = true;
|
||||||
|
|
||||||
|
// Wayland sessions may use clipboard-based text input on the controlled side.
|
||||||
|
// Require explicit user confirmation before allowing soft-keyboard and
|
||||||
|
// clipboard-assisted text input. Physical keyboard events are not gated here.
|
||||||
|
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
|
||||||
|
if (isSet) {
|
||||||
|
_initWaylandKeyboardGateIfNeeded();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (gFFI.ffiModel.pi.isSet.value) {
|
||||||
|
_initWaylandKeyboardGateIfNeeded();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
// Close the session up-front. `gFFI.close()` below only calls `sessionClose`
|
||||||
|
// after several awaits (canvas save, image update, the `enable_soft_keyboard`
|
||||||
|
// platform call), so if the app is backgrounded while this page is disposing,
|
||||||
|
// dispose can be suspended before reaching it and the connection is never torn
|
||||||
|
// down. The reconnect then re-attaches to the leaked session and is stuck on
|
||||||
|
// "Connecting...". Dispatching it here makes teardown happen synchronously on
|
||||||
|
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
|
||||||
|
unawaited(bind.sessionClose(sessionId: sessionId));
|
||||||
// https://github.com/flutter/flutter/issues/64935
|
// https://github.com/flutter/flutter/issues/64935
|
||||||
super.dispose();
|
super.dispose();
|
||||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||||
@@ -148,6 +160,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||||
_mobileFocusNode.dispose();
|
_mobileFocusNode.dispose();
|
||||||
_physicalFocusNode.dispose();
|
_physicalFocusNode.dispose();
|
||||||
|
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||||
|
_waylandKeyboardGateWorker?.dispose();
|
||||||
|
inputModel.keyboardInputAllowed = true;
|
||||||
await gFFI.close();
|
await gFFI.close();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_iosKeyboardWorkaroundTimer?.cancel();
|
_iosKeyboardWorkaroundTimer?.cancel();
|
||||||
@@ -176,6 +191,40 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
gFFI.invokeMethod("try_sync_clipboard");
|
gFFI.invokeMethod("try_sync_clipboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldGateKeyboardForWayland() {
|
||||||
|
if (!(isAndroid || isIOS)) return false;
|
||||||
|
final pi = gFFI.ffiModel.pi;
|
||||||
|
return pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initWaylandKeyboardGateIfNeeded() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_waylandKeyboardGateInitialized) return;
|
||||||
|
if (!_shouldGateKeyboardForWayland()) return;
|
||||||
|
|
||||||
|
_waylandKeyboardGateInitialized = true;
|
||||||
|
|
||||||
|
final allowWaylandKeyboard =
|
||||||
|
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
|
||||||
|
if (!shouldShowWaylandKeyboardPrompt(
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
isWaylandPeer: _shouldGateKeyboardForWayland(),
|
||||||
|
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||||
|
)) {
|
||||||
|
inputModel.keyboardInputAllowed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputModel.keyboardInputAllowed = false;
|
||||||
|
|
||||||
|
// Ensure soft keyboard is not active before user confirms.
|
||||||
|
_showEdit = false;
|
||||||
|
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||||
|
_mobileFocusNode.unfocus();
|
||||||
|
_physicalFocusNode.requestFocus();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
// to-do: It should be better to use transparent color instead of the bgColor.
|
// to-do: It should be better to use transparent color instead of the bgColor.
|
||||||
// But for now, the transparent color will cause the canvas to be white.
|
// But for now, the transparent color will cause the canvas to be white.
|
||||||
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
||||||
@@ -307,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
content == '【】')) {
|
content == '【】')) {
|
||||||
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
|
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
|
||||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||||
openKeyboard();
|
_openKeyboardUnlocked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||||
@@ -319,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
// handle mobile virtual keyboard
|
// handle mobile virtual keyboard
|
||||||
void handleSoftKeyboardInput(String newValue) {
|
void handleSoftKeyboardInput(String newValue) {
|
||||||
|
if (!inputModel.keyboardInputAllowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isIOS) {
|
if (isIOS) {
|
||||||
_handleIOSSoftKeyboardInput(newValue);
|
_handleIOSSoftKeyboardInput(newValue);
|
||||||
} else {
|
} else {
|
||||||
@@ -327,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void inputChar(String char) {
|
void inputChar(String char) {
|
||||||
|
if (!inputModel.keyboardInputAllowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (char == '\n') {
|
if (char == '\n') {
|
||||||
char = 'VK_RETURN';
|
char = 'VK_RETURN';
|
||||||
} else if (char == ' ') {
|
} else if (char == ' ') {
|
||||||
@@ -336,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void openKeyboard() {
|
void openKeyboard() {
|
||||||
|
final allowWaylandKeyboard =
|
||||||
|
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
|
||||||
|
if (shouldShowWaylandKeyboardPrompt(
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
isWaylandPeer: _shouldGateKeyboardForWayland(),
|
||||||
|
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||||
|
)) {
|
||||||
|
inputModel.keyboardInputAllowed = false;
|
||||||
|
showWaylandKeyboardInputWarningDialog(
|
||||||
|
id: widget.id,
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
ffi: gFFI,
|
||||||
|
onEnable: () async {
|
||||||
|
_openKeyboardUnlocked();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_openKeyboardUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openKeyboardUnlocked() {
|
||||||
|
inputModel.keyboardInputAllowed = true;
|
||||||
gFFI.invokeMethod("enable_soft_keyboard", true);
|
gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||||
// destroy first, so that our _value trick can work
|
// destroy first, so that our _value trick can work
|
||||||
_value = initText;
|
_value = initText;
|
||||||
@@ -439,10 +517,12 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
color: MyTheme.canvasColor,
|
color: MyTheme.canvasColor,
|
||||||
child: RawTouchGestureDetectorRegion(
|
child: inputModel.isPhysicalMouse.value
|
||||||
child: getBodyForMobile(),
|
? getBodyForMobile()
|
||||||
ffi: gFFI,
|
: RawTouchGestureDetectorRegion(
|
||||||
),
|
child: getBodyForMobile(),
|
||||||
|
ffi: gFFI,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -1140,7 +1220,11 @@ void showOptions(
|
|||||||
if (image != null) {
|
if (image != null) {
|
||||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||||
}
|
}
|
||||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
final privacyModeState = PrivacyModeState.find(id);
|
||||||
|
if (pi.displays.length > 1 &&
|
||||||
|
pi.currentDisplay != kAllDisplayValue &&
|
||||||
|
(privacyModeState.isEmpty ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
|
||||||
final cur = pi.currentDisplay;
|
final cur = pi.currentDisplay;
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||||
@@ -1194,9 +1278,8 @@ void showOptions(
|
|||||||
await toolbarDisplayToggle(context, id, gFFI);
|
await toolbarDisplayToggle(context, id, gFFI);
|
||||||
|
|
||||||
List<TToggleMenu> privacyModeList = [];
|
List<TToggleMenu> privacyModeList = [];
|
||||||
// privacy mode
|
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||||
final privacyModeState = PrivacyModeState.find(id);
|
privacyModeState.isNotEmpty) {
|
||||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
|
||||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||||
if (privacyModeList.length == 1) {
|
if (privacyModeList.length == 1) {
|
||||||
displayToggles.add(privacyModeList[0]);
|
displayToggles.add(privacyModeList[0]);
|
||||||
|
|||||||
@@ -583,9 +583,16 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final serverModel = Provider.of<ServerModel>(context);
|
final serverModel = Provider.of<ServerModel>(context);
|
||||||
final hasAudioPermission = androidVersion >= 30;
|
final hasAudioPermission = androidVersion >= 30;
|
||||||
final hideStopService =
|
final hideStopService = isAndroid &&
|
||||||
isAndroid &&
|
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
final allowPermChangeInAcceptWindow = option2bool(
|
||||||
|
kOptionEnablePermChangeInAcceptWindow,
|
||||||
|
bind.mainGetBuildinOption(
|
||||||
|
key: kOptionEnablePermChangeInAcceptWindow,
|
||||||
|
));
|
||||||
|
final permissionChangeLocked = isAndroid &&
|
||||||
|
serverModel.clients.any((c) => !c.disconnected) &&
|
||||||
|
!allowPermChangeInAcceptWindow;
|
||||||
return PaddingCard(
|
return PaddingCard(
|
||||||
title: translate("Permissions"),
|
title: translate("Permissions"),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
@@ -608,13 +615,21 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||||||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||||
? () => showScamWarning(context, serverModel)
|
? () => showScamWarning(context, serverModel)
|
||||||
: serverModel.toggleService),
|
: serverModel.toggleService),
|
||||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
PermissionRow(
|
||||||
serverModel.toggleInput),
|
translate("Input Control"),
|
||||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
serverModel.inputOk,
|
||||||
serverModel.toggleFile),
|
serverModel.toggleInput,
|
||||||
|
),
|
||||||
|
PermissionRow(
|
||||||
|
translate("Transfer file"),
|
||||||
|
serverModel.fileOk,
|
||||||
|
serverModel.toggleFile,
|
||||||
|
enabled: !permissionChangeLocked,
|
||||||
|
),
|
||||||
hasAudioPermission
|
hasAudioPermission
|
||||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||||
serverModel.toggleAudio)
|
serverModel.toggleAudio,
|
||||||
|
enabled: !permissionChangeLocked)
|
||||||
: Row(children: [
|
: Row(children: [
|
||||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -623,19 +638,25 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||||||
style: const TextStyle(color: MyTheme.darkGray),
|
style: const TextStyle(color: MyTheme.darkGray),
|
||||||
))
|
))
|
||||||
]),
|
]),
|
||||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
PermissionRow(
|
||||||
serverModel.toggleClipboard),
|
translate("Enable clipboard"),
|
||||||
|
serverModel.clipboardOk,
|
||||||
|
serverModel.toggleClipboard,
|
||||||
|
enabled: !permissionChangeLocked,
|
||||||
|
),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PermissionRow extends StatelessWidget {
|
class PermissionRow extends StatelessWidget {
|
||||||
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
|
const PermissionRow(this.name, this.isOk, this.onPressed,
|
||||||
|
{Key? key, this.enabled = true})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final bool isOk;
|
final bool isOk;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -644,9 +665,11 @@ class PermissionRow extends StatelessWidget {
|
|||||||
contentPadding: EdgeInsets.all(0),
|
contentPadding: EdgeInsets.all(0),
|
||||||
title: Text(name),
|
title: Text(name),
|
||||||
value: isOk,
|
value: isOk,
|
||||||
onChanged: (bool value) {
|
onChanged: enabled
|
||||||
onPressed();
|
? (bool value) {
|
||||||
});
|
onPressed();
|
||||||
|
}
|
||||||
|
: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ import '../../common/widgets/login.dart';
|
|||||||
import '../../consts.dart';
|
import '../../consts.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../models/shortcut_model.dart';
|
import '../widgets/deploy_dialog.dart';
|
||||||
import '../widgets/dialog.dart';
|
import '../widgets/dialog.dart';
|
||||||
import 'home_page.dart';
|
import 'home_page.dart';
|
||||||
import 'mobile_keyboard_shortcuts_page.dart';
|
|
||||||
import 'scan_page.dart';
|
import 'scan_page.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget implements PageShape {
|
class SettingsPage extends StatefulWidget implements PageShape {
|
||||||
@@ -730,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
changeSocks5Proxy();
|
changeSocks5Proxy();
|
||||||
}),
|
}),
|
||||||
|
if (isAndroid && !bind.isOutgoingOnly())
|
||||||
|
SettingsTile(
|
||||||
|
title: Text(translate('Deploy')),
|
||||||
|
leading: Icon(Icons.cloud_upload),
|
||||||
|
onPressed: (context) {
|
||||||
|
showDeployDialog();
|
||||||
|
}),
|
||||||
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
|
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: Text(translate('Use WebSocket')),
|
title: Text(translate('Use WebSocket')),
|
||||||
@@ -821,24 +827,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
showThemeSettings(gFFI.dialogManager);
|
showThemeSettings(gFFI.dialogManager);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (!disabledSettings)
|
|
||||||
SettingsTile.navigation(
|
|
||||||
leading: Icon(Icons.keyboard_outlined),
|
|
||||||
title: Text(translate('Keyboard Shortcuts')),
|
|
||||||
description: Text(ShortcutModel.isEnabled()
|
|
||||||
? translate('On')
|
|
||||||
: translate('Off')),
|
|
||||||
trailing: Icon(Icons.arrow_forward_ios),
|
|
||||||
onPressed: (context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const MobileKeyboardShortcutsPage(),
|
|
||||||
)).then((_) {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!bind.isDisableAccount())
|
if (!bind.isDisableAccount())
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: Text(translate('note-at-conn-end-tip')),
|
title: Text(translate('note-at-conn-end-tip')),
|
||||||
|
|||||||
@@ -259,11 +259,13 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
color: MyTheme.canvasColor,
|
color: MyTheme.canvasColor,
|
||||||
child: RawTouchGestureDetectorRegion(
|
child: inputModel.isPhysicalMouse.value
|
||||||
child: getBodyForMobile(),
|
? getBodyForMobile()
|
||||||
ffi: gFFI,
|
: RawTouchGestureDetectorRegion(
|
||||||
isCamera: true,
|
child: getBodyForMobile(),
|
||||||
),
|
ffi: gFFI,
|
||||||
|
isCamera: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
114
flutter/lib/mobile/widgets/deploy_dialog.dart
Normal file
114
flutter/lib/mobile/widgets/deploy_dialog.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../common.dart';
|
||||||
|
import '../../models/platform_model.dart';
|
||||||
|
|
||||||
|
const _deployDialogTag = 'android-deploy-device';
|
||||||
|
|
||||||
|
void showDeployPromptDialog() {
|
||||||
|
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||||
|
gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||||
|
submit() => close(true);
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Deploy")),
|
||||||
|
content: Text(translate("server_requires_deployment_tip")),
|
||||||
|
actions: [
|
||||||
|
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||||
|
dialogButton("OK", onPressed: submit),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
}, tag: _deployDialogTag).then((deploy) {
|
||||||
|
if (deploy == true) {
|
||||||
|
showDeployDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void showDeployDialog() {
|
||||||
|
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||||
|
final tokenController = TextEditingController();
|
||||||
|
final idController = TextEditingController();
|
||||||
|
var errorText = "";
|
||||||
|
var isInProgress = false;
|
||||||
|
gFFI.dialogManager.show((setState, close, context) {
|
||||||
|
submit() async {
|
||||||
|
if (isInProgress) return;
|
||||||
|
final token = tokenController.text.trim();
|
||||||
|
if (token.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
errorText = translate("token is required!");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
errorText = "";
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
String res;
|
||||||
|
try {
|
||||||
|
res = await bind.mainDeployDevice(
|
||||||
|
token: token, id: idController.text.trim());
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
errorText = translate(e.toString());
|
||||||
|
isInProgress = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.isEmpty) {
|
||||||
|
close();
|
||||||
|
await gFFI.serverModel.fetchID();
|
||||||
|
showToast(translate("Successful"));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
errorText = translate(res.toString());
|
||||||
|
isInProgress = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Deploy")),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: tokenController,
|
||||||
|
decoration: InputDecoration(labelText: translate("API Token")),
|
||||||
|
obscureText: true,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofocus: true,
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
TextField(
|
||||||
|
controller: idController,
|
||||||
|
decoration:
|
||||||
|
InputDecoration(labelText: translate("Custom ID (optional)")),
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
if (errorText.isNotEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SelectableText(
|
||||||
|
errorText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
).paddingOnly(top: 8),
|
||||||
|
),
|
||||||
|
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton("Cancel",
|
||||||
|
onPressed: isInProgress ? null : close, isOutline: true),
|
||||||
|
dialogButton("OK", onPressed: isInProgress ? null : submit),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: isInProgress ? null : close,
|
||||||
|
);
|
||||||
|
}, tag: _deployDialogTag);
|
||||||
|
}
|
||||||
@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
|
|||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: serverSettingsTextFormField(
|
||||||
|
label: label,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
decoration: InputDecoration(
|
errorMsg: errorMsg,
|
||||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
contentPadding:
|
||||||
contentPadding:
|
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
showLabelText: false,
|
||||||
),
|
|
||||||
validator: validator,
|
validator: validator,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
).workaroundFreezeLinuxMint(),
|
).workaroundFreezeLinuxMint(),
|
||||||
@@ -132,12 +132,10 @@ void showServerSettingsWithValue(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextFormField(
|
return serverSettingsTextFormField(
|
||||||
|
label: label,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
decoration: InputDecoration(
|
errorMsg: errorMsg,
|
||||||
labelText: label,
|
|
||||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
|
||||||
),
|
|
||||||
validator: validator,
|
validator: validator,
|
||||||
).workaroundFreezeLinuxMint();
|
).workaroundFreezeLinuxMint();
|
||||||
}
|
}
|
||||||
@@ -209,6 +207,35 @@ void showServerSettingsWithValue(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextFormField serverSettingsTextFormField({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String errorMsg,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
bool autofocus = false,
|
||||||
|
bool showLabelText = true,
|
||||||
|
EdgeInsetsGeometry? contentPadding,
|
||||||
|
}) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: showLabelText ? label : null,
|
||||||
|
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||||
|
contentPadding: contentPadding,
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
autofocus: autofocus,
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
|
textCapitalization: TextCapitalization.none,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
smartDashesType: SmartDashesType.disabled,
|
||||||
|
smartQuotesType: SmartQuotesType.disabled,
|
||||||
|
enableIMEPersonalizedLearning: false,
|
||||||
|
spellCheckConfiguration: const SpellCheckConfiguration.disabled(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void setPrivacyModeDialog(
|
void setPrivacyModeDialog(
|
||||||
OverlayDialogManager dialogManager,
|
OverlayDialogManager dialogManager,
|
||||||
List<TToggleMenu> privacyModeList,
|
List<TToggleMenu> privacyModeList,
|
||||||
|
|||||||
@@ -391,14 +391,30 @@ class FileController {
|
|||||||
|
|
||||||
await Future.delayed(Duration(milliseconds: 100));
|
await Future.delayed(Duration(milliseconds: 100));
|
||||||
|
|
||||||
final dir = (await bind.sessionGetPeerOption(
|
final savedDir = (await bind.sessionGetPeerOption(
|
||||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||||
openDirectory(dir.isEmpty ? options.value.home : dir);
|
Future<bool> tryOpenReadyDirs() async {
|
||||||
|
final dirs = <String>{
|
||||||
|
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||||
|
if (savedDir.isNotEmpty) savedDir,
|
||||||
|
options.value.home,
|
||||||
|
};
|
||||||
|
for (final dir in dirs) {
|
||||||
|
if (await _openDirectoryPath(dir, isBack: true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = await tryOpenReadyDirs();
|
||||||
|
|
||||||
await Future.delayed(Duration(seconds: 1));
|
await Future.delayed(Duration(seconds: 1));
|
||||||
|
|
||||||
if (directory.value.path.isEmpty) {
|
if (!opened) {
|
||||||
openDirectory(options.value.home);
|
// The peer may become ready during the reconnect delay, so retry the
|
||||||
|
// same candidates instead of only retrying the default home directory.
|
||||||
|
await tryOpenReadyDirs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,19 +445,23 @@ class FileController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<bool> refresh() async {
|
||||||
await openDirectory(directory.value.path);
|
// "." can be both a refresh command and a real remote directory path.
|
||||||
|
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
|
||||||
|
return await _openDirectoryPath(directory.value.path, isBack: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openDirectory(String path, {bool isBack = false}) async {
|
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||||
if (path == ".") {
|
if (!isBack && path == ".") {
|
||||||
refresh();
|
return await refresh();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (path == "..") {
|
if (!isBack && path == "..") {
|
||||||
goToParentDirectory();
|
return await _goToParentDirectory(isBack: isBack);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return await _openDirectoryPath(path, isBack: isBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||||
if (!isBack) {
|
if (!isBack) {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
}
|
}
|
||||||
@@ -458,8 +478,10 @@ class FileController {
|
|||||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||||
fd.format(isWindows, sort: sortBy.value);
|
fd.format(isWindows, sort: sortBy.value);
|
||||||
directory.value = fd;
|
directory.value = fd;
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Failed to openDirectory $path: $e");
|
debugPrint("Failed to openDirectory $path: $e");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,19 +509,22 @@ class FileController {
|
|||||||
goBack();
|
goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openDirectory(path, isBack: true);
|
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void goToParentDirectory() {
|
void goToParentDirectory() {
|
||||||
|
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||||
final isWindows = options.value.isWindows;
|
final isWindows = options.value.isWindows;
|
||||||
final dirPath = directory.value.path;
|
final dirPath = directory.value.path;
|
||||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||||
// specially for C:\, D:\, goto '/'
|
// specially for C:\, D:\, goto '/'
|
||||||
if (parent == dirPath && isWindows) {
|
if (parent == dirPath && isWindows) {
|
||||||
openDirectory('/');
|
return await _openDirectoryPath('/', isBack: isBack);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
openDirectory(parent);
|
return await _openDirectoryPath(parent, isBack: isBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO deprecated this
|
// TODO deprecated this
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import 'package:get/get.dart';
|
|||||||
|
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../models/shortcut_model.dart';
|
|
||||||
import '../../models/state_model.dart';
|
import '../../models/state_model.dart';
|
||||||
import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart';
|
|
||||||
import 'input_modifier_utils.dart';
|
import 'input_modifier_utils.dart';
|
||||||
import 'relative_mouse_model.dart';
|
import 'relative_mouse_model.dart';
|
||||||
import '../common.dart';
|
import '../common.dart';
|
||||||
@@ -476,6 +474,10 @@ class InputModel {
|
|||||||
|
|
||||||
late final SessionID sessionId;
|
late final SessionID sessionId;
|
||||||
|
|
||||||
|
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
|
||||||
|
// It should not block physical keyboard events.
|
||||||
|
bool keyboardInputAllowed = true;
|
||||||
|
|
||||||
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
||||||
String get id => parent.target?.id ?? '';
|
String get id => parent.target?.id ?? '';
|
||||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||||
@@ -828,9 +830,6 @@ class InputModel {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (_tryDispatchWebFlutterShortcut(e)) {
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (isWindows || isLinux) {
|
if (isWindows || isLinux) {
|
||||||
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
|
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
|
||||||
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
|
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
|
||||||
@@ -925,53 +924,6 @@ class InputModel {
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _tryDispatchWebFlutterShortcut(KeyEvent e) {
|
|
||||||
if (!isWeb || !isInputSourceFlutter) return false;
|
|
||||||
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return false;
|
|
||||||
if (!ShortcutModel.isEnabled() || ShortcutModel.isPassThrough()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final keyName = logicalKeyName(e.logicalKey);
|
|
||||||
if (keyName == null) return false;
|
|
||||||
final mods = canonicalShortcutModsForSave(_webFlutterShortcutMods());
|
|
||||||
final action = _matchWebFlutterShortcut(keyName, mods);
|
|
||||||
if (action == null) return false;
|
|
||||||
if (e is KeyDownEvent) {
|
|
||||||
parent.target?.shortcutModel.onTriggered(action);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> _webFlutterShortcutMods() {
|
|
||||||
final keyboard = HardwareKeyboard.instance;
|
|
||||||
final mods = <String>{};
|
|
||||||
if (isMacOS || isIOS || isWebOnMacOs) {
|
|
||||||
if (keyboard.isMetaPressed) mods.add('primary');
|
|
||||||
if (keyboard.isControlPressed) mods.add('ctrl');
|
|
||||||
} else if (keyboard.isControlPressed) {
|
|
||||||
mods.add('primary');
|
|
||||||
}
|
|
||||||
if (keyboard.isAltPressed) mods.add('alt');
|
|
||||||
if (keyboard.isShiftPressed) mods.add('shift');
|
|
||||||
return mods;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _matchWebFlutterShortcut(String keyName, List<String> mods) {
|
|
||||||
for (final binding in ShortcutModel.readBindings()) {
|
|
||||||
final action = binding['action'];
|
|
||||||
final key = binding['key'];
|
|
||||||
final bindingMods =
|
|
||||||
canonicalShortcutModsForSave(shortcutModSetFrom(binding['mods']));
|
|
||||||
if (action is String &&
|
|
||||||
key == keyName &&
|
|
||||||
bindingMods.isNotEmpty &&
|
|
||||||
listEquals(bindingMods, mods)) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send Key Event
|
/// Send Key Event
|
||||||
void newKeyboardMode(
|
void newKeyboardMode(
|
||||||
String character, int usbHid, bool down, bool iosCapsLock) {
|
String character, int usbHid, bool down, bool iosCapsLock) {
|
||||||
@@ -1355,7 +1307,8 @@ class InputModel {
|
|||||||
}
|
}
|
||||||
if (isPhysicalMouse.value) {
|
if (isPhysicalMouse.value) {
|
||||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||||
edgeScroll: useEdgeScroll);
|
edgeScroll: useEdgeScroll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1596,7 +1549,8 @@ class InputModel {
|
|||||||
_relativeMouse
|
_relativeMouse
|
||||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||||
} else {
|
} else {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1618,7 +1572,8 @@ class InputModel {
|
|||||||
_relativeMouse
|
_relativeMouse
|
||||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||||
} else {
|
} else {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1640,12 +1595,40 @@ class InputModel {
|
|||||||
}
|
}
|
||||||
if (isPhysicalMouse.value) {
|
if (isPhysicalMouse.value) {
|
||||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||||
|
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||||
edgeScroll: useEdgeScroll);
|
edgeScroll: useEdgeScroll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert pointer coordinates into the visible remote canvas space.
|
||||||
|
///
|
||||||
|
/// On mobile, the remote page body is wrapped in `SafeArea`, but the pointer
|
||||||
|
/// listener that feeds these events sits outside that subtree. As a result,
|
||||||
|
/// `event.localPosition` still includes the top/left safe-area inset.
|
||||||
|
///
|
||||||
|
/// When the keyboard-visible path shows `KeyHelpTools`, the remote canvas is
|
||||||
|
/// also shifted downward by `CanvasModel.getAdjustY()`. The downstream mouse
|
||||||
|
/// mapping logic expects coordinates relative to the visible canvas area, so
|
||||||
|
/// we subtract both the mobile safe-area padding and the current canvas
|
||||||
|
/// adjustment before passing the position into mouse mapping.
|
||||||
|
///
|
||||||
|
/// Desktop and web desktop continue to use the global position directly
|
||||||
|
/// because their pointer mapping is window-based.
|
||||||
|
Offset _pointerPositionForRemoteCanvas(PointerEvent event) {
|
||||||
|
if (isDesktop || isWebDesktop) {
|
||||||
|
return event.position;
|
||||||
|
}
|
||||||
|
final mediaData = MediaQueryData.fromView(
|
||||||
|
WidgetsBinding.instance.platformDispatcher.views.first);
|
||||||
|
final adjustY = parent.target?.canvasModel.getAdjustY() ?? 0.0;
|
||||||
|
return Offset(
|
||||||
|
event.localPosition.dx - mediaData.padding.left,
|
||||||
|
event.localPosition.dy - mediaData.padding.top - adjustY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
|
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
|
||||||
List<RemoteWindowCoords> remoteWindowCoords) async {
|
List<RemoteWindowCoords> remoteWindowCoords) async {
|
||||||
final coords =
|
final coords =
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
|||||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||||
import 'package:flutter_hbb/models/printer_model.dart';
|
import 'package:flutter_hbb/models/printer_model.dart';
|
||||||
import 'package:flutter_hbb/models/server_model.dart';
|
import 'package:flutter_hbb/models/server_model.dart';
|
||||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
|
||||||
import 'package:flutter_hbb/models/user_model.dart';
|
import 'package:flutter_hbb/models/user_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||||
@@ -56,6 +55,8 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
|
|||||||
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
|
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
|
||||||
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
|
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
|
||||||
final _constSessionId = Uuid().v4obj();
|
final _constSessionId = Uuid().v4obj();
|
||||||
|
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
|
||||||
|
const _restartReconnectSilentDelaySecs = 5;
|
||||||
|
|
||||||
class CachedPeerData {
|
class CachedPeerData {
|
||||||
Map<String, dynamic> updatePrivacyMode = {};
|
Map<String, dynamic> updatePrivacyMode = {};
|
||||||
@@ -120,6 +121,7 @@ class FfiModel with ChangeNotifier {
|
|||||||
bool _touchMode = false;
|
bool _touchMode = false;
|
||||||
late VirtualMouseMode virtualMouseMode;
|
late VirtualMouseMode virtualMouseMode;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
Timer? _restartReconnectDelayTimer;
|
||||||
var _reconnects = 1;
|
var _reconnects = 1;
|
||||||
DateTime? _offlineReconnectStartTime;
|
DateTime? _offlineReconnectStartTime;
|
||||||
bool _viewOnly = false;
|
bool _viewOnly = false;
|
||||||
@@ -251,6 +253,7 @@ class FfiModel with ChangeNotifier {
|
|||||||
_inputBlocked = false;
|
_inputBlocked = false;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
|
resetRestartReconnectState();
|
||||||
clearPermissions();
|
clearPermissions();
|
||||||
waitForImageTimer?.cancel();
|
waitForImageTimer?.cancel();
|
||||||
timerScreenshot?.cancel();
|
timerScreenshot?.cancel();
|
||||||
@@ -342,6 +345,7 @@ class FfiModel with ChangeNotifier {
|
|||||||
} else if (name == 'connection_ready') {
|
} else if (name == 'connection_ready') {
|
||||||
setConnectionType(peerId, evt['secure'] == 'true',
|
setConnectionType(peerId, evt['secure'] == 'true',
|
||||||
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
||||||
|
resetRestartReconnectState();
|
||||||
} else if (name == 'switch_display') {
|
} else if (name == 'switch_display') {
|
||||||
// switch display is kept for backward compatibility
|
// switch display is kept for backward compatibility
|
||||||
handleSwitchDisplay(evt, sessionId, peerId);
|
handleSwitchDisplay(evt, sessionId, peerId);
|
||||||
@@ -477,11 +481,6 @@ class FfiModel with ChangeNotifier {
|
|||||||
} else if (name == 'exit_relative_mouse_mode') {
|
} else if (name == 'exit_relative_mouse_mode') {
|
||||||
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
||||||
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
||||||
} else if (name == kShortcutEventName) {
|
|
||||||
final action = evt['action'];
|
|
||||||
if (action is String) {
|
|
||||||
parent.target?.shortcutModel.onTriggered(action);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
debugPrint('Event is not handled in the fixed branch: $name');
|
debugPrint('Event is not handled in the fixed branch: $name');
|
||||||
}
|
}
|
||||||
@@ -928,8 +927,28 @@ class FfiModel with ChangeNotifier {
|
|||||||
enterUserLoginAndPasswordDialog(
|
enterUserLoginAndPasswordDialog(
|
||||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||||
} else if (type == 'restarting') {
|
} else if (type == 'restarting') {
|
||||||
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
|
// Treat restart messages as reconnect control events. Rust still sends
|
||||||
hasCancel: false);
|
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||||
|
// frame briefly, then shows the Connecting overlay.
|
||||||
|
if (_restartReconnectDelayTimer == null) {
|
||||||
|
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||||
|
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
|
||||||
|
clearPermissions();
|
||||||
|
// Retry once more after the silent window so restart reconnect attempts
|
||||||
|
// are spaced by the empirical short cadence instead of only updating UI.
|
||||||
|
_restartReconnectDelayTimer =
|
||||||
|
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
|
||||||
|
_restartReconnectDelayTimer = null;
|
||||||
|
if (parent.target?.closed == true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnect(dialogManager, sessionId, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type == 'restarting-show') {
|
||||||
|
_restartReconnectDelayTimer?.cancel();
|
||||||
|
_restartReconnectDelayTimer = null;
|
||||||
|
reconnect(dialogManager, sessionId, false);
|
||||||
} else if (type == 'wait-remote-accept-nook') {
|
} else if (type == 'wait-remote-accept-nook') {
|
||||||
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
||||||
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
||||||
@@ -955,6 +974,11 @@ class FfiModel with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetRestartReconnectState() {
|
||||||
|
_restartReconnectDelayTimer?.cancel();
|
||||||
|
_restartReconnectDelayTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-retry check for "Remote desktop is offline" error.
|
/// Auto-retry check for "Remote desktop is offline" error.
|
||||||
/// returns true to auto-retry, false otherwise.
|
/// returns true to auto-retry, false otherwise.
|
||||||
bool shouldAutoRetryOnOffline(
|
bool shouldAutoRetryOnOffline(
|
||||||
@@ -1380,6 +1404,7 @@ class FfiModel with ChangeNotifier {
|
|||||||
if (displays.isNotEmpty) {
|
if (displays.isNotEmpty) {
|
||||||
_reconnects = 1;
|
_reconnects = 1;
|
||||||
_offlineReconnectStartTime = null;
|
_offlineReconnectStartTime = null;
|
||||||
|
resetRestartReconnectState();
|
||||||
waitForFirstImage.value = true;
|
waitForFirstImage.value = true;
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
}
|
}
|
||||||
@@ -3629,7 +3654,6 @@ class FFI {
|
|||||||
late final ElevationModel elevationModel; // session
|
late final ElevationModel elevationModel; // session
|
||||||
late final CmFileModel cmFileModel; // cm
|
late final CmFileModel cmFileModel; // cm
|
||||||
late final TextureModel textureModel; //session
|
late final TextureModel textureModel; //session
|
||||||
late final ShortcutModel shortcutModel; // session
|
|
||||||
late final Peers recentPeersModel; // global
|
late final Peers recentPeersModel; // global
|
||||||
late final Peers favoritePeersModel; // global
|
late final Peers favoritePeersModel; // global
|
||||||
late final Peers lanPeersModel; // global
|
late final Peers lanPeersModel; // global
|
||||||
@@ -3659,7 +3683,6 @@ class FFI {
|
|||||||
elevationModel = ElevationModel(WeakReference(this));
|
elevationModel = ElevationModel(WeakReference(this));
|
||||||
cmFileModel = CmFileModel(WeakReference(this));
|
cmFileModel = CmFileModel(WeakReference(this));
|
||||||
textureModel = TextureModel(WeakReference(this));
|
textureModel = TextureModel(WeakReference(this));
|
||||||
shortcutModel = ShortcutModel(WeakReference(this));
|
|
||||||
recentPeersModel = Peers(
|
recentPeersModel = Peers(
|
||||||
name: PeersModelName.recent,
|
name: PeersModelName.recent,
|
||||||
loadEvent: LoadEvent.recent,
|
loadEvent: LoadEvent.recent,
|
||||||
@@ -3674,6 +3697,7 @@ class FFI {
|
|||||||
|
|
||||||
/// Mobile reuse FFI
|
/// Mobile reuse FFI
|
||||||
void mobileReset() {
|
void mobileReset() {
|
||||||
|
ffiModel.resetRestartReconnectState();
|
||||||
ffiModel.waitForFirstImage.value = true;
|
ffiModel.waitForFirstImage.value = true;
|
||||||
ffiModel.isRefreshing = false;
|
ffiModel.isRefreshing = false;
|
||||||
ffiModel.waitForImageDialogShow.value = true;
|
ffiModel.waitForImageDialogShow.value = true;
|
||||||
@@ -3887,6 +3911,7 @@ class FFI {
|
|||||||
}
|
}
|
||||||
if (ffiModel.waitForFirstImage.value == true) {
|
if (ffiModel.waitForFirstImage.value == true) {
|
||||||
ffiModel.waitForFirstImage.value = false;
|
ffiModel.waitForFirstImage.value = false;
|
||||||
|
ffiModel.resetRestartReconnectState();
|
||||||
dialogManager.dismissAll();
|
dialogManager.dismissAll();
|
||||||
await canvasModel.updateViewStyle();
|
await canvasModel.updateViewStyle();
|
||||||
await canvasModel.updateScrollStyle();
|
await canvasModel.updateScrollStyle();
|
||||||
@@ -3933,7 +3958,6 @@ class FFI {
|
|||||||
ffiModel.pi.currentDisplay);
|
ffiModel.pi.currentDisplay);
|
||||||
}
|
}
|
||||||
imageModel.callbacksOnFirstImage.clear();
|
imageModel.callbacksOnFirstImage.clear();
|
||||||
shortcutModel.clear();
|
|
||||||
await imageModel.update(null);
|
await imageModel.update(null);
|
||||||
cursorModel.clear();
|
cursorModel.clear();
|
||||||
ffiModel.clear();
|
ffiModel.clear();
|
||||||
|
|||||||
@@ -145,23 +145,26 @@ class Peer {
|
|||||||
note == other.note;
|
note == other.note;
|
||||||
}
|
}
|
||||||
|
|
||||||
Peer.copy(Peer other)
|
factory Peer.copy(Peer other) {
|
||||||
: this(
|
final peer = Peer(
|
||||||
id: other.id,
|
id: other.id,
|
||||||
hash: other.hash,
|
hash: other.hash,
|
||||||
password: other.password,
|
password: other.password,
|
||||||
username: other.username,
|
username: other.username,
|
||||||
hostname: other.hostname,
|
hostname: other.hostname,
|
||||||
platform: other.platform,
|
platform: other.platform,
|
||||||
alias: other.alias,
|
alias: other.alias,
|
||||||
tags: other.tags.toList(),
|
tags: other.tags.toList(),
|
||||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||||
rdpPort: other.rdpPort,
|
rdpPort: other.rdpPort,
|
||||||
rdpUsername: other.rdpUsername,
|
rdpUsername: other.rdpUsername,
|
||||||
loginName: other.loginName,
|
loginName: other.loginName,
|
||||||
device_group_name: other.device_group_name,
|
device_group_name: other.device_group_name,
|
||||||
note: other.note,
|
note: other.note,
|
||||||
sameServer: other.sameServer);
|
sameServer: other.sameServer);
|
||||||
|
peer.online = other.online;
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UpdateEvent { online, load }
|
enum UpdateEvent { online, load }
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleAudio() async {
|
toggleAudio() async {
|
||||||
if (clients.isNotEmpty) {
|
if (clients.any((c) => !c.disconnected)) {
|
||||||
await showClientsMayNotBeChangedAlert(parent.target);
|
await showClientsMayNotBeChangedAlert(parent.target);
|
||||||
}
|
}
|
||||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||||
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleFile() async {
|
toggleFile() async {
|
||||||
if (clients.isNotEmpty) {
|
if (clients.any((c) => !c.disconnected)) {
|
||||||
await showClientsMayNotBeChangedAlert(parent.target);
|
await showClientsMayNotBeChangedAlert(parent.target);
|
||||||
}
|
}
|
||||||
if (!_fileOk &&
|
if (!_fileOk &&
|
||||||
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleInput() async {
|
toggleInput() async {
|
||||||
if (clients.isNotEmpty) {
|
if (clients.any((c) => !c.disconnected)) {
|
||||||
await showClientsMayNotBeChangedAlert(parent.target);
|
await showClientsMayNotBeChangedAlert(parent.target);
|
||||||
}
|
}
|
||||||
if (_inputOk) {
|
if (_inputOk) {
|
||||||
@@ -549,10 +549,19 @@ class ServerModel with ChangeNotifier {
|
|||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
_clients.add(client);
|
_clients.add(client);
|
||||||
} else {
|
} else {
|
||||||
|
if (_clients[index].authorized) {
|
||||||
|
_clients[index].privacyMode = client.privacyMode;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_clients[index].authorized = true;
|
_clients[index].authorized = true;
|
||||||
|
_clients[index].privacyMode = client.privacyMode;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (_clients.any((c) => c.id == client.id)) {
|
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
_clients[index].privacyMode = client.privacyMode;
|
||||||
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_clients.add(client);
|
_clients.add(client);
|
||||||
@@ -818,6 +827,7 @@ class Client {
|
|||||||
bool restart = false;
|
bool restart = false;
|
||||||
bool recording = false;
|
bool recording = false;
|
||||||
bool blockInput = false;
|
bool blockInput = false;
|
||||||
|
bool privacyMode = false;
|
||||||
bool disconnected = false;
|
bool disconnected = false;
|
||||||
bool fromSwitch = false;
|
bool fromSwitch = false;
|
||||||
bool inVoiceCall = false;
|
bool inVoiceCall = false;
|
||||||
@@ -846,6 +856,7 @@ class Client {
|
|||||||
restart = json['restart'];
|
restart = json['restart'];
|
||||||
recording = json['recording'];
|
recording = json['recording'];
|
||||||
blockInput = json['block_input'];
|
blockInput = json['block_input'];
|
||||||
|
privacyMode = json['privacy_mode'] ?? privacyMode;
|
||||||
disconnected = json['disconnected'];
|
disconnected = json['disconnected'];
|
||||||
fromSwitch = json['from_switch'];
|
fromSwitch = json['from_switch'];
|
||||||
inVoiceCall = json['in_voice_call'];
|
inVoiceCall = json['in_voice_call'];
|
||||||
@@ -870,6 +881,7 @@ class Client {
|
|||||||
data['restart'] = restart;
|
data['restart'] = restart;
|
||||||
data['recording'] = recording;
|
data['recording'] = recording;
|
||||||
data['block_input'] = blockInput;
|
data['block_input'] = blockInput;
|
||||||
|
data['privacy_mode'] = privacyMode;
|
||||||
data['disconnected'] = disconnected;
|
data['disconnected'] = disconnected;
|
||||||
data['from_switch'] = fromSwitch;
|
data['from_switch'] = fromSwitch;
|
||||||
data['in_voice_call'] = inVoiceCall;
|
data['in_voice_call'] = inVoiceCall;
|
||||||
|
|||||||
@@ -1,560 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../common.dart';
|
|
||||||
import '../common/shared_state.dart' show PrivacyModeState;
|
|
||||||
import '../common/widgets/dialog.dart'
|
|
||||||
show desktopTryShowTabAuditDialogCloseCancelled;
|
|
||||||
import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart';
|
|
||||||
import '../consts.dart';
|
|
||||||
import '../desktop/widgets/remote_toolbar.dart' show ToolbarState;
|
|
||||||
import 'chat_model.dart' show VoiceCallStatus;
|
|
||||||
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
|
|
||||||
import '../models/model.dart';
|
|
||||||
import '../models/platform_model.dart';
|
|
||||||
import '../models/state_model.dart';
|
|
||||||
|
|
||||||
typedef ShortcutCallback = FutureOr<void> Function();
|
|
||||||
|
|
||||||
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
|
|
||||||
///
|
|
||||||
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
|
|
||||||
/// session events containing the matched `action` id. The session event
|
|
||||||
/// listener in [FfiModel.startEventListener] forwards those to this model
|
|
||||||
/// via [onTriggered], which runs whatever callback the toolbar / menu
|
|
||||||
/// builders previously registered for that action id.
|
|
||||||
class ShortcutModel {
|
|
||||||
static WeakReference<ShortcutModel>? _activeWebModel;
|
|
||||||
|
|
||||||
final WeakReference<FFI> parent;
|
|
||||||
final Map<String, ShortcutCallback> _callbacks = {};
|
|
||||||
|
|
||||||
ShortcutModel(this.parent);
|
|
||||||
|
|
||||||
/// Called by toolbar / menu builders to register what to do when the
|
|
||||||
/// matched shortcut fires.
|
|
||||||
void register(String actionId, ShortcutCallback callback) {
|
|
||||||
_callbacks[actionId] = callback;
|
|
||||||
_activeWebModel = WeakReference(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
void unregister(String actionId) {
|
|
||||||
_callbacks.remove(actionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear() {
|
|
||||||
_callbacks.clear();
|
|
||||||
if (identical(_activeWebModel?.target, this)) {
|
|
||||||
_activeWebModel = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void onWebTriggered(String actionId) {
|
|
||||||
final model = _activeWebModel?.target;
|
|
||||||
if (model != null) {
|
|
||||||
model.onTriggered(actionId);
|
|
||||||
} else {
|
|
||||||
debugPrint('shortcut_triggered: no active web shortcut model');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called by the session event listener when a `shortcut_triggered` event
|
|
||||||
/// arrives for this session.
|
|
||||||
void onTriggered(String actionId) {
|
|
||||||
final cb = _callbacks[actionId];
|
|
||||||
if (cb != null) {
|
|
||||||
unawaited(Future.sync(cb).catchError((e, st) {
|
|
||||||
debugPrint(
|
|
||||||
'shortcut_triggered: handler failed for $actionId: $e\n$st');
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
debugPrint('shortcut_triggered: no handler for $actionId');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the bindings JSON from LocalConfig.
|
|
||||||
static List<Map<String, dynamic>> readBindings() {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
if (raw.isEmpty) return [];
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
return shortcutBindingMapsFrom(parsed['bindings']);
|
|
||||||
} catch (_) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool isEnabled() {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
if (raw.isEmpty) return false;
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
return parsed['enabled'] == true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool isPassThrough() {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
if (raw.isEmpty) return false;
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
return parsed['pass_through'] == true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persistent companion to [isEnabled]: when on, the matchers return early
|
|
||||||
/// and every keystroke flows through to the remote (i.e. all bindings are
|
|
||||||
/// suspended). Stored in the same JSON blob so a single reload refreshes
|
|
||||||
/// both flags on every active matcher.
|
|
||||||
static Future<void> setPassThrough(bool v) async {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
Map<String, dynamic> json = {};
|
|
||||||
if (raw.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
json = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
json = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
json['pass_through'] = v;
|
|
||||||
await bind.mainSetLocalOption(
|
|
||||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
|
||||||
bind.mainReloadKeyboardShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip the master `enabled` flag and persist. On the first enable we seed
|
|
||||||
/// the default bindings so common combos work out of the box; otherwise we
|
|
||||||
/// preserve whatever the user already has. Refreshes the matcher cache so
|
|
||||||
/// the change takes effect immediately (Rust on native, JS via the bridge
|
|
||||||
/// on Web).
|
|
||||||
static Future<void> setEnabled(bool v) async {
|
|
||||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
||||||
Map<String, dynamic> json = {};
|
|
||||||
if (raw.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
json = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
json = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
json['enabled'] = v;
|
|
||||||
final list = shortcutBindingMapsFrom(json['bindings']);
|
|
||||||
if (v && list.isEmpty) {
|
|
||||||
json['bindings'] = filterDefaultBindingsForPlatform(
|
|
||||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
|
|
||||||
currentPlatformCapabilities(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
json['bindings'] = list;
|
|
||||||
}
|
|
||||||
await bind.mainSetLocalOption(
|
|
||||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
|
||||||
bind.mainReloadKeyboardShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single source of truth for the per-platform "is this shortcut applicable"
|
|
||||||
/// decisions. Both [setEnabled]'s default-seeding pass and the configuration
|
|
||||||
/// page's reset / list-rendering paths read from here, so the seed list and
|
|
||||||
/// the visible action list can never disagree on which platform a given
|
|
||||||
/// action belongs to.
|
|
||||||
///
|
|
||||||
/// Capability rationale:
|
|
||||||
/// * Fullscreen / Toolbar / Pin / View Mode: rendered wherever the
|
|
||||||
/// desktop layout applies (native desktop + Web). Native mobile is
|
|
||||||
/// permanently full-screen and doesn't have a desktop-style toolbar.
|
|
||||||
/// * Screenshot / Switch Sides: native desktop only. The Web bridge
|
|
||||||
/// throws UnimplementedError for `sessionTakeScreenshot`; mobile
|
|
||||||
/// toolbars don't surface either action.
|
|
||||||
/// * Tab navigation / Close Tab: only native desktop ships
|
|
||||||
/// `DesktopTabController`; Web's `RemotePage` is invoked without one.
|
|
||||||
/// * Recording: native desktop has the `_RecordMenu` widget +
|
|
||||||
/// `registerSessionShortcutActions` registration; native Android has
|
|
||||||
/// the `toolbarControls` entry; iOS short-circuits inside
|
|
||||||
/// `recordingModel.toggle()`; Web has no implementation.
|
|
||||||
/// * Reset Canvas: only the mobile toolbar builds the menu entry
|
|
||||||
/// (`isDefaultConn && isMobile` in `toolbarControls`).
|
|
||||||
/// * Input Source: Web only ships a single source so toggling is a
|
|
||||||
/// no-op; the toolbar menu hides itself when fewer than 2 sources are
|
|
||||||
/// advertised.
|
|
||||||
/// * Voice Call: Web bridge throws `UnimplementedError` for both
|
|
||||||
/// `sessionRequestVoiceCall` and `sessionCloseVoiceCall`.
|
|
||||||
static ShortcutPlatformCapabilities currentPlatformCapabilities() {
|
|
||||||
final desktopLayout = isDesktop || isWeb;
|
|
||||||
return ShortcutPlatformCapabilities(
|
|
||||||
includeFullscreenShortcut: desktopLayout,
|
|
||||||
includeScreenshotShortcut: isDesktop,
|
|
||||||
includeTabShortcuts: isDesktop,
|
|
||||||
includeToolbarShortcut: desktopLayout,
|
|
||||||
includeCloseTabShortcut: isDesktop,
|
|
||||||
includeSwitchSidesShortcut: isDesktop,
|
|
||||||
includeRecordingShortcut: !isWeb && !isIOS,
|
|
||||||
includeResetCanvasShortcut: isMobile,
|
|
||||||
includePinToolbarShortcut: desktopLayout,
|
|
||||||
includeViewModeShortcut: desktopLayout,
|
|
||||||
includeInputSourceShortcut: !isWeb,
|
|
||||||
includeVoiceCallShortcut: !isWeb,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register the default-bound shortcut actions that aren't already wired by
|
|
||||||
/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the
|
|
||||||
/// screenshot action). Called once per session from the desktop / mobile
|
|
||||||
/// remote page, after the toolbar registrations have run.
|
|
||||||
///
|
|
||||||
/// We register unconditionally — even when shortcuts are master-disabled —
|
|
||||||
/// because the matcher (Rust + JS) gates dispatch via the `enabled` flag,
|
|
||||||
/// so registered closures are functionally invisible until the user flips
|
|
||||||
/// shortcuts on. This keeps the wiring simple (no rebind callbacks across
|
|
||||||
/// sessions) and lets the user toggle shortcuts mid-session without
|
|
||||||
/// reconnecting.
|
|
||||||
///
|
|
||||||
/// [tabController] is the desktop window's tab controller; `null` on mobile /
|
|
||||||
/// web (where tab-switch shortcuts don't apply).
|
|
||||||
///
|
|
||||||
/// Each callback below is a no-op when the underlying state required to
|
|
||||||
/// service the action isn't available (e.g. only one display, only one tab).
|
|
||||||
void registerSessionShortcutActions(
|
|
||||||
FFI ffi, {
|
|
||||||
DesktopTabController? tabController,
|
|
||||||
ToolbarState? toolbarState,
|
|
||||||
}) {
|
|
||||||
final sessionId = ffi.sessionId;
|
|
||||||
|
|
||||||
// Note on disposal: every closure registered below captures `ffi` via
|
|
||||||
// closure environment, so the FFI object stays alive for the duration of
|
|
||||||
// the closure's execution — even across awaits, even if the session is
|
|
||||||
// closed mid-execution. We therefore don't add per-closure liveness
|
|
||||||
// guards: a `WeakReference<FFI>` check would never go null while the
|
|
||||||
// closure is on the call stack, and the underlying `bind.session*` /
|
|
||||||
// model setters tolerate stale-session calls (they no-op on torn-down
|
|
||||||
// sessions). ShortcutModel.onTriggered's existing entry guard
|
|
||||||
// (`_callbacks` lookup returning null after disposal) is the actual
|
|
||||||
// liveness gate.
|
|
||||||
|
|
||||||
// Toggle Fullscreen — available wherever the desktop layout renders
|
|
||||||
// (native desktop + every Web browser, since Web uses the desktop
|
|
||||||
// RemotePage). `stateGlobal.setFullscreen` handles native window vs.
|
|
||||||
// browser fullscreen. Native mobile is permanently full-screen, so the
|
|
||||||
// action is intentionally not registered there.
|
|
||||||
if (isDesktop || isWeb) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
|
|
||||||
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle Recording — desktop only here. Mobile already wires this through
|
|
||||||
// `toolbarControls` (which adds a recording entry on `!(isDesktop||isWeb)`),
|
|
||||||
// but the desktop toolbar uses a separate `_RecordMenu` widget that has no
|
|
||||||
// `actionId`. Without this explicit registration a desktop user could bind
|
|
||||||
// Toggle Recording in settings and the press would have no handler.
|
|
||||||
// `recordingModel.toggle()` itself short-circuits on iOS and on sessions
|
|
||||||
// without recording permission.
|
|
||||||
if (isDesktop) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleRecording, () {
|
|
||||||
ffi.recordingModel.toggle();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch Display Next / Prev — requires the peer to have at least 2
|
|
||||||
// displays. From the "All displays" merged view, Next jumps to display 0
|
|
||||||
// and Prev to the last display, so the user can always escape the merged
|
|
||||||
// view via these shortcuts.
|
|
||||||
void switchDisplayBy(int delta) {
|
|
||||||
final pi = ffi.ffiModel.pi;
|
|
||||||
final count = pi.displays.length;
|
|
||||||
if (count <= 1) return;
|
|
||||||
final current = pi.currentDisplay;
|
|
||||||
final int next;
|
|
||||||
if (current == kAllDisplayValue) {
|
|
||||||
next = delta > 0 ? 0 : count - 1;
|
|
||||||
} else {
|
|
||||||
next = ((current + delta) % count + count) % count;
|
|
||||||
}
|
|
||||||
bind.sessionSwitchDisplay(
|
|
||||||
isDesktop: isDesktop,
|
|
||||||
sessionId: sessionId,
|
|
||||||
value: Int32List.fromList([next]),
|
|
||||||
);
|
|
||||||
if (pi.isSupportMultiUiSession) {
|
|
||||||
// On multi-ui-session peers no switch-display message is sent back, so
|
|
||||||
// update the local state directly (mirrors `model.dart` handling).
|
|
||||||
ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () {
|
|
||||||
switchDisplayBy(1);
|
|
||||||
});
|
|
||||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () {
|
|
||||||
switchDisplayBy(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch to all-monitors view — mirrors the toolbar Monitor menu's
|
|
||||||
// "all monitors" button (only built when peer has >1 display). Not a
|
|
||||||
// toggle: the toolbar button just sets the merged view; another action
|
|
||||||
// (Switch to next/previous display, or another monitor button) takes
|
|
||||||
// you back to a single display.
|
|
||||||
//
|
|
||||||
// Use `openMonitorInTheSameTab(kAllDisplayValue, ...)` rather than calling
|
|
||||||
// `sessionSwitchDisplay` with `[kAllDisplayValue]` directly — the toolbar
|
|
||||||
// path treats `kAllDisplayValue` as a UI sentinel and expands it to the
|
|
||||||
// real display index list (`[0, 1, ...]`) before sending, then updates
|
|
||||||
// local FfiModel state. Sending `[-1]` raw produces a wire value the
|
|
||||||
// remote can't act on and skips the local state update, so the merged
|
|
||||||
// view never engages.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayAll, () {
|
|
||||||
final pi = ffi.ffiModel.pi;
|
|
||||||
if (pi.displays.length <= 1) return;
|
|
||||||
if (pi.currentDisplay == kAllDisplayValue) return;
|
|
||||||
openMonitorInTheSameTab(kAllDisplayValue, ffi, pi);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch tab next / prev — desktop only. The remote-screen tabs live in
|
|
||||||
// the window-scoped DesktopTabController, not on the FFI itself, so we
|
|
||||||
// need the controller from the page that owns this session. We
|
|
||||||
// intentionally don't expose positional ("Switch to tab N") shortcuts:
|
|
||||||
// counting tabs in a long list is impractical, and AnyDesk / Chrome
|
|
||||||
// standard practice is to favour next/prev navigation.
|
|
||||||
if (tabController != null) {
|
|
||||||
void switchTabBy(int delta) {
|
|
||||||
final tabs = tabController.state.value.tabs;
|
|
||||||
if (tabs.length <= 1) return;
|
|
||||||
final cur = tabs.indexWhere((t) => t.key == ffi.id);
|
|
||||||
if (cur < 0) return;
|
|
||||||
final next = (cur + delta + tabs.length) % tabs.length;
|
|
||||||
tabController.jumpTo(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
ffi.shortcutModel
|
|
||||||
.register(kShortcutActionSwitchTabNext, () => switchTabBy(1));
|
|
||||||
ffi.shortcutModel
|
|
||||||
.register(kShortcutActionSwitchTabPrev, () => switchTabBy(-1));
|
|
||||||
|
|
||||||
// Close Tab — desktop only. Mirrors the tab right-click "Close" entry,
|
|
||||||
// including the audit-log confirmation dialog so a shortcut close goes
|
|
||||||
// through the same path as a menu close.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionCloseTab, () async {
|
|
||||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
|
||||||
id: ffi.id,
|
|
||||||
tabController: tabController,
|
|
||||||
)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tabController.closeBy(ffi.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle Toolbar — desktop only. ToolbarState is window/session-scoped,
|
|
||||||
// owned by the RemotePage that hosts this session.
|
|
||||||
if (toolbarState != null) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleToolbar, () {
|
|
||||||
toolbarState.switchHide(sessionId);
|
|
||||||
});
|
|
||||||
ffi.shortcutModel.register(kShortcutActionPinToolbar, () {
|
|
||||||
toolbarState.switchPin();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle Chat overlay (open/close the chat panel for this session).
|
|
||||||
// _ChatMenu is a standalone toolbar icon — not part of any toolbar
|
|
||||||
// helper that returns a TToggleMenu list — so its handler is wired
|
|
||||||
// here rather than picked up by helper auto-register.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleChat, () {
|
|
||||||
ffi.chatModel.toggleChatOverlay();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle Voice Call — start when idle, hang up when active. Mirrors the
|
|
||||||
// toolbar's `_VoiceCallMenu` state-driven button. Web bridge throws
|
|
||||||
// UnimplementedError on both sessionRequestVoiceCall and
|
|
||||||
// sessionCloseVoiceCall, so we don't register on web.
|
|
||||||
if (!isWeb) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleVoiceCall, () {
|
|
||||||
final status = ffi.chatModel.voiceCallStatus.value;
|
|
||||||
if (status == VoiceCallStatus.connected ||
|
|
||||||
status == VoiceCallStatus.waitingForResponse) {
|
|
||||||
bind.sessionCloseVoiceCall(sessionId: sessionId);
|
|
||||||
} else {
|
|
||||||
bind.sessionRequestVoiceCall(sessionId: sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inline _KeyboardMenu items + actions with no toolbar TToggleMenu/TRadioMenu ─
|
|
||||||
// The toolbar's TToggleMenu / TRadioMenu helpers (toolbarDisplayToggle,
|
|
||||||
// toolbarCursor, toolbarKeyboardToggles, toolbarCodec, toolbarPrivacyMode,
|
|
||||||
// toolbarViewStyle, toolbarImageQuality) auto-register their tagged entries
|
|
||||||
// from the bottom of each helper. The handlers below cover what those
|
|
||||||
// helpers DON'T own:
|
|
||||||
// * Show my cursor / Keyboard mode (Map/Translate/Legacy) / View Only
|
|
||||||
// (desktop) — built as widgets directly in `_KeyboardMenu`, not as
|
|
||||||
// TToggleMenu lists. (Mobile View Only IS in toolbarDisplayToggle and
|
|
||||||
// auto-registers; the desktop session-start handler below registers
|
|
||||||
// first and the helper's auto-register on mobile takes over after its
|
|
||||||
// unawaited future resolves.)
|
|
||||||
// * Plug out all virtual displays — built in `getVirtualDisplayMenuChildren`
|
|
||||||
// as a MenuButton, not a TToggleMenu.
|
|
||||||
// * Toggle Input Source — cycle action; the toolbar exposes per-source
|
|
||||||
// radios but no single "cycle to next source" entry.
|
|
||||||
|
|
||||||
// Show my cursor — toolbar (`_KeyboardMenu.showMyCursor`) pushes the new
|
|
||||||
// value into FfiModel.setShowMyCursor and auto-enables view-only when the
|
|
||||||
// toggle goes on, so the user can never control the remote with their own
|
|
||||||
// cursor visible.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleShowMyCursor, () async {
|
|
||||||
await bind.sessionToggleOption(
|
|
||||||
sessionId: sessionId, value: kOptionToggleShowMyCursor);
|
|
||||||
final showMyCursor = await bind.sessionGetToggleOption(
|
|
||||||
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
|
|
||||||
false;
|
|
||||||
ffi.ffiModel.setShowMyCursor(showMyCursor);
|
|
||||||
if (showMyCursor && !ffi.ffiModel.viewOnly) {
|
|
||||||
await bind.sessionToggleOption(
|
|
||||||
sessionId: sessionId, value: kOptionToggleViewOnly);
|
|
||||||
final viewOnly = await bind.sessionGetToggleOption(
|
|
||||||
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
|
|
||||||
false;
|
|
||||||
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard mode (Map / Translate / Legacy). Mirrors the radio buttons in
|
|
||||||
// `_KeyboardMenu.keyboardMode()` (built as RdoMenuButton, not TRadioMenu).
|
|
||||||
void registerKeyboardMode(String actionId, String mode) {
|
|
||||||
ffi.shortcutModel.register(actionId, () async {
|
|
||||||
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
|
|
||||||
await ffi.inputModel.updateKeyboardMode();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
registerKeyboardMode(kShortcutActionKeyboardModeMap, kKeyMapMode);
|
|
||||||
registerKeyboardMode(kShortcutActionKeyboardModeTranslate, kKeyTranslateMode);
|
|
||||||
registerKeyboardMode(kShortcutActionKeyboardModeLegacy, kKeyLegacyMode);
|
|
||||||
|
|
||||||
// Plug out all virtual displays (Windows + IDD only). Mirrors the toolbar's
|
|
||||||
// "Plug out all" button — present in both IDD modes (RustDesk + Amyuni),
|
|
||||||
// built as a MenuButton inside `getVirtualDisplayMenuChildren`.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionPlugOutAllVirtualDisplays, () {
|
|
||||||
bind.sessionToggleVirtualDisplay(
|
|
||||||
sessionId: sessionId,
|
|
||||||
index: kAllVirtualDisplay,
|
|
||||||
on: false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Privacy mode 1 / 2 — fallback handlers for the single-impl and null-impls
|
|
||||||
// branches of `toolbarPrivacyMode`. The multi-impl branch tags each entry
|
|
||||||
// with the matching actionId and `_registerToggleMenuShortcuts` overrides
|
|
||||||
// these closures with the toolbar's own onChanged. But when the peer only
|
|
||||||
// advertises a single impl (older Linux peers, certain platform configs)
|
|
||||||
// toolbarPrivacyMode returns a `getDefaultMenu` entry without an actionId,
|
|
||||||
// so the auto-register pass skips it — these fallbacks are what actually
|
|
||||||
// wire the shortcut in that case.
|
|
||||||
String? findPrivacyImpl(String nameKey) {
|
|
||||||
final impls = ffi.ffiModel.pi
|
|
||||||
.platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl]
|
|
||||||
as List<dynamic>?;
|
|
||||||
if (impls == null) return null;
|
|
||||||
for (final e in impls) {
|
|
||||||
if (e is List && e.length >= 2 && e[1] == nameKey) return e[0] as String;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match the multi-impl branch of `toolbarPrivacyMode`: turn this impl on iff
|
|
||||||
// the active impl isn't already this one. Comparing `.value == implKey`
|
|
||||||
// (rather than `.value.isEmpty`) means pressing the mode-1 shortcut while
|
|
||||||
// mode 2 is on correctly turns mode 1 ON, instead of misreading the
|
|
||||||
// "any-mode-active" state as "this-mode-active" and toggling OFF.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionPrivacyMode1, () {
|
|
||||||
final implKey = findPrivacyImpl('privacy_mode_impl_mag_tip');
|
|
||||||
if (implKey == null) return;
|
|
||||||
bind.sessionTogglePrivacyMode(
|
|
||||||
sessionId: sessionId,
|
|
||||||
implKey: implKey,
|
|
||||||
on: PrivacyModeState.find(ffi.id).value != implKey,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ffi.shortcutModel.register(kShortcutActionPrivacyMode2, () {
|
|
||||||
final implKey = findPrivacyImpl('privacy_mode_impl_virtual_display_tip');
|
|
||||||
if (implKey == null) return;
|
|
||||||
bind.sessionTogglePrivacyMode(
|
|
||||||
sessionId: sessionId,
|
|
||||||
implKey: implKey,
|
|
||||||
on: PrivacyModeState.find(ffi.id).value != implKey,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// View Only — desktop toolbar exposes this inline in `_KeyboardMenu.viewMode`
|
|
||||||
// (mobile is in toolbarDisplayToggle and goes through helper auto-register).
|
|
||||||
// Mirrors the desktop callback: toggle + sync FfiModel.viewOnly +
|
|
||||||
// FfiModel.showMyCursor (the toolbar keeps these in step).
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleViewOnly, () async {
|
|
||||||
await bind.sessionToggleOption(
|
|
||||||
sessionId: sessionId, value: kOptionToggleViewOnly);
|
|
||||||
final viewOnly = await bind.sessionGetToggleOption(
|
|
||||||
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
|
|
||||||
false;
|
|
||||||
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
|
|
||||||
final showMyCursor = await bind.sessionGetToggleOption(
|
|
||||||
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
|
|
||||||
false;
|
|
||||||
ffi.ffiModel.setShowMyCursor(showMyCursor);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle Reverse mouse wheel — read current 'Y'/'N' (falling back to user
|
|
||||||
// default), flip, write back.
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleReverseMouseWheel, () async {
|
|
||||||
var cur = bind.sessionGetReverseMouseWheelSync(sessionId: sessionId) ?? '';
|
|
||||||
if (cur == '') {
|
|
||||||
cur = bind.mainGetUserDefaultOption(key: kKeyReverseMouseWheel);
|
|
||||||
}
|
|
||||||
final next = cur == 'Y' ? 'N' : 'Y';
|
|
||||||
await bind.sessionSetReverseMouseWheel(sessionId: sessionId, value: next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle Relative mouse mode (gaming mode). Desktop only.
|
|
||||||
if (isDesktop && !isWeb) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleRelativeMouseMode, () {
|
|
||||||
ffi.inputModel.toggleRelativeMouseMode();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle Input Source — flips between the available keyboard-event capture
|
|
||||||
// backends (e.g. JS vs Flutter on desktop). Mirrors the radio menu in
|
|
||||||
// remote_toolbar.dart::inputSource(); when fewer than 2 sources are
|
|
||||||
// available the menu hides itself, so this handler is a no-op too.
|
|
||||||
// Useful for accessibility: screen-reader users sometimes need to swap
|
|
||||||
// sources to regain control of the local keyboard (discussion #1933).
|
|
||||||
// Web only ships a single source, so we don't register on web.
|
|
||||||
if (!isWeb) {
|
|
||||||
ffi.shortcutModel.register(kShortcutActionToggleInputSource, () async {
|
|
||||||
final raw = bind.mainSupportedInputSource();
|
|
||||||
if (raw.isEmpty) return;
|
|
||||||
final List<dynamic> list;
|
|
||||||
try {
|
|
||||||
list = jsonDecode(raw) as List<dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (list.length < 2) return;
|
|
||||||
final ids = list
|
|
||||||
.map((e) => (e is List && e.isNotEmpty) ? e[0] as String : '')
|
|
||||||
.where((s) => s.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
if (ids.length < 2) return;
|
|
||||||
final current = stateGlobal.getInputSource();
|
|
||||||
final idx = ids.indexOf(current);
|
|
||||||
final next = ids[(idx < 0 ? 0 : idx + 1) % ids.length];
|
|
||||||
await stateGlobal.setInputSource(sessionId, next);
|
|
||||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
|
||||||
await ffi.inputModel.updateKeyboardMode();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Buffer for output data received before terminal view has valid dimensions.
|
// Buffer for output data received before terminal view has valid dimensions.
|
||||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||||
final _pendingOutputChunks = <String>[];
|
final _pendingOutputChunks = <String>[];
|
||||||
|
final _pendingOutputSuppressFlags = <bool>[];
|
||||||
int _pendingOutputSize = 0;
|
int _pendingOutputSize = 0;
|
||||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||||
// View ready state: true when terminal has valid dimensions, safe to write
|
// View ready state: true when terminal has valid dimensions, safe to write
|
||||||
bool _terminalViewReady = false;
|
bool _terminalViewReady = false;
|
||||||
|
bool _markViewReadyScheduled = false;
|
||||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
bool _suppressTerminalOutput = false;
|
||||||
|
bool _suppressNextTerminalDataOutput = false;
|
||||||
|
|
||||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||||
|
|
||||||
Future<void> _handleInput(String data) async {
|
Future<void> _handleInput(String data) async {
|
||||||
// If we press the `Enter` button on Android,
|
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||||
// `data` can be '\r' or '\n' when using different keyboards.
|
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||||
// Desktop -> Desktop works fine.
|
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||||
|
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||||
|
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||||
|
// so embedded newlines in pasted content are preserved.
|
||||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
if (isMobileOrWebMobile && data == '\n') {
|
||||||
data = '\r';
|
data = '\r';
|
||||||
}
|
}
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
terminalController = TerminalController();
|
terminalController = TerminalController();
|
||||||
|
|
||||||
// Setup terminal callbacks
|
// Setup terminal callbacks
|
||||||
terminal.onOutput = _handleInput;
|
terminal.onOutput = (data) {
|
||||||
|
if (_suppressTerminalOutput) return;
|
||||||
|
_handleInput(data);
|
||||||
|
};
|
||||||
|
|
||||||
terminal.onResize = (w, h, pw, ph) async {
|
terminal.onResize = (w, h, pw, ph) async {
|
||||||
// Validate all dimensions before using them
|
// Validate all dimensions before using them
|
||||||
@@ -84,7 +92,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
||||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
_markViewReady();
|
_scheduleMarkViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier {
|
|||||||
void onReady() {
|
void onReady() {
|
||||||
parent.dialogManager.dismissAll();
|
parent.dialogManager.dismissAll();
|
||||||
|
|
||||||
// Fire and forget - don't block onReady
|
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||||
openTerminal().catchError((e) {
|
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||||
|
// the persistent session active again and resumes output streaming.
|
||||||
|
openTerminal(force: _terminalOpened).catchError((e) {
|
||||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openTerminal() async {
|
Future<void> openTerminal({bool force = false}) async {
|
||||||
if (_terminalOpened) return;
|
if (_terminalOpened && !force) return;
|
||||||
// Request the remote side to open a terminal with default shell
|
// Request the remote side to open a terminal with default shell
|
||||||
// The remote side will decide which shell to use based on its OS
|
// The remote side will decide which shell to use based on its OS
|
||||||
|
|
||||||
@@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (success) {
|
if (success) {
|
||||||
_terminalOpened = true;
|
_terminalOpened = true;
|
||||||
|
|
||||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
// On reconnect, the server may replay recent output. That replay can include
|
||||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||||
|
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||||
|
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||||
|
message == 'Reconnected to existing terminal with pending output';
|
||||||
|
|
||||||
// Fallback: if terminal view is not yet ready but already has valid
|
// Fallback: if terminal view is not yet ready but already has valid
|
||||||
// dimensions (e.g. layout completed before open response arrived),
|
// dimensions (e.g. layout completed before open response arrived),
|
||||||
@@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (!_terminalViewReady &&
|
if (!_terminalViewReady &&
|
||||||
terminal.viewWidth > 0 &&
|
terminal.viewWidth > 0 &&
|
||||||
terminal.viewHeight > 0) {
|
terminal.viewHeight > 0) {
|
||||||
_markViewReady();
|
_scheduleMarkViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any buffered input
|
// Process any buffered input
|
||||||
@@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||||
|
.whereType<int>()
|
||||||
|
.where((id) => !parent.terminalModels.containsKey(id))
|
||||||
|
.toList();
|
||||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||||
DesktopMultiWindow.invokeMethod(
|
DesktopMultiWindow.invokeMethod(
|
||||||
kWindowId!,
|
kWindowId!,
|
||||||
kWindowEventRestoreTerminalSessions,
|
kWindowEventRestoreTerminalSessions,
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
|
'peer_id': id,
|
||||||
'persistent_sessions': persistentSessions,
|
'persistent_sessions': persistentSessions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier {
|
|||||||
final data = evt['data'];
|
final data = evt['data'];
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||||
|
_suppressNextTerminalDataOutput = false;
|
||||||
try {
|
try {
|
||||||
String text = '';
|
String text = '';
|
||||||
if (data is String) {
|
if (data is String) {
|
||||||
@@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeToTerminal(text);
|
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||||
}
|
}
|
||||||
@@ -361,7 +380,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
/// Write text to terminal, buffering if the view is not yet ready.
|
/// Write text to terminal, buffering if the view is not yet ready.
|
||||||
/// All terminal output should go through this method to avoid NaN errors
|
/// All terminal output should go through this method to avoid NaN errors
|
||||||
/// from writing before the terminal view has valid layout dimensions.
|
/// from writing before the terminal view has valid layout dimensions.
|
||||||
void _writeToTerminal(String text) {
|
void _writeToTerminal(
|
||||||
|
String text, {
|
||||||
|
bool suppressTerminalOutput = false,
|
||||||
|
}) {
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
// If a single chunk exceeds the cap, keep only its tail.
|
// If a single chunk exceeds the cap, keep only its tail.
|
||||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||||
@@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier {
|
|||||||
_pendingOutputChunks
|
_pendingOutputChunks
|
||||||
..clear()
|
..clear()
|
||||||
..add(truncated);
|
..add(truncated);
|
||||||
|
_pendingOutputSuppressFlags
|
||||||
|
..clear()
|
||||||
|
..add(suppressTerminalOutput);
|
||||||
_pendingOutputSize = truncated.length;
|
_pendingOutputSize = truncated.length;
|
||||||
} else {
|
} else {
|
||||||
_pendingOutputChunks.add(text);
|
_pendingOutputChunks.add(text);
|
||||||
|
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||||
_pendingOutputSize += text.length;
|
_pendingOutputSize += text.length;
|
||||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||||
_pendingOutputChunks.length > 1) {
|
_pendingOutputChunks.length > 1) {
|
||||||
final removed = _pendingOutputChunks.removeAt(0);
|
final removed = _pendingOutputChunks.removeAt(0);
|
||||||
|
_pendingOutputSuppressFlags.removeAt(0);
|
||||||
_pendingOutputSize -= removed.length;
|
_pendingOutputSize -= removed.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminal.write(text);
|
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flushOutputBuffer() {
|
void _flushOutputBuffer() {
|
||||||
if (_pendingOutputChunks.isEmpty) return;
|
if (_pendingOutputChunks.isEmpty) return;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||||
for (final chunk in _pendingOutputChunks) {
|
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||||
terminal.write(chunk);
|
_writeTerminalChunk(
|
||||||
|
_pendingOutputChunks[i],
|
||||||
|
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSuppressFlags.clear();
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _writeTerminalChunk(
|
||||||
|
String text, {
|
||||||
|
required bool suppressTerminalOutput,
|
||||||
|
}) {
|
||||||
|
if (!suppressTerminalOutput) {
|
||||||
|
terminal.write(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final previous = _suppressTerminalOutput;
|
||||||
|
_suppressTerminalOutput = true;
|
||||||
|
try {
|
||||||
|
terminal.write(text);
|
||||||
|
} finally {
|
||||||
|
_suppressTerminalOutput = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark terminal view as ready and flush buffered output.
|
/// Mark terminal view as ready and flush buffered output.
|
||||||
|
void _scheduleMarkViewReady() {
|
||||||
|
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
||||||
|
_markViewReadyScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_markViewReadyScheduled = false;
|
||||||
|
if (_disposed || _terminalViewReady) return;
|
||||||
|
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
||||||
|
_markViewReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.ensureVisualUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
void _markViewReady() {
|
void _markViewReady() {
|
||||||
if (_terminalViewReady) return;
|
if (_terminalViewReady) return;
|
||||||
_terminalViewReady = true;
|
_terminalViewReady = true;
|
||||||
@@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Clear buffers to free memory
|
// Clear buffers to free memory
|
||||||
_inputBuffer.clear();
|
_inputBuffer.clear();
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSuppressFlags.clear();
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
|
_markViewReadyScheduled = false;
|
||||||
|
_suppressNextTerminalDataOutput = false;
|
||||||
// Terminal cleanup is handled server-side when service closes
|
// Terminal cleanup is handled server-side when service closes
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ class PlatformFFI {
|
|||||||
gFFI.dialogManager.dismissAll();
|
gFFI.dialogManager.dismissAll();
|
||||||
closeConnection();
|
closeConnection();
|
||||||
};
|
};
|
||||||
await _ffiBind.mainInit(appDir: '');
|
|
||||||
context.callMethod('init');
|
context.callMethod('init');
|
||||||
version = getByName('version');
|
version = getByName('version');
|
||||||
window.onContextMenu.listen((event) {
|
window.onContextMenu.listen((event) {
|
||||||
|
|||||||
109
flutter/lib/native/font_manager.dart
Normal file
109
flutter/lib/native/font_manager.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import 'dart:ffi' show Abi;
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Font family name registered with [FontLoader] when a system CJK font is
|
||||||
|
/// successfully loaded on ARM64 Linux.
|
||||||
|
const kLinuxCjkFontFamily = 'SystemCJK';
|
||||||
|
|
||||||
|
const _kFontSearchPaths = [
|
||||||
|
// Debian / Ubuntu (noto-fonts / fonts-noto-cjk)
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf',
|
||||||
|
// Fedora / RHEL / Rocky (google-noto-sans-cjk-fonts)
|
||||||
|
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc',
|
||||||
|
// Arch Linux (noto-fonts-cjk)
|
||||||
|
'/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/noto-cjk/NotoSansCJKsc-Regular.otf',
|
||||||
|
// Generic fallback paths
|
||||||
|
'/usr/share/fonts/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/noto/NotoSansCJKsc-Regular.otf',
|
||||||
|
// WenQuanYi — commonly pre-installed on CJK-locale systems
|
||||||
|
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||||
|
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
|
||||||
|
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc',
|
||||||
|
'/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Loads a system CJK font on ARM64 Linux into Flutter's font registry via
|
||||||
|
/// [FontLoader], working around the missing fontconfig support in the
|
||||||
|
/// flutter-elinux engine (https://github.com/flutter/flutter/issues/139293).
|
||||||
|
///
|
||||||
|
/// Returns true if a CJK font was successfully loaded; false otherwise.
|
||||||
|
/// On all other platforms this is a no-op and returns false immediately.
|
||||||
|
Future<bool> loadSystemCJKFonts() async {
|
||||||
|
if (Abi.current() != Abi.linuxArm64) return false;
|
||||||
|
|
||||||
|
final path = await _findCjkFontPath();
|
||||||
|
if (path == null) {
|
||||||
|
debugPrint('ARM64 Linux: no CJK font found; CJK text may not render');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final loader = FontLoader(kLinuxCjkFontFamily);
|
||||||
|
final bytes = await File(path).readAsBytes();
|
||||||
|
loader.addFont(Future.value(ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)));
|
||||||
|
await loader.load();
|
||||||
|
debugPrint('ARM64 Linux: loaded CJK font from $path');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ARM64 Linux: failed to load CJK font: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _findCjkFontPath() async {
|
||||||
|
// Query fc-list for each CJK script separately. Fonts present in all three
|
||||||
|
// sets (zh ∩ ja ∩ ko) are true pan-CJK fonts; prefer them so we don't
|
||||||
|
// accidentally pick a Chinese-only font that lacks Japanese kana or Korean
|
||||||
|
// hangul glyphs. fc-list is a fontconfig CLI tool available on most Linux
|
||||||
|
// systems independent of whether the Flutter engine was built with fontconfig.
|
||||||
|
final byLang = <String, Set<String>>{};
|
||||||
|
for (final lang in const ['zh', 'ja', 'ko']) {
|
||||||
|
final paths = <String>{};
|
||||||
|
try {
|
||||||
|
final r =
|
||||||
|
await Process.run('fc-list', [':lang=$lang', '--format=%{file}\n']);
|
||||||
|
if (r.exitCode == 0) {
|
||||||
|
for (final line in r.stdout.toString().split('\n')) {
|
||||||
|
final p = line.trim();
|
||||||
|
if (p.isNotEmpty && File(p).existsSync()) paths.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ARM64 Linux: fc-list failed for lang=$lang: $e');
|
||||||
|
}
|
||||||
|
byLang[lang] = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
final panCjk = byLang['zh']!
|
||||||
|
.intersection(byLang['ja']!)
|
||||||
|
.intersection(byLang['ko']!);
|
||||||
|
final anyCjk =
|
||||||
|
byLang.values.fold(<String>{}, (acc, s) => acc..addAll(s));
|
||||||
|
|
||||||
|
// Among candidates, prefer well-known pan-CJK font families.
|
||||||
|
String? pick(Iterable<String> pool) {
|
||||||
|
const preferred = ['notosanscjk', 'sourcehansans', 'sourcehanserif'];
|
||||||
|
for (final name in preferred) {
|
||||||
|
for (final p in pool) {
|
||||||
|
if (p.toLowerCase().contains(name)) return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pool.isNotEmpty ? pool.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final found = pick(panCjk) ?? pick(anyCjk);
|
||||||
|
if (found != null) return found;
|
||||||
|
|
||||||
|
for (final p in _kFontSearchPaths) {
|
||||||
|
if (File(p).existsSync()) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import 'package:uuid/uuid.dart';
|
|||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
|
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
|
||||||
|
|
||||||
final _privateConstructorUsedError = UnsupportedError(
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||||
@@ -931,21 +930,6 @@ class RustdeskImpl {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to
|
|
||||||
// re-read its bindings from LocalStorage. Mirrors the native call which
|
|
||||||
// refreshes the Rust matcher's in-memory cache.
|
|
||||||
void mainReloadKeyboardShortcuts({dynamic hint}) {
|
|
||||||
js.context.callMethod('reloadShortcuts', []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web has no Rust at runtime, so the defaults seed comes from the
|
|
||||||
// [kDefaultShortcutBindings] canonical in shortcut_constants.dart. Parity
|
|
||||||
// with Rust's `default_bindings()` is enforced by tests on both sides
|
|
||||||
// against `flutter/test/fixtures/default_keyboard_shortcuts.json`.
|
|
||||||
String mainGetDefaultKeyboardShortcuts({dynamic hint}) {
|
|
||||||
return jsonEncode(kDefaultShortcutBindings);
|
|
||||||
}
|
|
||||||
|
|
||||||
String mainGetInputSource({dynamic hint}) {
|
String mainGetInputSource({dynamic hint}) {
|
||||||
final inputSource =
|
final inputSource =
|
||||||
js.context.callMethod('getByName', ['option:local', 'input-source']);
|
js.context.callMethod('getByName', ['option:local', 'input-source']);
|
||||||
@@ -1192,16 +1176,6 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> mainInit({required String appDir, dynamic hint}) {
|
Future<void> mainInit({required String appDir, dynamic hint}) {
|
||||||
// JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/
|
|
||||||
// shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a
|
|
||||||
// binding fires; route it to the active session's ShortcutModel.
|
|
||||||
// Web uses a JS-side connection, so the event does not arrive through the
|
|
||||||
// native session event stream.
|
|
||||||
js.context['onShortcutTriggered'] = (dynamic action) {
|
|
||||||
if (action is String) {
|
|
||||||
ShortcutModel.onWebTriggered(action);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,7 +1729,7 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
return '[]';
|
||||||
}
|
}
|
||||||
|
|
||||||
String mainSupportedInputSource({dynamic hint}) {
|
String mainSupportedInputSource({dynamic hint}) {
|
||||||
@@ -2060,7 +2034,14 @@ class RustdeskImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
|
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
|
||||||
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
|
return js.context.callMethod(
|
||||||
|
'getByName', ['resolve_avatar_url', avatar])?.toString() ??
|
||||||
|
avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> mainDeployDevice(
|
||||||
|
{required String token, required String id, dynamic hint}) {
|
||||||
|
throw UnimplementedError("mainDeployDevice");
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {}
|
void dispose() {}
|
||||||
|
|||||||
8
flutter/lib/web/font_manager.dart
Normal file
8
flutter/lib/web/font_manager.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// Web stub for `native/font_manager.dart`.
|
||||||
|
///
|
||||||
|
/// The native implementation depends on `dart:io` (Process/File/Platform) to
|
||||||
|
/// load a system CJK font on ARM64 Linux, which cannot compile for the web
|
||||||
|
/// target. The web build has no such fontconfig limitation, so this is a no-op.
|
||||||
|
const kLinuxCjkFontFamily = 'SystemCJK';
|
||||||
|
|
||||||
|
Future<bool> loadSystemCJKFonts() async => false;
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo ndk --platform 21 --target armv7-linux-androideabi build --release --features flutter,hwcodec
|
cargo ndk --platform 21 --target armv7-linux-androideabi build --locked --release --features flutter,hwcodec
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo ndk --platform 21 --target aarch64-linux-android build --release --features flutter,hwcodec
|
cargo ndk --platform 21 --target aarch64-linux-android build --locked --release --features flutter,hwcodec
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo ndk --platform 21 --target x86_64-linux-android build --release --features flutter
|
cargo ndk --platform 21 --target x86_64-linux-android build --locked --release --features flutter
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||||
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||||
|
|
||||||
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
|
cargo ndk --platform 21 --target i686-linux-android build --locked --release --features flutter
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||||
version: 1.4.6+64
|
version: 1.4.8+66
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '^3.1.0'
|
sdk: '^3.1.0'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||||
flutter pub get
|
flutter pub get
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h
|
||||||
# call `flutter clean` if cargo build fails
|
# call `flutter clean` if cargo build fails
|
||||||
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
|
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
|
||||||
cargo build --features flutter
|
cargo build --locked --features flutter
|
||||||
flutter run $@
|
flutter run $@
|
||||||
|
|||||||
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:flutter_hbb/common/widgets/autocomplete.dart';
|
||||||
|
import 'package:flutter_hbb/models/peer_model.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Peer _peer({
|
||||||
|
required String id,
|
||||||
|
String alias = '',
|
||||||
|
String username = '',
|
||||||
|
String hostname = '',
|
||||||
|
bool online = false,
|
||||||
|
}) {
|
||||||
|
final peer = Peer(
|
||||||
|
id: id,
|
||||||
|
username: username,
|
||||||
|
hostname: hostname,
|
||||||
|
alias: alias,
|
||||||
|
platform: '',
|
||||||
|
tags: [],
|
||||||
|
hash: '',
|
||||||
|
password: '',
|
||||||
|
forceAlwaysRelay: false,
|
||||||
|
rdpPort: '',
|
||||||
|
rdpUsername: '',
|
||||||
|
loginName: '',
|
||||||
|
device_group_name: '',
|
||||||
|
note: '',
|
||||||
|
);
|
||||||
|
peer.online = online;
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('merged autocomplete peers keep address book metadata and online state',
|
||||||
|
() {
|
||||||
|
final peers = mergeAutocompletePeers(
|
||||||
|
addressBookPeers: [
|
||||||
|
_peer(id: '123456789', alias: 'Office PC', username: 'ab-user'),
|
||||||
|
],
|
||||||
|
lanPeers: [
|
||||||
|
_peer(id: '123456789', username: 'lan-user', online: true),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peers, hasLength(1));
|
||||||
|
expect(peers.single.id, '123456789');
|
||||||
|
expect(peers.single.alias, 'Office PC');
|
||||||
|
expect(peers.single.username, 'ab-user');
|
||||||
|
expect(peers.single.online, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('peer copies preserve online state', () {
|
||||||
|
final peer = _peer(id: '987654321', online: true);
|
||||||
|
|
||||||
|
expect(Peer.copy(peer).online, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online callbacks update autocomplete-only peers', () {
|
||||||
|
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
|
||||||
|
|
||||||
|
final changed = updateAutocompletePeerOnlineStates(
|
||||||
|
peers,
|
||||||
|
onlines: {'112233445'},
|
||||||
|
offlines: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changed, isTrue);
|
||||||
|
expect(peers.single.online, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online query ids are deduplicated and limited', () {
|
||||||
|
final peers = List.generate(
|
||||||
|
25,
|
||||||
|
(index) => _peer(id: index.toString()),
|
||||||
|
)..insert(1, _peer(id: '0'));
|
||||||
|
|
||||||
|
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
|
||||||
|
|
||||||
|
expect(ids, hasLength(20));
|
||||||
|
expect(ids.first, '0');
|
||||||
|
expect(ids.where((id) => id == '0'), hasLength(1));
|
||||||
|
expect(ids.last, '19');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty online query ids cancel pending debounce', () async {
|
||||||
|
final queriedIds = <List<String>>[];
|
||||||
|
final loader = AllPeersLoader(
|
||||||
|
queryOnlines: (ids) async {
|
||||||
|
queriedIds.add(ids);
|
||||||
|
},
|
||||||
|
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
loader.queryOnlines([_peer(id: '123456789')]);
|
||||||
|
loader.queryOnlines([]);
|
||||||
|
await Future.delayed(Duration(milliseconds: 2));
|
||||||
|
|
||||||
|
expect(queriedIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed online query enqueue does not suppress retry', () async {
|
||||||
|
var queryCount = 0;
|
||||||
|
final loader = AllPeersLoader(
|
||||||
|
queryOnlines: (ids) {
|
||||||
|
queryCount += 1;
|
||||||
|
return Future<void>.error(Exception('queue full'));
|
||||||
|
},
|
||||||
|
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
loader.queryOnlines([_peer(id: '123456789')]);
|
||||||
|
await Future.delayed(Duration(milliseconds: 2));
|
||||||
|
|
||||||
|
loader.queryOnlines([_peer(id: '123456789')]);
|
||||||
|
await Future.delayed(Duration(milliseconds: 2));
|
||||||
|
|
||||||
|
expect(queryCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online callback updates currently displayed options', () async {
|
||||||
|
final loader = AllPeersLoader(
|
||||||
|
queryOnlines: (ids) async {},
|
||||||
|
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||||
|
);
|
||||||
|
final displayedOptions = [_peer(id: '123456789')];
|
||||||
|
|
||||||
|
loader.queryOnlines(displayedOptions);
|
||||||
|
loader.updateOnlineStateForTesting({
|
||||||
|
'onlines': '123456789',
|
||||||
|
'offlines': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(displayedOptions.single.online, isTrue);
|
||||||
|
await Future.delayed(Duration(milliseconds: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cached online callback state is reapplied after peers merge', () {
|
||||||
|
final loader = AllPeersLoader();
|
||||||
|
loader.updateOnlineStateForTesting({
|
||||||
|
'onlines': '123456789',
|
||||||
|
'offlines': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final mergedPeers = [_peer(id: '123456789')];
|
||||||
|
loader.applyLastOnlineStateForTesting(mergedPeers);
|
||||||
|
|
||||||
|
expect(mergedPeers.single.online, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
flutter/test/cm_demo.dart
Normal file
63
flutter/test/cm_demo.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
|
import 'package:flutter_hbb/main.dart';
|
||||||
|
import 'package:flutter_hbb/models/server_model.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
final testClients = [
|
||||||
|
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||||
|
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||||
|
Client(2, false, false, false, "UserC", "331123123", true, false, false),
|
||||||
|
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false,
|
||||||
|
false)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// flutter run -d {platform} -t test/cm_demo.dart to test cm
|
||||||
|
void main() async {
|
||||||
|
isTest = true;
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await windowManager.ensureInitialized();
|
||||||
|
await windowManager.setSize(const Size(400, 600));
|
||||||
|
await windowManager.setAlignment(Alignment.topRight);
|
||||||
|
await initEnv(kAppTypeMain);
|
||||||
|
for (var client in testClients) {
|
||||||
|
gFFI.serverModel.clients.add(client);
|
||||||
|
gFFI.serverModel.tabController.add(TabInfo(
|
||||||
|
key: client.id.toString(),
|
||||||
|
label: client.name,
|
||||||
|
closable: false,
|
||||||
|
page: buildConnectionCard(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
runApp(GetMaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: MyTheme.lightTheme,
|
||||||
|
darkTheme: MyTheme.darkTheme,
|
||||||
|
themeMode: MyTheme.currentThemeMode(),
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: supportedLocales,
|
||||||
|
home: const DesktopServerPage()));
|
||||||
|
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||||
|
size: kConnectionManagerWindowSizeClosedChat);
|
||||||
|
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
|
await windowManager.show();
|
||||||
|
// ensure initial window size to be changed
|
||||||
|
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
||||||
|
await Future.wait([
|
||||||
|
windowManager.setAlignment(Alignment.topRight),
|
||||||
|
windowManager.focus(),
|
||||||
|
windowManager.setOpacity(1)
|
||||||
|
]);
|
||||||
|
// ensure
|
||||||
|
windowManager.setAlignment(Alignment.topRight);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,62 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
|
||||||
import 'package:flutter_hbb/consts.dart';
|
|
||||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
|
||||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
|
||||||
import 'package:flutter_hbb/main.dart';
|
|
||||||
import 'package:flutter_hbb/models/server_model.dart';
|
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
final testClients = [
|
import 'cm_demo.dart' as cm_demo;
|
||||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
|
||||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
|
||||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
|
||||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
|
||||||
];
|
|
||||||
|
|
||||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
void main() {
|
||||||
void main(List<String> args) async {
|
test('connection manager demo clients match the current Client API', () {
|
||||||
isTest = true;
|
expect(cm_demo.testClients, hasLength(4));
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
expect(cm_demo.testClients.map((client) => client.name), [
|
||||||
await windowManager.ensureInitialized();
|
'UserAAAAAA',
|
||||||
await windowManager.setSize(const Size(400, 600));
|
'UserBBBBB',
|
||||||
await windowManager.setAlignment(Alignment.topRight);
|
'UserC',
|
||||||
await initEnv(kAppTypeMain);
|
'UserDDDDDDDDDDDd',
|
||||||
for (var client in testClients) {
|
|
||||||
gFFI.serverModel.clients.add(client);
|
|
||||||
gFFI.serverModel.tabController.add(TabInfo(
|
|
||||||
key: client.id.toString(),
|
|
||||||
label: client.name,
|
|
||||||
closable: false,
|
|
||||||
page: buildConnectionCard(client)));
|
|
||||||
}
|
|
||||||
|
|
||||||
runApp(GetMaterialApp(
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: MyTheme.lightTheme,
|
|
||||||
darkTheme: MyTheme.darkTheme,
|
|
||||||
themeMode: MyTheme.currentThemeMode(),
|
|
||||||
localizationsDelegates: const [
|
|
||||||
GlobalMaterialLocalizations.delegate,
|
|
||||||
GlobalWidgetsLocalizations.delegate,
|
|
||||||
GlobalCupertinoLocalizations.delegate,
|
|
||||||
],
|
|
||||||
supportedLocales: supportedLocales,
|
|
||||||
home: const DesktopServerPage()));
|
|
||||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
|
||||||
size: kConnectionManagerWindowSizeClosedChat);
|
|
||||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
|
||||||
await windowManager.show();
|
|
||||||
// ensure initial window size to be changed
|
|
||||||
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
|
||||||
await Future.wait([
|
|
||||||
windowManager.setAlignment(Alignment.topRight),
|
|
||||||
windowManager.focus(),
|
|
||||||
windowManager.setOpacity(1)
|
|
||||||
]);
|
]);
|
||||||
// ensure
|
expect(
|
||||||
windowManager.setAlignment(Alignment.topRight);
|
cm_demo.testClients.every(
|
||||||
|
(client) => client.keyboard && !client.clipboard && !client.audio),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
[
|
|
||||||
{"action": "send_ctrl_alt_del", "mods": ["primary", "alt", "shift"], "key": "delete"},
|
|
||||||
{"action": "toggle_fullscreen", "mods": ["primary", "alt", "shift"], "key": "enter"},
|
|
||||||
{"action": "switch_display_next", "mods": ["primary", "alt", "shift"], "key": "arrow_right"},
|
|
||||||
{"action": "switch_display_prev", "mods": ["primary", "alt", "shift"], "key": "arrow_left"},
|
|
||||||
{"action": "screenshot", "mods": ["primary", "alt", "shift"], "key": "p"},
|
|
||||||
{"action": "toggle_show_remote_cursor", "mods": ["primary", "alt", "shift"], "key": "m"},
|
|
||||||
{"action": "toggle_mute", "mods": ["primary", "alt", "shift"], "key": "s"},
|
|
||||||
{"action": "toggle_block_input", "mods": ["primary", "alt", "shift"], "key": "i"},
|
|
||||||
{"action": "toggle_chat", "mods": ["primary", "alt", "shift"], "key": "c"}
|
|
||||||
]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[
|
|
||||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
|
||||||
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
|
||||||
"digit0", "digit1", "digit2", "digit3", "digit4",
|
|
||||||
"digit5", "digit6", "digit7", "digit8", "digit9",
|
|
||||||
"f1", "f2", "f3", "f4", "f5", "f6",
|
|
||||||
"f7", "f8", "f9", "f10", "f11", "f12",
|
|
||||||
"delete", "backspace", "tab", "space", "enter",
|
|
||||||
"arrow_left", "arrow_right", "arrow_up", "arrow_down",
|
|
||||||
"home", "end", "page_up", "page_down", "insert"
|
|
||||||
]
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_actions.dart';
|
|
||||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart';
|
|
||||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_utils.dart';
|
|
||||||
|
|
||||||
ShortcutPlatformCapabilities capabilities({
|
|
||||||
bool includeFullscreenShortcut = true,
|
|
||||||
bool includeScreenshotShortcut = true,
|
|
||||||
bool includeTabShortcuts = true,
|
|
||||||
bool includeToolbarShortcut = true,
|
|
||||||
bool includeCloseTabShortcut = true,
|
|
||||||
bool includeSwitchSidesShortcut = true,
|
|
||||||
bool includeRecordingShortcut = true,
|
|
||||||
bool includeResetCanvasShortcut = true,
|
|
||||||
bool includePinToolbarShortcut = true,
|
|
||||||
bool includeViewModeShortcut = true,
|
|
||||||
bool includeInputSourceShortcut = true,
|
|
||||||
bool includeVoiceCallShortcut = true,
|
|
||||||
}) {
|
|
||||||
return ShortcutPlatformCapabilities(
|
|
||||||
includeFullscreenShortcut: includeFullscreenShortcut,
|
|
||||||
includeScreenshotShortcut: includeScreenshotShortcut,
|
|
||||||
includeTabShortcuts: includeTabShortcuts,
|
|
||||||
includeToolbarShortcut: includeToolbarShortcut,
|
|
||||||
includeCloseTabShortcut: includeCloseTabShortcut,
|
|
||||||
includeSwitchSidesShortcut: includeSwitchSidesShortcut,
|
|
||||||
includeRecordingShortcut: includeRecordingShortcut,
|
|
||||||
includeResetCanvasShortcut: includeResetCanvasShortcut,
|
|
||||||
includePinToolbarShortcut: includePinToolbarShortcut,
|
|
||||||
includeViewModeShortcut: includeViewModeShortcut,
|
|
||||||
includeInputSourceShortcut: includeInputSourceShortcut,
|
|
||||||
includeVoiceCallShortcut: includeVoiceCallShortcut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('kDefaultShortcutBindings matches fixture', () {
|
|
||||||
// The fixture is the cross-language source of truth for default
|
|
||||||
// bindings. Rust has its own parity test against the same file
|
|
||||||
// (`default_bindings_match_fixture_json` in src/keyboard/shortcuts.rs),
|
|
||||||
// so a drift on either side breaks CI.
|
|
||||||
final fixturePath = 'test/fixtures/default_keyboard_shortcuts.json';
|
|
||||||
final fixture =
|
|
||||||
jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>;
|
|
||||||
expect(kDefaultShortcutBindings, equals(fixture),
|
|
||||||
reason: 'kDefaultShortcutBindings drifted from $fixturePath — update '
|
|
||||||
'shortcut_constants.dart, the fixture, and Rust default_bindings() '
|
|
||||||
'together');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save order preserves macOS control modifier', () {
|
|
||||||
expect(canonicalShortcutModsForSave({'ctrl'}), ['ctrl']);
|
|
||||||
expect(canonicalShortcutModsForSave({'shift', 'ctrl', 'primary', 'alt'}),
|
|
||||||
['primary', 'ctrl', 'alt', 'shift']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shortcutBindingMapsFrom ignores malformed bindings', () {
|
|
||||||
expect(shortcutBindingMapsFrom('not a list'), isEmpty);
|
|
||||||
|
|
||||||
final bindings = shortcutBindingMapsFrom([
|
|
||||||
{
|
|
||||||
'action': kShortcutActionScreenshot,
|
|
||||||
'mods': ['primary'],
|
|
||||||
'key': 'p',
|
|
||||||
},
|
|
||||||
'bad',
|
|
||||||
1,
|
|
||||||
{
|
|
||||||
'action': kShortcutActionToggleMute,
|
|
||||||
'mods': ['alt'],
|
|
||||||
'key': 's',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(bindings, hasLength(2));
|
|
||||||
expect(bindings.map((binding) => binding['action']), [
|
|
||||||
kShortcutActionScreenshot,
|
|
||||||
kShortcutActionToggleMute,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shortcutModSetFrom ignores malformed modifiers', () {
|
|
||||||
expect(shortcutModSetFrom('not a list'), isEmpty);
|
|
||||||
expect(shortcutModSetFrom(['primary', 1, 'alt', null, 'primary']), {
|
|
||||||
'primary',
|
|
||||||
'alt',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-desktop defaults exclude desktop-only and tab shortcuts', () {
|
|
||||||
final defaults = [
|
|
||||||
{
|
|
||||||
'action': kShortcutActionSendCtrlAltDel,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'delete',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'action': kShortcutActionToggleFullscreen,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'enter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'action': kShortcutActionSwitchDisplayNext,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'arrow_right',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'action': kShortcutActionScreenshot,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'p',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'action': kShortcutActionSwitchTabNext,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'right_bracket',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'action': kShortcutActionToggleRelativeMouseMode,
|
|
||||||
'mods': ['primary', 'alt', 'shift'],
|
|
||||||
'key': 'g',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
final filtered = filterDefaultBindingsForPlatform(
|
|
||||||
defaults,
|
|
||||||
capabilities(
|
|
||||||
includeFullscreenShortcut: false,
|
|
||||||
includeScreenshotShortcut: false,
|
|
||||||
includeTabShortcuts: false,
|
|
||||||
includeToolbarShortcut: false,
|
|
||||||
includeCloseTabShortcut: false,
|
|
||||||
includeSwitchSidesShortcut: false,
|
|
||||||
includeRecordingShortcut: false,
|
|
||||||
includeResetCanvasShortcut: false,
|
|
||||||
includePinToolbarShortcut: false,
|
|
||||||
includeViewModeShortcut: false,
|
|
||||||
includeInputSourceShortcut: false,
|
|
||||||
includeVoiceCallShortcut: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(filtered.map((binding) => binding['action']), [
|
|
||||||
kShortcutActionSendCtrlAltDel,
|
|
||||||
kShortcutActionSwitchDisplayNext,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Set<String> idSet(Iterable<KeyboardShortcutActionGroup> groups) =>
|
|
||||||
{for (final e in allActionEntries(groups)) e.id};
|
|
||||||
|
|
||||||
/// Convenience: extract the children of the named group as a flat list of
|
|
||||||
/// human-readable tokens. Subgroups appear as `'group:<title>'` followed
|
|
||||||
/// by their entries, so call sites can assert on full ordering (subgroups
|
|
||||||
/// interleaved with direct items) in one expectation.
|
|
||||||
List<String> childTokens(
|
|
||||||
List<KeyboardShortcutActionGroup> groups, String titleKey) {
|
|
||||||
final group = groups.firstWhere((g) => g.titleKey == titleKey);
|
|
||||||
final out = <String>[];
|
|
||||||
for (final child in group.children) {
|
|
||||||
switch (child) {
|
|
||||||
case KeyboardShortcutActionEntry():
|
|
||||||
out.add(child.id);
|
|
||||||
case KeyboardShortcutActionSubgroup():
|
|
||||||
out.add('group:${child.titleKey}');
|
|
||||||
for (final entry in child.entries) {
|
|
||||||
out.add(' ${entry.id}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('filterKeyboardShortcutActionGroupsForPlatform strips desktop-only', () {
|
|
||||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
|
||||||
capabilities(
|
|
||||||
includeFullscreenShortcut: false,
|
|
||||||
includeScreenshotShortcut: false,
|
|
||||||
includeTabShortcuts: false,
|
|
||||||
includeToolbarShortcut: false,
|
|
||||||
includeCloseTabShortcut: false,
|
|
||||||
includeSwitchSidesShortcut: false,
|
|
||||||
// Recording / Reset Canvas are intentionally still included here —
|
|
||||||
// they have non-desktop platforms (mobile Android / mobile both).
|
|
||||||
includeRecordingShortcut: true,
|
|
||||||
includeResetCanvasShortcut: true,
|
|
||||||
includePinToolbarShortcut: false,
|
|
||||||
includeViewModeShortcut: false,
|
|
||||||
includeInputSourceShortcut: false,
|
|
||||||
includeVoiceCallShortcut: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final ids = idSet(groups);
|
|
||||||
// Desktop-only actions are stripped.
|
|
||||||
expect(ids, isNot(contains(kShortcutActionToggleFullscreen)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionToggleRelativeMouseMode)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionScreenshot)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionToggleToolbar)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionCloseTab)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionSwitchSides)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionPinToolbar)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionViewModeOriginal)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionViewModeAdaptive)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionSwitchTabNext)));
|
|
||||||
expect(ids, isNot(contains(kShortcutActionSwitchTabPrev)));
|
|
||||||
// Cross-platform actions survive.
|
|
||||||
expect(ids, contains(kShortcutActionSendCtrlAltDel));
|
|
||||||
expect(ids, contains(kShortcutActionInsertLock));
|
|
||||||
expect(ids, contains(kShortcutActionRestartRemote));
|
|
||||||
expect(ids, contains(kShortcutActionSwitchDisplayNext));
|
|
||||||
expect(ids, contains(kShortcutActionToggleRecording));
|
|
||||||
expect(ids, contains(kShortcutActionResetCanvas));
|
|
||||||
expect(ids, contains(kShortcutActionToggleMute));
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'filterKeyboardShortcutActionGroupsForPlatform hides Toggle Recording on Web/iOS',
|
|
||||||
() {
|
|
||||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
|
||||||
capabilities(includeRecordingShortcut: false),
|
|
||||||
);
|
|
||||||
final ids = idSet(groups);
|
|
||||||
expect(ids, isNot(contains(kShortcutActionToggleRecording)));
|
|
||||||
// Other Session Control entries unaffected.
|
|
||||||
expect(ids, contains(kShortcutActionSendCtrlAltDel));
|
|
||||||
expect(ids, contains(kShortcutActionInsertLock));
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'filterKeyboardShortcutActionGroupsForPlatform keeps full set on desktop',
|
|
||||||
() {
|
|
||||||
final groups =
|
|
||||||
filterKeyboardShortcutActionGroupsForPlatform(capabilities());
|
|
||||||
expect(idSet(groups), equals(idSet(kKeyboardShortcutActionGroups)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shortcut action groups follow toolbar menu order', () {
|
|
||||||
final groups = kKeyboardShortcutActionGroups;
|
|
||||||
|
|
||||||
// Top-level groups in toolbar order.
|
|
||||||
expect(
|
|
||||||
groups.map((g) => g.titleKey).toList(),
|
|
||||||
['Monitor', 'Control Actions', 'Display', 'Keyboard', 'Chat', 'Other'],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Display: subgroups (View Mode → Image Quality → Codec → Virtual
|
|
||||||
// display) first, then direct items (cursor toggles + display toggles),
|
|
||||||
// then Privacy mode subgroup last — exactly matching `_DisplayMenu`.
|
|
||||||
expect(childTokens(groups, 'Display'), [
|
|
||||||
'group:View Mode',
|
|
||||||
' $kShortcutActionViewModeOriginal',
|
|
||||||
' $kShortcutActionViewModeAdaptive',
|
|
||||||
' $kShortcutActionViewModeCustom',
|
|
||||||
'group:Image Quality',
|
|
||||||
' $kShortcutActionImageQualityBest',
|
|
||||||
' $kShortcutActionImageQualityBalanced',
|
|
||||||
' $kShortcutActionImageQualityLow',
|
|
||||||
'group:Codec',
|
|
||||||
' $kShortcutActionCodecAuto',
|
|
||||||
' $kShortcutActionCodecVp8',
|
|
||||||
' $kShortcutActionCodecVp9',
|
|
||||||
' $kShortcutActionCodecAv1',
|
|
||||||
' $kShortcutActionCodecH264',
|
|
||||||
' $kShortcutActionCodecH265',
|
|
||||||
'group:Virtual display',
|
|
||||||
' $kShortcutActionPlugOutAllVirtualDisplays',
|
|
||||||
kShortcutActionToggleShowRemoteCursor,
|
|
||||||
kShortcutActionToggleFollowRemoteCursor,
|
|
||||||
kShortcutActionToggleFollowRemoteWindow,
|
|
||||||
kShortcutActionToggleZoomCursor,
|
|
||||||
kShortcutActionToggleQualityMonitor,
|
|
||||||
kShortcutActionToggleMute,
|
|
||||||
kShortcutActionToggleEnableFileCopyPaste,
|
|
||||||
kShortcutActionToggleDisableClipboard,
|
|
||||||
kShortcutActionToggleLockAfterSessionEnd,
|
|
||||||
kShortcutActionToggleTrueColor,
|
|
||||||
'group:Privacy mode',
|
|
||||||
' $kShortcutActionPrivacyMode1',
|
|
||||||
' $kShortcutActionPrivacyMode2',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Privacy mode is the last child under Display (matching the toolbar's
|
|
||||||
// submenu order — `_DisplayMenu` adds Privacy mode after the toggles).
|
|
||||||
final displayChildren =
|
|
||||||
groups.firstWhere((g) => g.titleKey == 'Display').children;
|
|
||||||
expect(displayChildren.last, isA<KeyboardShortcutActionSubgroup>());
|
|
||||||
expect(
|
|
||||||
(displayChildren.last as KeyboardShortcutActionSubgroup).titleKey,
|
|
||||||
'Privacy mode',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keyboard: Keyboard mode subgroup first, then direct items —
|
|
||||||
// matching `_KeyboardMenu`.
|
|
||||||
expect(childTokens(groups, 'Keyboard'), [
|
|
||||||
'group:Keyboard mode',
|
|
||||||
' $kShortcutActionKeyboardModeLegacy',
|
|
||||||
' $kShortcutActionKeyboardModeMap',
|
|
||||||
' $kShortcutActionKeyboardModeTranslate',
|
|
||||||
kShortcutActionToggleInputSource,
|
|
||||||
kShortcutActionToggleViewOnly,
|
|
||||||
kShortcutActionToggleShowMyCursor,
|
|
||||||
kShortcutActionToggleSwapCtrlCmd,
|
|
||||||
kShortcutActionToggleRelativeMouseMode,
|
|
||||||
kShortcutActionToggleReverseMouseWheel,
|
|
||||||
kShortcutActionToggleSwapLeftRightMouse,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filterKeyboardShortcutActionGroupsForPlatform drops empty groups', () {
|
|
||||||
// Sanity: KeyboardShortcutActionGroup ctor still accepts a single direct
|
|
||||||
// entry as a child.
|
|
||||||
final original = [
|
|
||||||
KeyboardShortcutActionGroup('TestGroup', [
|
|
||||||
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close Tab'),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
expect(original.first.children, hasLength(1));
|
|
||||||
|
|
||||||
// With every capability flag off, groups whose items are all behind
|
|
||||||
// those flags get dropped. Display / Keyboard parent groups still carry
|
|
||||||
// cross-platform direct items so they survive even when the gated
|
|
||||||
// subgroups thin out.
|
|
||||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
|
||||||
capabilities(
|
|
||||||
includeFullscreenShortcut: false,
|
|
||||||
includeScreenshotShortcut: false,
|
|
||||||
includeTabShortcuts: false,
|
|
||||||
includeToolbarShortcut: false,
|
|
||||||
includeCloseTabShortcut: false,
|
|
||||||
includeSwitchSidesShortcut: false,
|
|
||||||
includeRecordingShortcut: false,
|
|
||||||
includeResetCanvasShortcut: false,
|
|
||||||
includePinToolbarShortcut: false,
|
|
||||||
includeViewModeShortcut: false,
|
|
||||||
includeInputSourceShortcut: false,
|
|
||||||
includeVoiceCallShortcut: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final titles = groups.map((g) => g.titleKey).toList();
|
|
||||||
// "Other" has nothing but platform-gated entries → dropped entirely.
|
|
||||||
expect(titles, isNot(contains('Other')));
|
|
||||||
// Parent groups with cross-platform direct items survive.
|
|
||||||
expect(titles, contains('Display'));
|
|
||||||
expect(titles, contains('Keyboard'));
|
|
||||||
// The "View Mode" subgroup under Display is gated by includeViewModeShortcut,
|
|
||||||
// so it must be absent from Display's surviving children.
|
|
||||||
final displayChildren =
|
|
||||||
groups.firstWhere((g) => g.titleKey == 'Display').children;
|
|
||||||
final subgroupTitles = displayChildren
|
|
||||||
.whereType<KeyboardShortcutActionSubgroup>()
|
|
||||||
.map((s) => s.titleKey)
|
|
||||||
.toList();
|
|
||||||
expect(subgroupTitles, isNot(contains('View Mode')));
|
|
||||||
// No surviving group is empty either way.
|
|
||||||
expect(groups.every((g) => g.children.isNotEmpty), isTrue);
|
|
||||||
// No surviving subgroup is empty.
|
|
||||||
for (final group in groups) {
|
|
||||||
for (final child in group.children) {
|
|
||||||
if (child is KeyboardShortcutActionSubgroup) {
|
|
||||||
expect(child.entries, isNotEmpty,
|
|
||||||
reason: 'subgroup "${child.titleKey}" should not be empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logicalKeyName covers the supported-keys fixture', () {
|
|
||||||
// The fixture is the cross-language source of truth for the full set of
|
|
||||||
// shortcut-bindable key names. Rust has a mirror test against the same
|
|
||||||
// file (`supported_keys_match_fixture` in src/keyboard/shortcuts.rs).
|
|
||||||
// Drift on either side breaks one of the two tests.
|
|
||||||
final fixturePath = 'test/fixtures/supported_shortcut_keys.json';
|
|
||||||
final fixture =
|
|
||||||
(jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>)
|
|
||||||
.cast<String>()
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
// Hand-rolled (LogicalKeyboardKey, name) round-trip table. Adding a key
|
|
||||||
// requires updates in three places: the fixture, this table, and Rust's
|
|
||||||
// matching table — that's the price of the parity guarantee.
|
|
||||||
final mappings = <(LogicalKeyboardKey, String)>[
|
|
||||||
for (var c = 0; c < 26; c++)
|
|
||||||
(
|
|
||||||
LogicalKeyboardKey(0x00000000061 + c),
|
|
||||||
String.fromCharCode(0x61 + c),
|
|
||||||
),
|
|
||||||
for (var d = 0; d < 10; d++)
|
|
||||||
(LogicalKeyboardKey(0x00000000030 + d), 'digit$d'),
|
|
||||||
(LogicalKeyboardKey.f1, 'f1'),
|
|
||||||
(LogicalKeyboardKey.f2, 'f2'),
|
|
||||||
(LogicalKeyboardKey.f3, 'f3'),
|
|
||||||
(LogicalKeyboardKey.f4, 'f4'),
|
|
||||||
(LogicalKeyboardKey.f5, 'f5'),
|
|
||||||
(LogicalKeyboardKey.f6, 'f6'),
|
|
||||||
(LogicalKeyboardKey.f7, 'f7'),
|
|
||||||
(LogicalKeyboardKey.f8, 'f8'),
|
|
||||||
(LogicalKeyboardKey.f9, 'f9'),
|
|
||||||
(LogicalKeyboardKey.f10, 'f10'),
|
|
||||||
(LogicalKeyboardKey.f11, 'f11'),
|
|
||||||
(LogicalKeyboardKey.f12, 'f12'),
|
|
||||||
(LogicalKeyboardKey.delete, 'delete'),
|
|
||||||
(LogicalKeyboardKey.backspace, 'backspace'),
|
|
||||||
(LogicalKeyboardKey.tab, 'tab'),
|
|
||||||
(LogicalKeyboardKey.space, 'space'),
|
|
||||||
(LogicalKeyboardKey.enter, 'enter'),
|
|
||||||
(LogicalKeyboardKey.numpadEnter, 'enter'),
|
|
||||||
(LogicalKeyboardKey.arrowLeft, 'arrow_left'),
|
|
||||||
(LogicalKeyboardKey.arrowRight, 'arrow_right'),
|
|
||||||
(LogicalKeyboardKey.arrowUp, 'arrow_up'),
|
|
||||||
(LogicalKeyboardKey.arrowDown, 'arrow_down'),
|
|
||||||
(LogicalKeyboardKey.home, 'home'),
|
|
||||||
(LogicalKeyboardKey.end, 'end'),
|
|
||||||
(LogicalKeyboardKey.pageUp, 'page_up'),
|
|
||||||
(LogicalKeyboardKey.pageDown, 'page_down'),
|
|
||||||
(LogicalKeyboardKey.insert, 'insert'),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Round-trip: every (key, name) pair must agree with logicalKeyName.
|
|
||||||
for (final (key, name) in mappings) {
|
|
||||||
expect(logicalKeyName(key), equals(name),
|
|
||||||
reason: 'logicalKeyName($key) should be "$name"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The set of names produced by the table must equal the fixture.
|
|
||||||
final namesFromTable = mappings.map((e) => e.$2).toSet();
|
|
||||||
expect(namesFromTable, equals(fixture),
|
|
||||||
reason: 'logicalKeyName vocabulary drifted from $fixturePath — update '
|
|
||||||
'shortcut_utils.dart::logicalKeyName, the fixture, and Rust '
|
|
||||||
'event_to_key_name together');
|
|
||||||
|
|
||||||
// Modifier-only / unsupported keys must return null.
|
|
||||||
expect(logicalKeyName(LogicalKeyboardKey.shift), isNull);
|
|
||||||
expect(logicalKeyName(LogicalKeyboardKey.escape), isNull);
|
|
||||||
expect(logicalKeyName(LogicalKeyboardKey.f13), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('configurable shortcut list does not include known-removed action IDs',
|
|
||||||
() {
|
|
||||||
// These IDs were briefly defined without handlers (a "ghost action"
|
|
||||||
// footgun). If you intend to re-add one of these as a real action,
|
|
||||||
// wire up its handler and add a constant + group entry — do not just
|
|
||||||
// resurrect the literal string below.
|
|
||||||
//
|
|
||||||
// Note: `toggle_privacy_mode` was once on this list but is now a real
|
|
||||||
// implemented action (registered in shortcut_model.dart). The other
|
|
||||||
// legacy IDs (toggle_audio, view_mode_shrink/stretch, view_mode_1_to_1)
|
|
||||||
// were renamed: their replacements are kShortcutActionToggleMute and
|
|
||||||
// kShortcutActionViewModeOriginal/Adaptive/Custom.
|
|
||||||
const knownRemoved = [
|
|
||||||
'toggle_audio',
|
|
||||||
'view_mode_1_to_1',
|
|
||||||
'view_mode_shrink',
|
|
||||||
'view_mode_stretch',
|
|
||||||
];
|
|
||||||
final actions = idSet(kKeyboardShortcutActionGroups);
|
|
||||||
for (final id in knownRemoved) {
|
|
||||||
expect(actions, isNot(contains(id)),
|
|
||||||
reason:
|
|
||||||
'"$id" was a known ghost action — wire a real handler before re-adding it');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
38
flutter/test/server_settings_dialog_test.dart
Normal file
38
flutter/test/server_settings_dialog_test.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('server settings text fields preserve literal input',
|
||||||
|
(tester) async {
|
||||||
|
final controller = TextEditingController(text: 'AbCdR1c1E=');
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: serverSettingsTextFormField(
|
||||||
|
label: 'Key',
|
||||||
|
controller: controller,
|
||||||
|
errorMsg: '',
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
|
||||||
|
expect(textField.controller, controller);
|
||||||
|
expect(textField.autofocus, isTrue);
|
||||||
|
expect(textField.keyboardType, TextInputType.visiblePassword);
|
||||||
|
expect(textField.textCapitalization, TextCapitalization.none);
|
||||||
|
expect(textField.autocorrect, isFalse);
|
||||||
|
expect(textField.enableSuggestions, isFalse);
|
||||||
|
expect(textField.smartDashesType, SmartDashesType.disabled);
|
||||||
|
expect(textField.smartQuotesType, SmartQuotesType.disabled);
|
||||||
|
expect(textField.enableIMEPersonalizedLearning, isFalse);
|
||||||
|
expect(
|
||||||
|
textField.spellCheckConfiguration,
|
||||||
|
const SpellCheckConfiguration.disabled(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,6 +39,28 @@
|
|||||||
|
|
||||||
#define CLIPRDR_SVC_CHANNEL_NAME "cliprdr"
|
#define CLIPRDR_SVC_CHANNEL_NAME "cliprdr"
|
||||||
|
|
||||||
|
/* Maximum number of clipboard streams accepted from a remote peer (integer overflow / DoS guard) */
|
||||||
|
#define WF_CLIPRDR_MAX_STREAMS 16384
|
||||||
|
|
||||||
|
/* Validates the remote descriptor array size after cItems has been read safely. */
|
||||||
|
static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
|
||||||
|
{
|
||||||
|
SIZE_T header_size = offsetof(FILEGROUPDESCRIPTORW, fgd);
|
||||||
|
SIZE_T descriptors_size;
|
||||||
|
|
||||||
|
if (count == 0 || count > WF_CLIPRDR_MAX_STREAMS)
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
if (size < header_size)
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
if ((SIZE_T)count > (((SIZE_T)-1) - header_size) / sizeof(FILEDESCRIPTORW))
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
descriptors_size = header_size + (SIZE_T)count * sizeof(FILEDESCRIPTORW);
|
||||||
|
return size >= descriptors_size;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clipboard Formats
|
* Clipboard Formats
|
||||||
*/
|
*/
|
||||||
@@ -224,6 +246,7 @@ struct wf_clipboard
|
|||||||
|
|
||||||
HWND hwnd;
|
HWND hwnd;
|
||||||
HANDLE hmem;
|
HANDLE hmem;
|
||||||
|
SIZE_T hmem_data_len;
|
||||||
HANDLE thread;
|
HANDLE thread;
|
||||||
HANDLE formatDataRespEvent;
|
HANDLE formatDataRespEvent;
|
||||||
BOOL formatDataRespReceived;
|
BOOL formatDataRespReceived;
|
||||||
@@ -629,6 +652,50 @@ void CliprdrStream_Delete(CliprdrStream *instance)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void wf_cliprdr_release_streams(IStream **streams, ULONG count)
|
||||||
|
{
|
||||||
|
ULONG i;
|
||||||
|
|
||||||
|
if (!streams)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (streams[i])
|
||||||
|
CliprdrStream_Release(streams[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(streams);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wf_cliprdr_reset_streams(CliprdrDataObject *instance)
|
||||||
|
{
|
||||||
|
if (!instance)
|
||||||
|
return;
|
||||||
|
|
||||||
|
wf_cliprdr_release_streams(instance->m_pStream, instance->m_nStreams);
|
||||||
|
instance->m_pStream = NULL;
|
||||||
|
instance->m_nStreams = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only call after clipboard->hmem has been locked by GlobalLock. */
|
||||||
|
static HRESULT wf_cliprdr_fail_locked_file_descriptor_data(wfClipboard *clipboard,
|
||||||
|
STGMEDIUM *medium,
|
||||||
|
CliprdrDataObject *instance,
|
||||||
|
IStream **streams,
|
||||||
|
ULONG stream_count,
|
||||||
|
HRESULT error)
|
||||||
|
{
|
||||||
|
GlobalUnlock(clipboard->hmem);
|
||||||
|
GlobalFree(clipboard->hmem);
|
||||||
|
clipboard->hmem = NULL;
|
||||||
|
clipboard->hmem_data_len = 0;
|
||||||
|
medium->hGlobal = NULL;
|
||||||
|
wf_cliprdr_release_streams(streams, stream_count);
|
||||||
|
wf_cliprdr_reset_streams(instance);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDataObject
|
* IDataObject
|
||||||
*/
|
*/
|
||||||
@@ -747,6 +814,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
|
|||||||
{
|
{
|
||||||
// FILEGROUPDESCRIPTOR *dsc;
|
// FILEGROUPDESCRIPTOR *dsc;
|
||||||
FILEGROUPDESCRIPTORW *dsc;
|
FILEGROUPDESCRIPTORW *dsc;
|
||||||
|
IStream **streams = NULL;
|
||||||
|
UINT stream_count = 0;
|
||||||
|
SIZE_T hmem_size;
|
||||||
// DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat);
|
// DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat);
|
||||||
// FIXME: origin code may be failed here???
|
// FIXME: origin code may be failed here???
|
||||||
if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0)
|
if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0)
|
||||||
@@ -764,40 +834,48 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
|
|||||||
* is the number of FILEDESCRIPTOR's */
|
* is the number of FILEDESCRIPTOR's */
|
||||||
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
|
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
|
||||||
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
|
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
|
||||||
instance->m_nStreams = dsc->cItems;
|
if (!dsc)
|
||||||
GlobalUnlock(clipboard->hmem);
|
|
||||||
|
|
||||||
if (instance->m_nStreams > 0)
|
|
||||||
{
|
{
|
||||||
if (!instance->m_pStream)
|
|
||||||
{
|
|
||||||
instance->m_pStream = (LPSTREAM *)calloc(instance->m_nStreams, sizeof(LPSTREAM));
|
|
||||||
|
|
||||||
if (instance->m_pStream)
|
|
||||||
{
|
|
||||||
for (i = 0; i < instance->m_nStreams; i++)
|
|
||||||
{
|
|
||||||
instance->m_pStream[i] =
|
|
||||||
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
|
|
||||||
|
|
||||||
if (!instance->m_pStream[i])
|
|
||||||
return E_OUTOFMEMORY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instance->m_pStream)
|
|
||||||
{
|
|
||||||
if (clipboard->hmem)
|
|
||||||
{
|
|
||||||
GlobalFree(clipboard->hmem);
|
|
||||||
clipboard->hmem = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
pMedium->hGlobal = NULL;
|
pMedium->hGlobal = NULL;
|
||||||
return E_OUTOFMEMORY;
|
GlobalFree(clipboard->hmem);
|
||||||
|
clipboard->hmem = NULL;
|
||||||
|
clipboard->hmem_data_len = 0;
|
||||||
|
wf_cliprdr_reset_streams(instance);
|
||||||
|
return E_UNEXPECTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hmem_size = clipboard->hmem_data_len;
|
||||||
|
/* cItems is remote-controlled; verify the fixed header exists before reading it. */
|
||||||
|
if (hmem_size < offsetof(FILEGROUPDESCRIPTORW, fgd))
|
||||||
|
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||||
|
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
|
||||||
|
|
||||||
|
stream_count = dsc->cItems;
|
||||||
|
if (!wf_cliprdr_file_group_descriptor_size_valid(hmem_size, stream_count))
|
||||||
|
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||||
|
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
|
||||||
|
|
||||||
|
streams = (IStream **)calloc(stream_count, sizeof(IStream *));
|
||||||
|
if (!streams)
|
||||||
|
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||||
|
clipboard, pMedium, instance, NULL, 0, E_OUTOFMEMORY);
|
||||||
|
|
||||||
|
for (i = 0; i < stream_count; i++)
|
||||||
|
{
|
||||||
|
streams[i] =
|
||||||
|
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
|
||||||
|
if (!streams[i])
|
||||||
|
{
|
||||||
|
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||||
|
clipboard, pMedium, instance, streams, i, E_OUTOFMEMORY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalUnlock(clipboard->hmem);
|
||||||
|
wf_cliprdr_reset_streams(instance);
|
||||||
|
instance->m_pStream = streams;
|
||||||
|
instance->m_nStreams = stream_count;
|
||||||
|
return S_OK;
|
||||||
}
|
}
|
||||||
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
|
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
|
||||||
{
|
{
|
||||||
@@ -2161,16 +2239,16 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi
|
|||||||
return FALSE;
|
return FALSE;
|
||||||
|
|
||||||
/* add to name array */
|
/* add to name array */
|
||||||
clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc((size_t)MAX_PATH * sizeof(WCHAR));
|
|
||||||
|
|
||||||
if (!clipboard->file_names[clipboard->nFiles])
|
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
// `MAX_PATH` is long enough for the file name.
|
// `MAX_PATH` is long enough for the file name.
|
||||||
// So we just return FALSE if the file name is too long, which is not a normal case.
|
// So we just return FALSE if the file name is too long, which is not a normal case.
|
||||||
if ((wcslen(full_file_name) + 1) > MAX_PATH)
|
if ((wcslen(full_file_name) + 1) > MAX_PATH)
|
||||||
return FALSE;
|
return FALSE;
|
||||||
|
|
||||||
|
clipboard->file_names[clipboard->nFiles] = (LPWSTR)calloc(MAX_PATH, sizeof(WCHAR));
|
||||||
|
|
||||||
|
if (!clipboard->file_names[clipboard->nFiles])
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
wcsncpy_s(clipboard->file_names[clipboard->nFiles], MAX_PATH, full_file_name, wcslen(full_file_name) + 1);
|
wcsncpy_s(clipboard->file_names[clipboard->nFiles], MAX_PATH, full_file_name, wcslen(full_file_name) + 1);
|
||||||
/* add to descriptor array */
|
/* add to descriptor array */
|
||||||
clipboard->fileDescriptor[clipboard->nFiles] =
|
clipboard->fileDescriptor[clipboard->nFiles] =
|
||||||
@@ -2778,6 +2856,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
clipboard->hmem = NULL;
|
clipboard->hmem = NULL;
|
||||||
|
clipboard->hmem_data_len = 0;
|
||||||
|
|
||||||
if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
|
if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
|
||||||
{
|
{
|
||||||
@@ -2811,6 +2890,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clipboard->hmem_data_len = formatDataResponse->dataLen;
|
||||||
clipboard->hmem = hMem;
|
clipboard->hmem = hMem;
|
||||||
rc = CHANNEL_RC_OK;
|
rc = CHANNEL_RC_OK;
|
||||||
} while (0);
|
} while (0);
|
||||||
|
|||||||
Submodule libs/hbb_common updated: 87b11a7959...387603f47c
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustdesk-portable-packer"
|
name = "rustdesk-portable-packer"
|
||||||
version = "1.4.6"
|
version = "1.4.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "RustDesk Remote Desktop"
|
description = "RustDesk Remote Desktop"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import optparse
|
import optparse
|
||||||
|
import subprocess
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
import brotli
|
import brotli
|
||||||
import datetime
|
import datetime
|
||||||
@@ -65,11 +66,15 @@ def write_app_metadata(output_folder: str):
|
|||||||
print(f"App metadata has been written to {output_path}")
|
print(f"App metadata has been written to {output_path}")
|
||||||
|
|
||||||
def build_portable(output_folder: str, target: str):
|
def build_portable(output_folder: str, target: str):
|
||||||
os.chdir(output_folder)
|
current_dir = os.getcwd()
|
||||||
if target:
|
try:
|
||||||
os.system("cargo build --release --target " + target)
|
os.chdir(output_folder)
|
||||||
else:
|
cmd = ["cargo", "build", "--locked", "--release"]
|
||||||
os.system("cargo build --release")
|
if target:
|
||||||
|
cmd.extend(["--target", target])
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
finally:
|
||||||
|
os.chdir(current_dir)
|
||||||
|
|
||||||
# Linux: python3 generate.py -f ../rustdesk-portable-packer/test -o . -e ./test/main.py
|
# Linux: python3 generate.py -f ../rustdesk-portable-packer/test -o . -e ./test/main.py
|
||||||
# Windows: python3 .\generate.py -f ..\rustdesk\flutter\build\windows\runner\Debug\ -o . -e ..\rustdesk\flutter\build\windows\runner\Debug\rustdesk.exe
|
# Windows: python3 .\generate.py -f ..\rustdesk\flutter\build\windows\runner\Debug\ -o . -e ..\rustdesk\flutter\build\windows\runner\Debug\rustdesk.exe
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
|
|||||||
format!("{}-{}", target_arch, target_os)
|
format!("{}-{}", target_arch, target_os)
|
||||||
}
|
}
|
||||||
} else if target_os == "windows" {
|
} else if target_os == "windows" {
|
||||||
"x64-windows-static".to_owned()
|
format!("{}-windows-static", target_arch)
|
||||||
} else {
|
} else {
|
||||||
format!("{}-{}", target_arch, target_os)
|
format!("{}-{}", target_arch, target_os)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ lazy_static::lazy_static! {
|
|||||||
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default();
|
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_windows(cls: &str, name: &str) -> Result<Vec<HWND>> {
|
||||||
|
let name_c = CString::new(name)?;
|
||||||
|
let cls_c = if cls.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(CString::new(cls)?)
|
||||||
|
};
|
||||||
|
let mut hwnds = Vec::new();
|
||||||
|
unsafe {
|
||||||
|
let mut after = NULL as _;
|
||||||
|
loop {
|
||||||
|
let hwnd = FindWindowExA(
|
||||||
|
NULL as _,
|
||||||
|
after,
|
||||||
|
cls_c.as_ref().map_or(NULL as _, |c| c.as_ptr()),
|
||||||
|
name_c.as_ptr(),
|
||||||
|
);
|
||||||
|
if hwnd.is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hwnds.push(hwnd);
|
||||||
|
after = hwnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(hwnds)
|
||||||
|
}
|
||||||
|
|
||||||
pub type REFWICPixelFormatGUID = *const GUID;
|
pub type REFWICPixelFormatGUID = *const GUID;
|
||||||
pub type WICPixelFormatGUID = GUID;
|
pub type WICPixelFormatGUID = GUID;
|
||||||
|
|
||||||
@@ -247,6 +274,8 @@ pub struct CapturerMag {
|
|||||||
rect: RECT,
|
rect: RECT,
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
|
excluded_window_target: Option<(String, String)>,
|
||||||
|
excluded_windows: Vec<HWND>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for CapturerMag {
|
impl Drop for CapturerMag {
|
||||||
@@ -261,6 +290,10 @@ impl CapturerMag {
|
|||||||
MagInterface::new().is_ok()
|
MagInterface::new().is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This captures through the legacy Windows Magnification API. Do not infer
|
||||||
|
// multi-monitor capture support from privacy overlay coverage: WebRTC also
|
||||||
|
// disables its magnifier capturer when SM_CMONITORS != 1.
|
||||||
|
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||||
pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
|
pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||||
@@ -305,6 +338,8 @@ impl CapturerMag {
|
|||||||
},
|
},
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
excluded_window_target: None,
|
||||||
|
excluded_windows: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -436,19 +471,41 @@ impl CapturerMag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> {
|
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> {
|
||||||
let name_c = CString::new(name)?;
|
let mut hwnds = find_windows(cls, name)?;
|
||||||
|
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||||
|
self.excluded_window_target = Some((cls.to_owned(), name.to_owned()));
|
||||||
|
if hwnds.is_empty() {
|
||||||
|
self.excluded_windows.clear();
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.exclude_windows(&mut hwnds)?;
|
||||||
|
self.excluded_windows = hwnds;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_excluded_windows(&mut self) -> Result<()> {
|
||||||
|
let Some((cls, name)) = self.excluded_window_target.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut hwnds = find_windows(cls, name)?;
|
||||||
|
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||||
|
// This runs from frame() because refreshed privacy overlays get new
|
||||||
|
// HWNDs. It is only used on the legacy magnifier backend while privacy
|
||||||
|
// mode is active; if it shows up as hot-path cost, throttle this check.
|
||||||
|
// Keep the previous filter list while privacy windows are being recreated.
|
||||||
|
if hwnds.is_empty() || hwnds == self.excluded_windows {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.exclude_windows(&mut hwnds)?;
|
||||||
|
self.excluded_windows = hwnds;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exclude_windows(&mut self, hwnds: &mut [HWND]) -> Result<bool> {
|
||||||
|
let count = hwnds.len() as _;
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut hwnd = if cls.len() == 0 {
|
|
||||||
FindWindowExA(NULL as _, NULL as _, NULL as _, name_c.as_ptr())
|
|
||||||
} else {
|
|
||||||
let cls_c = CString::new(cls).unwrap();
|
|
||||||
FindWindowExA(NULL as _, NULL as _, cls_c.as_ptr(), name_c.as_ptr())
|
|
||||||
};
|
|
||||||
|
|
||||||
if hwnd.is_null() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(set_window_filter_list_func) =
|
if let Some(set_window_filter_list_func) =
|
||||||
self.mag_interface.set_window_filter_list_func
|
self.mag_interface.set_window_filter_list_func
|
||||||
{
|
{
|
||||||
@@ -456,16 +513,15 @@ impl CapturerMag {
|
|||||||
== set_window_filter_list_func(
|
== set_window_filter_list_func(
|
||||||
self.magnifier_window,
|
self.magnifier_window,
|
||||||
MW_FILTERMODE_EXCLUDE,
|
MW_FILTERMODE_EXCLUDE,
|
||||||
1,
|
count,
|
||||||
&mut hwnd,
|
hwnds.as_mut_ptr(),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return Err(Error::new(
|
return Err(Error::new(
|
||||||
ErrorKind::Other,
|
ErrorKind::Other,
|
||||||
format!(
|
format!(
|
||||||
"Failed MagSetWindowFilterList for cls {} name {}, error {}",
|
"Failed MagSetWindowFilterList for {} windows, error {}",
|
||||||
cls,
|
count,
|
||||||
name,
|
|
||||||
Error::last_os_error()
|
Error::last_os_error()
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -496,6 +552,7 @@ impl CapturerMag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
|
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
|
||||||
|
self.refresh_excluded_windows()?;
|
||||||
Self::clear_data();
|
Self::clear_data();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
@@ -276,12 +276,21 @@ impl PipeWireRecorder {
|
|||||||
// see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982
|
// see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982
|
||||||
src.set_property("always-copy", &true)?;
|
src.set_property("always-copy", &true)?;
|
||||||
|
|
||||||
|
// COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink.
|
||||||
|
// xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream
|
||||||
|
// format set is too narrow (appsink only accepts BGRx/RGBx), producing
|
||||||
|
// "no more output formats" / not-negotiated (-4). videoconvert accepts any
|
||||||
|
// system-memory video/x-raw format, widening negotiation so the portal can
|
||||||
|
// settle on a format it can deliver via its SHM path.
|
||||||
|
let convert = gst::ElementFactory::make("videoconvert", None)?;
|
||||||
|
|
||||||
let sink = gst::ElementFactory::make("appsink", None)?;
|
let sink = gst::ElementFactory::make("appsink", None)?;
|
||||||
sink.set_property("drop", &true)?;
|
sink.set_property("drop", &true)?;
|
||||||
sink.set_property("max-buffers", &1u32)?;
|
sink.set_property("max-buffers", &1u32)?;
|
||||||
|
|
||||||
pipeline.add_many(&[&src, &sink])?;
|
pipeline.add_many(&[&src, &convert, &sink])?;
|
||||||
src.link(&sink)?;
|
src.link(&convert)?;
|
||||||
|
convert.link(&sink)?;
|
||||||
|
|
||||||
let appsink = sink
|
let appsink = sink
|
||||||
.dynamic_cast::<AppSink>()
|
.dynamic_cast::<AppSink>()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=rustdesk
|
pkgname=rustdesk
|
||||||
pkgver=1.4.6
|
pkgver=1.4.8
|
||||||
pkgrel=0
|
pkgrel=0
|
||||||
epoch=
|
epoch=
|
||||||
pkgdesc=""
|
pkgdesc=""
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#! /usr/bin/env bash
|
#! /usr/bin/env bash
|
||||||
sed -i "s/$1/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
sed -i "s/\b$1\b/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
||||||
cargo run # to bump version in cargo lock
|
cargo run # to bump version in cargo lock
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ LExit:
|
|||||||
return WcaFinalize(er);
|
return WcaFinalize(er);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to safely delete a file or directory using handle-based deletion.
|
// Helper function to safely delete a file using handle-based deletion.
|
||||||
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
|
// Directories are refused after opening the handle.
|
||||||
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||||
{
|
{
|
||||||
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT
|
// Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
|
||||||
// to prevent following symlinks.
|
// to prevent following symlinks.
|
||||||
// Use shared access to allow deletion even when other processes have the file open.
|
// Use shared access to allow deletion even when other processes have the file open.
|
||||||
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
||||||
HANDLE hFile = CreateFileW(
|
HANDLE hFile = CreateFileW(
|
||||||
fullPath,
|
fullPath,
|
||||||
DELETE,
|
DELETE | FILE_READ_ATTRIBUTES,
|
||||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
||||||
NULL,
|
NULL,
|
||||||
OPEN_EXISTING,
|
OPEN_EXISTING,
|
||||||
@@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BY_HANDLE_FILE_INFORMATION fileInfo;
|
||||||
|
if (FALSE == GetFileInformationByHandle(hFile, &fileInfo))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError());
|
||||||
|
CloseHandle(hFile);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath);
|
||||||
|
CloseHandle(hFile);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
// Use SetFileInformationByHandle to mark for deletion.
|
// Use SetFileInformationByHandle to mark for deletion.
|
||||||
// The file will be deleted when the handle is closed.
|
// The file will be deleted when the handle is closed.
|
||||||
FILE_DISPOSITION_INFO dispInfo;
|
FILE_DISPOSITION_INFO dispInfo;
|
||||||
@@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to recursively delete a directory's contents with detailed logging.
|
BOOL PathEndsWithSlash(LPCWSTR path)
|
||||||
void RecursiveDelete(LPCWSTR path)
|
|
||||||
{
|
{
|
||||||
// Ensure the path is not empty or null.
|
size_t length = 0;
|
||||||
if (path == NULL || path[0] == L'\0')
|
HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
|
||||||
|
if (FAILED(hr) || length == 0)
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
WCHAR last = path[length - 1];
|
||||||
|
return last == L'\\' || last == L'/';
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
|
||||||
|
{
|
||||||
|
if (!(attributes & FILE_ATTRIBUTE_READONLY))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extra safety: never operate directly on a root path.
|
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
|
||||||
if (PathIsRootW(path))
|
if (writableAttributes == 0)
|
||||||
{
|
{
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path);
|
writableAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SetFileAttributesW(fullPath, writableAttributes))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError());
|
||||||
// No need to handle extended-length paths (\\?\) in this context.
|
}
|
||||||
WCHAR searchPath[MAX_PATH];
|
|
||||||
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path);
|
BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
||||||
if (FAILED(hr)) {
|
{
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path);
|
WCHAR fullPath[MAX_PATH];
|
||||||
return;
|
LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\";
|
||||||
|
HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName);
|
||||||
|
if (FAILED(hr))
|
||||||
|
{
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName);
|
||||||
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
WIN32_FIND_DATAW findData;
|
DWORD attributes = GetFileAttributesW(fullPath);
|
||||||
HANDLE hFind = FindFirstFileW(searchPath, &findData);
|
if (attributes == INVALID_FILE_ATTRIBUTES)
|
||||||
|
|
||||||
if (hFind == INVALID_HANDLE_VALUE)
|
|
||||||
{
|
{
|
||||||
// This can happen if the directory is empty or doesn't exist, which is not an error in our case.
|
DWORD error = GetLastError();
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError());
|
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
|
||||||
return;
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
|
||||||
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||||
{
|
{
|
||||||
// Skip '.' and '..' directories.
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
|
||||||
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
|
return FALSE;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
|
||||||
// No need to handle extended-length paths (\\?\) in this context.
|
|
||||||
WCHAR fullPath[MAX_PATH];
|
|
||||||
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
|
|
||||||
if (FAILED(hr)) {
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before acting, ensure the read-only attribute is not set.
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
|
|
||||||
{
|
|
||||||
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
|
||||||
{
|
|
||||||
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
|
|
||||||
// Do not follow reparse points, only remove the link itself.
|
|
||||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Recursively delete directory contents first
|
|
||||||
RecursiveDelete(fullPath);
|
|
||||||
// Then delete the directory itself
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Delete file using safe handle-based deletion
|
|
||||||
SafeDeleteItem(fullPath);
|
|
||||||
}
|
|
||||||
} while (FindNextFileW(hFind, &findData) != 0);
|
|
||||||
|
|
||||||
DWORD lastError = GetLastError();
|
|
||||||
if (lastError != ERROR_NO_MORE_FILES)
|
|
||||||
{
|
|
||||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FindClose(hFind);
|
ClearReadOnlyAttribute(fullPath, attributes);
|
||||||
|
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
|
||||||
|
return SafeDeleteItem(fullPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// See `Package.wxs` for the sequence of this custom action.
|
// See `Package.wxs` for the sequence of this custom action.
|
||||||
@@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path)
|
|||||||
// 2. RemoveExistingProducts
|
// 2. RemoveExistingProducts
|
||||||
// ├─ TerminateProcesses
|
// ├─ TerminateProcesses
|
||||||
// ├─ TryStopDeleteService
|
// ├─ TryStopDeleteService
|
||||||
// ├─ RemoveInstallFolder - <-- Here
|
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
|
||||||
// └─ RemoveFiles
|
// └─ RemoveFiles
|
||||||
// 3. InstallValidate
|
// 3. InstallValidate
|
||||||
// 4. InstallFiles
|
// 4. InstallFiles
|
||||||
// 5. InstallExecute
|
// 5. InstallExecute
|
||||||
// 6. InstallFinalize
|
// 6. InstallFinalize
|
||||||
UINT __stdcall RemoveInstallFolder(
|
UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||||
__in MSIHANDLE hInstall)
|
__in MSIHANDLE hInstall)
|
||||||
{
|
{
|
||||||
HRESULT hr = S_OK;
|
HRESULT hr = S_OK;
|
||||||
@@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder(
|
|||||||
LPWSTR pwz = NULL;
|
LPWSTR pwz = NULL;
|
||||||
LPWSTR pwzData = NULL;
|
LPWSTR pwzData = NULL;
|
||||||
|
|
||||||
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
|
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
|
||||||
ExitOnFailure(hr, "Failed to initialize");
|
ExitOnFailure(hr, "Failed to initialize");
|
||||||
|
|
||||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||||
@@ -202,24 +193,20 @@ UINT __stdcall RemoveInstallFolder(
|
|||||||
|
|
||||||
pwz = pwzData;
|
pwz = pwzData;
|
||||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
|
||||||
|
|
||||||
if (installFolder == NULL || installFolder[0] == L'\0') {
|
if (installFolder == NULL || installFolder[0] == L'\0') {
|
||||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
|
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
|
||||||
goto LExit;
|
goto LExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PathIsRootW(installFolder)) {
|
if (PathIsRootW(installFolder)) {
|
||||||
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
|
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
|
||||||
goto LExit;
|
goto LExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder);
|
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
|
||||||
|
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
|
||||||
RecursiveDelete(installFolder);
|
|
||||||
|
|
||||||
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
|
|
||||||
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
|
|
||||||
|
|
||||||
LExit:
|
LExit:
|
||||||
ReleaseStr(pwzData);
|
ReleaseStr(pwzData);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ LIBRARY "CustomActions"
|
|||||||
|
|
||||||
EXPORTS
|
EXPORTS
|
||||||
CustomActionHello
|
CustomActionHello
|
||||||
RemoveInstallFolder
|
RemoveRuntimeGeneratedFiles
|
||||||
TerminateProcesses
|
TerminateProcesses
|
||||||
AddFirewallRules
|
AddFirewallRules
|
||||||
SetPropertyIsServiceRunning
|
SetPropertyIsServiceRunning
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<Configuration>Release</Configuration>
|
<Configuration>Release</Configuration>
|
||||||
<Platform>x64</Platform>
|
<Platform>x64</Platform>
|
||||||
</ProjectConfiguration>
|
</ProjectConfiguration>
|
||||||
|
<ProjectConfiguration Include="Release|ARM64">
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>ARM64</Platform>
|
||||||
|
</ProjectConfiguration>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<PropertyGroup Label="Globals">
|
<PropertyGroup Label="Globals">
|
||||||
<Keyword>Win32Proj</Keyword>
|
<Keyword>Win32Proj</Keyword>
|
||||||
@@ -22,6 +26,12 @@
|
|||||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||||
<CharacterSet>Unicode</CharacterSet>
|
<CharacterSet>Unicode</CharacterSet>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
|
||||||
|
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||||
|
<UseDebugLibraries>false</UseDebugLibraries>
|
||||||
|
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||||
|
<CharacterSet>Unicode</CharacterSet>
|
||||||
|
</PropertyGroup>
|
||||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||||
<ImportGroup Label="ExtensionSettings">
|
<ImportGroup Label="ExtensionSettings">
|
||||||
</ImportGroup>
|
</ImportGroup>
|
||||||
@@ -30,6 +40,9 @@
|
|||||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||||
</ImportGroup>
|
</ImportGroup>
|
||||||
|
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||||
|
</ImportGroup>
|
||||||
<PropertyGroup Label="UserMacros" />
|
<PropertyGroup Label="UserMacros" />
|
||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
<ClCompile>
|
<ClCompile>
|
||||||
@@ -53,6 +66,28 @@
|
|||||||
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<ClCompile>
|
||||||
|
<WarningLevel>Level3</WarningLevel>
|
||||||
|
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||||
|
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||||
|
<SDLCheck>true</SDLCheck>
|
||||||
|
<PreprocessorDefinitions>NDEBUG;EXAMPLECADLL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
|
<ConformanceMode>true</ConformanceMode>
|
||||||
|
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||||
|
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
|
||||||
|
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<AdditionalDependencies>msi.lib;version.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
|
<SubSystem>Windows</SubSystem>
|
||||||
|
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||||
|
<OptimizeReferences>true</OptimizeReferences>
|
||||||
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
|
<EnableUAC>false</EnableUAC>
|
||||||
|
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="Common.h" />
|
<ClInclude Include="Common.h" />
|
||||||
<ClInclude Include="framework.h" />
|
<ClInclude Include="framework.h" />
|
||||||
@@ -65,6 +100,7 @@
|
|||||||
<ClCompile Include="FirewallRules.cpp" />
|
<ClCompile Include="FirewallRules.cpp" />
|
||||||
<ClCompile Include="pch.cpp">
|
<ClCompile Include="pch.cpp">
|
||||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
|
||||||
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<ClCompile Include="ReadConfig.cpp" />
|
<ClCompile Include="ReadConfig.cpp" />
|
||||||
<ClCompile Include="RemotePrinter.cpp" />
|
<ClCompile Include="RemotePrinter.cpp" />
|
||||||
|
|||||||
@@ -16,8 +16,15 @@
|
|||||||
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
||||||
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
||||||
|
|
||||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
<!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
|
||||||
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" />
|
<!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)\"" />
|
||||||
|
<!-- Case 2: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerFromProductDirNoSlash" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)"" />
|
||||||
|
<!-- Case 3: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerAppendProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||||
|
<!-- Case 4: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||||
|
<SetProperty Action="SetInstallFolderInnerAppendSlashProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND NOT INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||||
|
|
||||||
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
||||||
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</Component>
|
</Component>
|
||||||
</DirectoryRef>
|
</DirectoryRef>
|
||||||
|
|
||||||
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" />
|
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
|
||||||
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||||
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||||
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[INSTALLFOLDER_INNER]$(var.Product).exe" --service" />
|
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[INSTALLFOLDER_INNER]$(var.Product).exe" --service" />
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
|
Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
|
||||||
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
|
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
|
||||||
<!-- Remote printer also works on Win8.1 in my test. -->
|
<!-- Remote printer also works on Win8.1 in my test. -->
|
||||||
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT >= 603 AND PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y"" />
|
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT >= 603 AND (PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y")" />
|
||||||
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT >= 603" />
|
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT >= 603" />
|
||||||
|
|
||||||
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->
|
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->
|
||||||
@@ -77,21 +77,21 @@
|
|||||||
|
|
||||||
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
||||||
|
|
||||||
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/>
|
<Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||||
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
|
<Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||||
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
|
<Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
|
||||||
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
||||||
|
|
||||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||||
|
|
||||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||||
|
|
||||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
||||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
|
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
||||||
</InstallExecuteSequence>
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
||||||
|
|
||||||
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
<?include ..\Includes.wxi?>
|
<?include ..\Includes.wxi?>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Properties and related actions for specifying whether to install start menu/desktop shortcuts.
|
Properties and related actions for specifying whether to install shortcuts and the printer.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- These are the actual properties that get used in conditions to determine whether to
|
<!-- These are the actual properties that get used in conditions to determine whether to
|
||||||
install start menu shortcuts, they are initialized with a default value to install shortcuts.
|
install start menu shortcuts or the printer. Shortcut properties default to install;
|
||||||
They should not be set directly from the command line or registry, instead the CREATE* properties
|
PRINTER defaults to not install. The CREATE* properties below update shortcut
|
||||||
below should be set, then they will update these properties with their values only if set. -->
|
properties from command line, bundle, or registry values. -->
|
||||||
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
|
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||||
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
|
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||||
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
|
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||||
<Property Id="PRINTER" Value="1" Secure="yes"></Property>
|
<Property Id="PRINTER" Secure="yes"></Property>
|
||||||
|
|
||||||
<!-- These properties get set from either the command line, bundle or registry value,
|
<!-- These properties get set from either the command line, bundle or registry value,
|
||||||
if set they update the properties above with their value. -->
|
if set they update the properties above with their value. -->
|
||||||
@@ -77,7 +77,11 @@
|
|||||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
||||||
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = "Y" OR CREATESTARTMENUSHORTCUTS = "y")" />
|
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = "Y" OR CREATESTARTMENUSHORTCUTS = "y")" />
|
||||||
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = "Y" OR CREATEDESKTOPSHORTCUTS = "y")" />
|
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = "Y" OR CREATEDESKTOPSHORTCUTS = "y")" />
|
||||||
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y")" />
|
<!-- PRINTER defaults to empty now, so a saved or command-line INSTALLPRINTER=1
|
||||||
|
must explicitly enable the main PRINTER property. Non-truthy INSTALLPRINTER
|
||||||
|
values still clear PRINTER so upgrades preserve an explicit disabled choice. -->
|
||||||
|
<SetProperty Action="SetPrinterValueEnabled" Id="PRINTER" Value="1" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y"" />
|
||||||
|
<SetProperty Action="SetPrinterValueDisabled" Id="PRINTER" Value="" After="SetPrinterValueEnabled" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y")" />
|
||||||
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Wix>
|
</Wix>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<IncludeSearchPaths>
|
<IncludeSearchPaths>
|
||||||
</IncludeSearchPaths>
|
</IncludeSearchPaths>
|
||||||
<Configurations>Release</Configurations>
|
<Configurations>Release</Configurations>
|
||||||
<Platforms>x64</Platforms>
|
<Platforms>x64;ARM64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Includes.wxi" />
|
<Content Include="Includes.wxi" />
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ Patch dialog sequence:
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||||
|
<?include ../Includes.wxi?>
|
||||||
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
||||||
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
||||||
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||||
</UI>
|
</UI>
|
||||||
|
|
||||||
<UIRef Id="UI_MyInstallDialog" />
|
<UIRef Id="UI_MyInstallDialog" />
|
||||||
@@ -64,9 +65,16 @@ Patch dialog sequence:
|
|||||||
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
||||||
|
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
<!-- Normalize INSTALLFOLDER_INNER before SetTargetPath and WixUIValidatePath run. -->
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
<!-- UI case 1: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\" Order="1" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\$(var.Product)"" />
|
||||||
|
<!-- UI case 2: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]$(var.Product)\" Order="2" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||||
|
<!-- UI case 3: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\$(var.Product)\" Order="3" Condition="INSTALLFOLDER_INNER AND NOT INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="4" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="6" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
||||||
|
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
||||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
|
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
|
||||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />
|
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ EndProject
|
|||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Release|x64 = Release|x64
|
Release|x64 = Release|x64
|
||||||
|
Release|ARM64 = Release|ARM64
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.ActiveCfg = Release|x64
|
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.ActiveCfg = Release|x64
|
||||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.Build.0 = Release|x64
|
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.Build.0 = Release|x64
|
||||||
|
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||||
|
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.Build.0 = Release|ARM64
|
||||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.ActiveCfg = Release|x64
|
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.ActiveCfg = Release|x64
|
||||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.Build.0 = Release|x64
|
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.Build.0 = Release|x64
|
||||||
|
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||||
|
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.Build.0 = Release|ARM64
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
echo $MACOS_CODESIGN_IDENTITY
|
echo $MACOS_CODESIGN_IDENTITY
|
||||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||||
cd flutter; flutter pub get; cd -
|
cd flutter; flutter pub get; cd -
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
||||||
./build.py --flutter
|
./build.py --flutter
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user