mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-06-09 18:04:53 +03:00
Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d4a1430c27 | ||
|
|
bfd31d21e4 | ||
|
|
590296b297 | ||
|
|
ee8cc0c06b | ||
|
|
99b565ef40 | ||
|
|
1e6a3dc644 | ||
|
|
5b7ad339b8 | ||
|
|
7308c448f1 | ||
|
|
c8ba99d1a1 | ||
|
|
5ea6714db8 | ||
|
|
3a1622e8b5 | ||
|
|
38f1300717 | ||
|
|
03e351ac61 | ||
|
|
6cb323725b | ||
|
|
5d0533f0d4 | ||
|
|
e0c5e1483e | ||
|
|
47e4c65d8e | ||
|
|
9bc1ce52af | ||
|
|
348d1b46e1 | ||
|
|
1a41b3ac11 | ||
|
|
b239535009 | ||
|
|
5fd20f808c | ||
|
|
803ac8cc4e | ||
|
|
4a50bc6fc2 | ||
|
|
e8a1b7fe21 | ||
|
|
ac124c0680 | ||
|
|
91aff3ffd1 | ||
|
|
642c281ad0 | ||
|
|
1e9c4d04f1 | ||
|
|
9f817714fe | ||
|
|
091f2c6135 | ||
|
|
91de51290d | ||
|
|
68fa0466c8 | ||
|
|
28e303576c | ||
|
|
2d41b3e80d | ||
|
|
ffd2d26c1a | ||
|
|
a8dc6fc632 | ||
|
|
771cb4ebd7 | ||
|
|
2f694c0eb2 | ||
|
|
8dea347a21 | ||
|
|
0cf3e8ed40 | ||
|
|
9d3bc7d9e6 | ||
|
|
e0427bdc77 | ||
|
|
9cf1338dc4 | ||
|
|
4e30ee8d1c | ||
|
|
cca6a5fe12 | ||
|
|
9e4b7fca4d | ||
|
|
d135c58ead | ||
|
|
de194417d4 | ||
|
|
d01ce3173f | ||
|
|
010a54d1c9 | ||
|
|
f557fc94fa | ||
|
|
f02cd9c0f6 | ||
|
|
170516572e | ||
|
|
285e29d2dc | ||
|
|
aab34b2338 | ||
|
|
ad1e5330e9 | ||
|
|
ca4647ddd6 | ||
|
|
7004acae46 | ||
|
|
899dd46f5b | ||
|
|
dba5fea66f | ||
|
|
c457b0e7d3 | ||
|
|
c0da4a6645 | ||
|
|
9d8df6a226 | ||
|
|
02da7132e7 | ||
|
|
e3b6e4eaf0 | ||
|
|
0388d00ad3 | ||
|
|
1e2d2c5146 | ||
|
|
96797742f2 | ||
|
|
682e347be0 | ||
|
|
b3f43f55c1 | ||
|
|
016a0b1141 | ||
|
|
fd7bcf54bd | ||
|
|
db3f5fe816 | ||
|
|
0d3016fcd8 | ||
|
|
1abc897c45 | ||
|
|
ab64a32f30 | ||
|
|
52b66e71d1 | ||
|
|
41ab5bbdd8 | ||
|
|
732b250815 | ||
|
|
157dbdc543 | ||
|
|
6ba23683d5 | ||
|
|
80a5865db3 | ||
|
|
9cb6f38aea | ||
|
|
cd7e3e4505 | ||
|
|
1833cb0655 | ||
|
|
e4208aa9cf | ||
|
|
bb3501a4f9 | ||
|
|
4abdb2e08b | ||
|
|
d49ae493b2 | ||
|
|
394079833e | ||
|
|
12d6789c2e | ||
|
|
34803f8e9b | ||
|
|
fd43184406 | ||
|
|
3cc3315081 | ||
|
|
6aee70fa18 | ||
|
|
82a9fd1540 | ||
|
|
dc760d6ca8 | ||
|
|
eb239501bc | ||
|
|
0016033937 | ||
|
|
50c62d5eac | ||
|
|
91ac48912e | ||
|
|
272a6604cd | ||
|
|
8a889d3ebb | ||
|
|
17a3f2ae52 | ||
|
|
6c3515588f | ||
|
|
4d2d2118a2 | ||
|
|
483fe80308 | ||
|
|
34ceeac36e | ||
|
|
20f11018ce | ||
|
|
9345fb754a | ||
|
|
779b7aaf02 | ||
|
|
b268aa1061 | ||
|
|
40f86fa639 | ||
|
|
980bc11e68 | ||
|
|
85db677982 | ||
|
|
2842315b1d | ||
|
|
6c541f7bfd | ||
|
|
067fab2b73 | ||
|
|
de6bf9dc7e | ||
|
|
54eae37038 | ||
|
|
0118e16132 | ||
|
|
626a091f55 | ||
|
|
4fa5e99e65 | ||
|
|
5ee9dcf42d | ||
|
|
6306f83316 | ||
|
|
96075fdf49 | ||
|
|
8c6dcf53a6 | ||
|
|
e1b1a927b8 | ||
|
|
1e6bfa7bb1 | ||
|
|
79ef4c4501 | ||
|
|
5f3ceef592 | ||
|
|
1a90e6b6c7 | ||
|
|
f112d097dc | ||
|
|
45cab7f808 | ||
|
|
216ec9d52b | ||
|
|
56a8f6b97b | ||
|
|
c76d10a438 | ||
|
|
f05f2178e5 | ||
|
|
226d7417b2 | ||
|
|
b0c8e65c6e | ||
|
|
4ae577c3c4 | ||
|
|
204e81a700 | ||
|
|
1f35830570 | ||
|
|
6b334f2977 | ||
|
|
0dc3c12aa5 | ||
|
|
ceffcce20e | ||
|
|
e4b06dadf5 | ||
|
|
087eb55299 | ||
|
|
341eb0c671 | ||
|
|
43b39102a4 | ||
|
|
be4bbd018d | ||
|
|
21a7cef98a | ||
|
|
a6724b1c07 | ||
|
|
7437593ee7 | ||
|
|
f21829b075 | ||
|
|
b4f60e6057 | ||
|
|
b9ebddff0c | ||
|
|
a2243484a3 | ||
|
|
c4a9835ae5 | ||
|
|
92ad279324 | ||
|
|
7276025cf9 | ||
|
|
9808d585cf | ||
|
|
dab9ed711c | ||
|
|
b27a93fc77 | ||
|
|
e3f66973b7 | ||
|
|
21529d6ca2 | ||
|
|
775b0a3c93 | ||
|
|
070d4d029f | ||
|
|
5355702e9c | ||
|
|
a97997952d | ||
|
|
b0c12bd86b | ||
|
|
82fcab26b1 | ||
|
|
f3bbcc4f55 | ||
|
|
98362eaca0 | ||
|
|
998b75856d | ||
|
|
3a9084006f | ||
|
|
4d3ccc62e8 | ||
|
|
8fe10d61ea | ||
|
|
5a183490dc | ||
|
|
9dd4fa8646 | ||
|
|
a05b619563 | ||
|
|
7f9506b476 | ||
|
|
f65952cf1c | ||
|
|
7ac03ffefc | ||
|
|
f6d6c3afb5 | ||
|
|
419703d2ea | ||
|
|
9301edef06 | ||
|
|
7e3f0a607b | ||
|
|
dec0e7c56d | ||
|
|
0758e10ae2 | ||
|
|
19ae785fa2 | ||
|
|
918ce865ca | ||
|
|
d27a21feee | ||
|
|
d8932b69a3 | ||
|
|
5af580f44d | ||
|
|
3384eda8b7 | ||
|
|
969ea28d06 | ||
|
|
5b2101e17d | ||
|
|
ec2d7f0519 | ||
|
|
656ce93d6e | ||
|
|
b69e871f9a | ||
|
|
bba57069a8 | ||
|
|
6a701f1420 | ||
|
|
eba847e62e | ||
|
|
b80eb2dc6c | ||
|
|
1f9689dc00 | ||
|
|
84eb75d5b6 | ||
|
|
4f2aea65ab | ||
|
|
d6463f95b9 | ||
|
|
3e0688ab63 | ||
|
|
692e90f779 | ||
|
|
e4faedcb62 | ||
|
|
a32d36a97b | ||
|
|
da2c678fb3 | ||
|
|
7bdfa121f3 | ||
|
|
b9a1369c6f | ||
|
|
0112b3387e | ||
|
|
de9d86621d | ||
|
|
735862d1fd | ||
|
|
a0537759b1 | ||
|
|
a79776c1c4 | ||
|
|
822b6d1baf | ||
|
|
0065085ba2 | ||
|
|
4f4da20fc0 | ||
|
|
eb0174ea53 | ||
|
|
20ce626654 | ||
|
|
a342941ec1 | ||
|
|
a78a803a22 | ||
|
|
23754630e8 | ||
|
|
8e6e91eb4a | ||
|
|
9cfa551163 | ||
|
|
5b21441898 | ||
|
|
4ed8696d1d | ||
|
|
ae06f27372 | ||
|
|
33e1493932 | ||
|
|
22b1dcaf7b | ||
|
|
426a68775f | ||
|
|
3c0be4e40e | ||
|
|
3787b45b49 |
@@ -1,56 +0,0 @@
|
||||
You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to analyze and improve the instructions for Claude Code.
|
||||
Follow these steps carefully:
|
||||
|
||||
1. Analysis Phase:
|
||||
Review the chat history in your context window.
|
||||
|
||||
Then, examine the current Claude instructions, commands and config
|
||||
<claude_instructions>
|
||||
/CLAUDE.md
|
||||
/.claude/commands/*
|
||||
**/CLAUDE.md
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
</claude_instructions>
|
||||
|
||||
Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for:
|
||||
- Inconsistencies in Claude's responses
|
||||
- Misunderstandings of user requests
|
||||
- Areas where Claude could provide more detailed or accurate information
|
||||
- Opportunities to enhance Claude's ability to handle specific types of queries or tasks
|
||||
- New commands or improvements to a commands name, function or response
|
||||
- Permissions and MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them for the command to work
|
||||
|
||||
2. Interaction Phase:
|
||||
Present your findings and improvement ideas to the human. For each suggestion:
|
||||
a) Explain the current issue you've identified
|
||||
b) Propose a specific change or addition to the instructions
|
||||
c) Describe how this change would improve Claude's performance
|
||||
|
||||
Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the implementation phase. If not, refine your suggestion or move on to the next idea.
|
||||
|
||||
3. Implementation Phase:
|
||||
For each approved change:
|
||||
a) Clearly state the section of the instructions you're modifying
|
||||
b) Present the new or modified text for that section
|
||||
c) Explain how this change addresses the issue identified in the analysis phase
|
||||
|
||||
4. Output Format:
|
||||
Present your final output in the following structure:
|
||||
|
||||
<analysis>
|
||||
[List the issues identified and potential improvements]
|
||||
</analysis>
|
||||
|
||||
<improvements>
|
||||
[For each approved improvement:
|
||||
1. Section being modified
|
||||
2. New or modified instruction text
|
||||
3. Explanation of how this addresses the identified issue]
|
||||
</improvements>
|
||||
|
||||
<final_instructions>
|
||||
[Present the complete, updated set of instructions for Claude, incorporating all approved changes]
|
||||
</final_instructions>
|
||||
|
||||
Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your implementations.
|
||||
12
.github/workflows/bridge.yml
vendored
12
.github/workflows/bridge.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -49,25 +49,25 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: bridge-${{ matrix.job.os }}
|
||||
|
||||
- name: Cache Bridge
|
||||
id: cache-bridge
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||
with:
|
||||
path: /tmp/flutter_rust_bridge
|
||||
key: vcpkg-${{ matrix.job.arch }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: |
|
||||
|
||||
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@@ -29,13 +29,13 @@ jobs:
|
||||
# name: Ensure 'cargo fmt' has been run
|
||||
# runs-on: ubuntu-20.04
|
||||
# steps:
|
||||
# - uses: actions-rs/toolchain@v1
|
||||
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# with:
|
||||
# toolchain: stable
|
||||
# default: true
|
||||
# profile: minimal
|
||||
# components: rustfmt
|
||||
# - uses: actions/checkout@v3
|
||||
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# - run: cargo fmt -- --check
|
||||
|
||||
# min_version:
|
||||
@@ -43,24 +43,24 @@ jobs:
|
||||
# runs-on: ubuntu-20.04
|
||||
# steps:
|
||||
# - name: Checkout source code
|
||||
# uses: actions/checkout@v3
|
||||
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# with:
|
||||
# submodules: recursive
|
||||
|
||||
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
||||
# uses: actions-rs/toolchain@v1
|
||||
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# with:
|
||||
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
||||
# default: true
|
||||
# profile: minimal # minimal component installation (ie, no documentation)
|
||||
# components: clippy
|
||||
# - 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:
|
||||
# command: clippy
|
||||
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
||||
# - name: Run tests
|
||||
# uses: actions-rs/cargo@v1
|
||||
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
# with:
|
||||
# command: test
|
||||
# args: --locked
|
||||
@@ -84,15 +84,29 @@ jobs:
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
# 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.
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -131,7 +145,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -142,7 +156,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -158,10 +172,10 @@ jobs:
|
||||
cargo -V
|
||||
rustc -V
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
with:
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
command: build
|
||||
@@ -229,7 +243,7 @@ jobs:
|
||||
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
with:
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
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
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
console.log("About to clear")
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
console.log("Clear completed")
|
||||
|
||||
- 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:
|
||||
accessed: true # Purge caches by their last accessed 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
|
||||
|
||||
- name: Publish RustDesk version file
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: "fdroid-version"
|
||||
|
||||
250
.github/workflows/flutter-build.yml
vendored
250
.github/workflows/flutter-build.yml
vendored
@@ -39,13 +39,13 @@ env:
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.4"
|
||||
NDK_VERSION: "r27c"
|
||||
VERSION: "1.4.7"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
|
||||
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
|
||||
|
||||
jobs:
|
||||
generate-bridge:
|
||||
@@ -81,30 +81,30 @@ jobs:
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: KyleMayes/install-llvm-action@v1
|
||||
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- 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:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -126,18 +126,18 @@ jobs:
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download RustDeskTempTopMostWindow artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
@@ -228,17 +228,17 @@ jobs:
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: rustdesk
|
||||
|
||||
- name: Sign rustdesk files
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
pip3 install requests argparse
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
|
||||
|
||||
- name: Build self-extracted executable
|
||||
shell: bash
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
|
||||
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Build msi
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -266,13 +266,13 @@ jobs:
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
BASE_URL=${{ secrets.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
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
@@ -302,35 +302,35 @@ jobs:
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- 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:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}-sciter
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -363,7 +363,8 @@ jobs:
|
||||
python3 res/inline-sciter.py
|
||||
# Patch sciter x86
|
||||
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
|
||||
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
|
||||
@@ -394,17 +395,17 @@ jobs:
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: Release
|
||||
|
||||
- name: Sign rustdesk files
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
pip3 install requests argparse
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
|
||||
|
||||
- name: Build self-extracted executable
|
||||
shell: bash
|
||||
@@ -418,13 +419,13 @@ jobs:
|
||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
BASE_URL=${{ secrets.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
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
@@ -444,12 +445,12 @@ jobs:
|
||||
- {
|
||||
arch: aarch64,
|
||||
target: aarch64-apple-ios,
|
||||
os: macos-13,
|
||||
os: macos-latest,
|
||||
vcpkg-triplet: arm64-ios,
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -459,12 +460,12 @@ jobs:
|
||||
run: |
|
||||
brew install nasm yasm
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -475,7 +476,7 @@ jobs:
|
||||
[[ "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
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -499,19 +500,19 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-ios
|
||||
key: ${{ matrix.job.target }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
@@ -519,10 +520,10 @@ jobs:
|
||||
- name: Build rustdesk lib
|
||||
run: |
|
||||
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
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: liblibrustdesk.a
|
||||
path: target/aarch64-apple-ios/release/liblibrustdesk.a
|
||||
@@ -537,14 +538,14 @@ jobs:
|
||||
|
||||
# - name: Upload Artifacts
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: actions/upload-artifact@master
|
||||
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# with:
|
||||
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
# path: flutter/build/ios/ipa/*.ipa
|
||||
|
||||
# - name: Publish ipa package
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: softprops/action-gh-release@v1
|
||||
# uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
# with:
|
||||
# prerelease: true
|
||||
# tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -562,7 +563,7 @@ jobs:
|
||||
job:
|
||||
- {
|
||||
target: x86_64-apple-darwin,
|
||||
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
|
||||
os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
|
||||
extra-build-args: "",
|
||||
arch: x86_64,
|
||||
vcpkg-triplet: x64-osx,
|
||||
@@ -577,20 +578,20 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||
@@ -604,7 +605,7 @@ jobs:
|
||||
|
||||
- name: Import notarize key
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
with:
|
||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||
fileName: rustdesk.json
|
||||
@@ -623,7 +624,7 @@ jobs:
|
||||
|
||||
- name: Install build runtime
|
||||
run: |
|
||||
brew install llvm create-dmg nasm
|
||||
brew install llvm create-dmg
|
||||
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
|
||||
if command -v pkg-config &>/dev/null; then
|
||||
echo "pkg-config is already installed"
|
||||
@@ -631,8 +632,19 @@ jobs:
|
||||
brew install pkg-config
|
||||
fi
|
||||
|
||||
- name: Install NASM
|
||||
run: |
|
||||
# Install NASM 2.16.x from official release.
|
||||
# Do NOT use `brew install nasm` which installs NASM 3.x.
|
||||
# NASM 3.x is a complete rewrite with incompatible CLI options and removed features.
|
||||
# aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly.
|
||||
wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip
|
||||
unzip nasm-2.16.03-macosx.zip
|
||||
sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm
|
||||
nasm --version
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -651,24 +663,24 @@ jobs:
|
||||
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.MAC_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -720,7 +732,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned macOS app
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
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
|
||||
@@ -752,7 +764,7 @@ jobs:
|
||||
|
||||
- name: Publish DMG package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -768,25 +780,25 @@ jobs:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-x86_64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-aarch64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
@@ -796,7 +808,7 @@ jobs:
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -833,7 +845,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -844,7 +856,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -886,12 +898,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -901,14 +913,14 @@ jobs:
|
||||
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
|
||||
|
||||
- uses: nttld/setup-ndk@v1
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -943,18 +955,18 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
||||
key: ${{ matrix.job.target }}
|
||||
@@ -990,7 +1002,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Upload Rustdesk library to Artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: librustdesk.so.${{ matrix.job.target }}
|
||||
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
|
||||
@@ -1055,7 +1067,7 @@ jobs:
|
||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
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
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1071,14 +1083,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||
|
||||
- name: Publish signed apk package
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1087,7 +1099,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1105,7 +1117,7 @@ jobs:
|
||||
suffix: ""
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -1116,7 +1128,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1158,12 +1170,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -1174,32 +1186,32 @@ jobs:
|
||||
[[ "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
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.aarch64-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.armv7-linux-androideabi
|
||||
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.x86_64-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86_64
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
if: ${{ env.reltype == 'debug' }}
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.i686-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86
|
||||
@@ -1239,7 +1251,7 @@ jobs:
|
||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
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
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1255,14 +1267,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||
|
||||
- name: Publish signed apk package
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1271,7 +1283,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1305,7 +1317,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1323,13 +1335,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@master
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
@@ -1339,7 +1351,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- 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'
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
@@ -1358,14 +1370,14 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -1393,12 +1405,12 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
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
|
||||
id: vcpkg
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -1480,7 +1492,7 @@ jobs:
|
||||
export JOBS=""
|
||||
fi
|
||||
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 ~/.cargo
|
||||
|
||||
@@ -1572,7 +1584,7 @@ jobs:
|
||||
|
||||
- name: Publish debian/rpm package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1581,7 +1593,7 @@ jobs:
|
||||
rustdesk-*.rpm
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
@@ -1600,7 +1612,7 @@ jobs:
|
||||
|
||||
- name: Build archlinux package
|
||||
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:
|
||||
packages:
|
||||
scripts: |
|
||||
@@ -1608,7 +1620,7 @@ jobs:
|
||||
|
||||
- name: Publish archlinux package
|
||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1646,14 +1658,14 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -1671,7 +1683,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -1682,7 +1694,7 @@ jobs:
|
||||
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
||||
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 }}
|
||||
id: vcpkg
|
||||
with:
|
||||
@@ -1810,7 +1822,7 @@ jobs:
|
||||
# build rustdesk
|
||||
python3 ./res/inline-sciter.py
|
||||
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
|
||||
mkdir -p ./Release
|
||||
mv ./target/release/rustdesk ./Release/rustdesk
|
||||
@@ -1828,7 +1840,7 @@ jobs:
|
||||
|
||||
- name: Publish debian package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1836,7 +1848,7 @@ jobs:
|
||||
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
@@ -1855,12 +1867,12 @@ jobs:
|
||||
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
path: .
|
||||
@@ -1885,7 +1897,7 @@ jobs:
|
||||
|
||||
- name: Publish appimage package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1928,12 +1940,12 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
|
||||
path: .
|
||||
@@ -1942,7 +1954,7 @@ jobs:
|
||||
run: |
|
||||
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 }}
|
||||
id: flatpak
|
||||
with:
|
||||
@@ -1970,7 +1982,7 @@ jobs:
|
||||
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
|
||||
|
||||
- name: Publish flatpak package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1989,7 +2001,7 @@ jobs:
|
||||
RELEASE_NAME: web-basic
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -1999,7 +2011,7 @@ jobs:
|
||||
sudo apt-get install -y wget npm
|
||||
|
||||
- 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:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -2043,7 +2055,7 @@ jobs:
|
||||
|
||||
- name: Publish web
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
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"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.4"
|
||||
VERSION: "1.4.7"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -79,21 +79,21 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Import notarize key
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
with:
|
||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||
fileName: rustdesk.json
|
||||
@@ -129,19 +129,19 @@ jobs:
|
||||
brew install llvm create-dmg nasm pkg-config
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ matrix.job.flutter }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||
|
||||
- name: Restore from cache and install vcpkg
|
||||
uses: lukka/run-vcpkg@v7
|
||||
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
|
||||
if: false
|
||||
with:
|
||||
setupOnly: true
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Publish DMG package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
@@ -290,13 +290,13 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: "rustfmt"
|
||||
@@ -310,14 +310,14 @@ jobs:
|
||||
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
|
||||
|
||||
- uses: nttld/setup-ndk@v1
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
mkdir -p signed-apk; pushd signed-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
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -410,7 +410,7 @@ jobs:
|
||||
BUILD_TOOLS_VERSION: "30.0.2"
|
||||
|
||||
- name: Publish signed apk package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
||||
steps:
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Download the source code
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||
|
||||
- name: Archive build artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
|
||||
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
|
||||
15
.github/workflows/winget.yml
vendored
15
.github/workflows/winget.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: RustDesk.RustDesk
|
||||
version: "1.4.4"
|
||||
release-tag: "1.4.4"
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
.env
|
||||
libsciter-gtk.so
|
||||
src/ui/inline.rs
|
||||
extractor
|
||||
|
||||
86
AGENTS.md
Normal file
86
AGENTS.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# RustDesk Guide
|
||||
|
||||
## Project Layout
|
||||
|
||||
### Directory Structure
|
||||
* `src/` Rust app
|
||||
* `src/server/` audio / clipboard / input / video / network
|
||||
* `src/platform/` platform-specific code
|
||||
* `src/ui/` legacy Sciter UI (deprecated)
|
||||
* `flutter/` current UI
|
||||
* `libs/hbb_common/` config / proto / shared utils
|
||||
* `libs/scrap/` screen capture
|
||||
* `libs/enigo/` input control
|
||||
* `libs/clipboard/` clipboard
|
||||
* `libs/hbb_common/src/config.rs` all options
|
||||
|
||||
### Key Components
|
||||
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
|
||||
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
|
||||
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
|
||||
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
|
||||
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
|
||||
|
||||
### UI Architecture
|
||||
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
|
||||
- **Modern UI**: Flutter-based - files in `flutter/`
|
||||
- Desktop: `flutter/lib/desktop/`
|
||||
- Mobile: `flutter/lib/mobile/`
|
||||
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
|
||||
|
||||
## Rust Rules
|
||||
|
||||
* Avoid `unwrap()` / `expect()` in production code.
|
||||
* Exceptions:
|
||||
|
||||
* tests;
|
||||
* lock acquisition where failure means poisoning, not normal control flow.
|
||||
* Otherwise prefer `Result` + `?` or explicit handling.
|
||||
* Do not ignore errors silently.
|
||||
* Avoid unnecessary `.clone()`.
|
||||
* Prefer borrowing when practical.
|
||||
* Do not add dependencies unless needed.
|
||||
* Keep code simple and idiomatic.
|
||||
|
||||
## Tokio Rules
|
||||
|
||||
* Assume a Tokio runtime already exists.
|
||||
* Never create nested runtimes.
|
||||
* Never call `Runtime::block_on()` inside Tokio / async code.
|
||||
* Do not hide runtime creation inside helpers or libraries.
|
||||
* Do not hold locks across `.await`.
|
||||
* Prefer `.await`, `tokio::spawn`, channels.
|
||||
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||
* Do not use `std::thread::sleep()` in async code.
|
||||
|
||||
## Editing Hygiene
|
||||
|
||||
* Change only what is required.
|
||||
* Prefer the smallest valid diff.
|
||||
* Do not refactor unrelated code.
|
||||
* Do not make formatting-only changes.
|
||||
* 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`.
|
||||
92
CLAUDE.md
92
CLAUDE.md
@@ -1,91 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build Commands
|
||||
- `cargo run` - Build and run the desktop application (requires libsciter library)
|
||||
- `python3 build.py --flutter` - Build Flutter version (desktop)
|
||||
- `python3 build.py --flutter --release` - Build Flutter version in release mode
|
||||
- `python3 build.py --hwcodec` - Build with hardware codec support
|
||||
- `python3 build.py --vram` - Build with VRAM feature (Windows only)
|
||||
- `cargo build --release` - Build Rust binary in release mode
|
||||
- `cargo build --features hwcodec` - Build with specific features
|
||||
|
||||
### Flutter Mobile Commands
|
||||
- `cd flutter && flutter build android` - Build Android APK
|
||||
- `cd flutter && flutter build ios` - Build iOS app
|
||||
- `cd flutter && flutter run` - Run Flutter app in development mode
|
||||
- `cd flutter && flutter test` - Run Flutter tests
|
||||
|
||||
### Testing
|
||||
- `cargo test` - Run Rust tests
|
||||
- `cd flutter && flutter test` - Run Flutter tests
|
||||
|
||||
### Platform-Specific Build Scripts
|
||||
- `flutter/build_android.sh` - Android build script
|
||||
- `flutter/build_ios.sh` - iOS build script
|
||||
- `flutter/build_fdroid.sh` - F-Droid build script
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Directory Structure
|
||||
- **`src/`** - Main Rust application code
|
||||
- `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead)
|
||||
- `src/server/` - Audio/clipboard/input/video services and network connections
|
||||
- `src/client.rs` - Peer connection handling
|
||||
- `src/platform/` - Platform-specific code
|
||||
- **`flutter/`** - Flutter UI code for desktop and mobile
|
||||
- **`libs/`** - Core libraries
|
||||
- `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities
|
||||
- `libs/scrap/` - Screen capture functionality
|
||||
- `libs/enigo/` - Platform-specific keyboard/mouse control
|
||||
- `libs/clipboard/` - Cross-platform clipboard implementation
|
||||
|
||||
### Key Components
|
||||
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
|
||||
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
|
||||
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
|
||||
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
|
||||
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
|
||||
|
||||
### UI Architecture
|
||||
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
|
||||
- **Modern UI**: Flutter-based - files in `flutter/`
|
||||
- Desktop: `flutter/lib/desktop/`
|
||||
- Mobile: `flutter/lib/mobile/`
|
||||
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
|
||||
|
||||
## Important Build Notes
|
||||
|
||||
### Dependencies
|
||||
- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom`
|
||||
- Set `VCPKG_ROOT` environment variable
|
||||
- Download appropriate Sciter library for legacy UI support
|
||||
|
||||
### Ignore Patterns
|
||||
When working with files, ignore these directories:
|
||||
- `target/` - Rust build artifacts
|
||||
- `flutter/build/` - Flutter build output
|
||||
- `flutter/.dart_tool/` - Flutter tooling files
|
||||
|
||||
### Cross-Platform Considerations
|
||||
- Windows builds require additional DLLs and virtual display drivers
|
||||
- macOS builds need proper signing and notarization for distribution
|
||||
- Linux builds support multiple package formats (deb, rpm, AppImage)
|
||||
- Mobile builds require platform-specific toolchains (Android SDK, Xcode)
|
||||
|
||||
### Feature Flags
|
||||
- `hwcodec` - Hardware video encoding/decoding
|
||||
- `vram` - VRAM optimization (Windows only)
|
||||
- `flutter` - Enable Flutter UI
|
||||
- `unix-file-copy-paste` - Unix file clipboard support
|
||||
- `screencapturekit` - macOS ScreenCaptureKit (macOS only)
|
||||
|
||||
### Config
|
||||
All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types:
|
||||
- Settings
|
||||
- Local
|
||||
- Display
|
||||
- Built-in
|
||||
AGENTS.md
|
||||
|
||||
1394
Cargo.lock
generated
1394
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.4"
|
||||
version = "1.4.7"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -76,7 +76,6 @@ crossbeam-queue = "0.3"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
cidr-utils = "0.5"
|
||||
libloading = "0.8"
|
||||
fon = "0.6"
|
||||
zip = "0.6"
|
||||
shutdown_hooks = "0.1"
|
||||
@@ -123,10 +122,19 @@ winapi = { version = "0.3", features = [
|
||||
] }
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Environment",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
@@ -152,7 +160,7 @@ piet-coregraphics = "0.6"
|
||||
foreign-types = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
tray-icon = { git = "https://github.com/tauri-apps/tray-icon" }
|
||||
tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" }
|
||||
tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" }
|
||||
image = "0.24"
|
||||
|
||||
@@ -168,6 +176,7 @@ bytemuck = "1.23"
|
||||
ttf-parser = "0.25"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libxdo-sys = "0.11"
|
||||
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
||||
pulse = { package = "libpulse-binding", version = "2.27" }
|
||||
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
||||
@@ -176,7 +185,6 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
||||
users = { version = "0.11" }
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
@@ -199,6 +207,11 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
|
||||
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
[patch.crates-io]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
@@ -232,3 +245,6 @@ panic = 'abort'
|
||||
strip = true
|
||||
#opt-level = 'z' # only have smaller size after strip
|
||||
rpath = true
|
||||
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.4
|
||||
version: 1.4.7
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.4
|
||||
version: 1.4.7
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
26
build.py
26
build.py
@@ -172,7 +172,7 @@ def generate_build_script_for_docker():
|
||||
# flutter_rust_bridge
|
||||
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/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
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||
# install vcpkg
|
||||
@@ -299,7 +299,7 @@ Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <info@rustdesk.com>
|
||||
Homepage: https://rustdesk.com
|
||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3, 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
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -317,7 +317,7 @@ def ffi_bindgen_function_refactor():
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
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()
|
||||
os.chdir('flutter')
|
||||
system2('flutter build linux --release')
|
||||
@@ -405,7 +405,7 @@ def build_flutter_dmg(version, features):
|
||||
if not skip_cargo:
|
||||
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
||||
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
|
||||
system2(
|
||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||
@@ -422,7 +422,7 @@ def build_flutter_dmg(version, features):
|
||||
|
||||
def build_flutter_arch_manjaro(version, features):
|
||||
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()
|
||||
os.chdir('flutter')
|
||||
system2('flutter build linux --release')
|
||||
@@ -433,7 +433,7 @@ def build_flutter_arch_manjaro(version, features):
|
||||
|
||||
def build_flutter_windows(version, features, skip_portable_pack):
|
||||
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"):
|
||||
print("cargo build failed, please check rust source code.")
|
||||
exit(-1)
|
||||
@@ -489,13 +489,13 @@ def main():
|
||||
if windows:
|
||||
# build virtual display dynamic library
|
||||
os.chdir('libs/virtual_display/dylib')
|
||||
system2('cargo build --release')
|
||||
system2('cargo build --locked --release')
|
||||
os.chdir('../../..')
|
||||
|
||||
if flutter:
|
||||
build_flutter_windows(version, features, args.skip_portable_pack)
|
||||
return
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
# system2('upx.exe target/release/rustdesk.exe')
|
||||
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
||||
pa = os.environ.get('P')
|
||||
@@ -512,14 +512,14 @@ def main():
|
||||
system2('pip3 install -r requirements.txt')
|
||||
system2(
|
||||
f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe')
|
||||
system2('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
|
||||
system2(f'mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
|
||||
elif os.path.isfile('/usr/bin/pacman'):
|
||||
# pacman -S -needed base-devel
|
||||
system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version)
|
||||
if flutter:
|
||||
build_flutter_arch_manjaro(version, features)
|
||||
else:
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('git checkout src/ui/common.tis')
|
||||
system2('strip target/release/rustdesk')
|
||||
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
||||
@@ -528,7 +528,7 @@ def main():
|
||||
version, version))
|
||||
# pacman -U ./rustdesk.pkg.tar.zst
|
||||
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(
|
||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
||||
@@ -538,7 +538,7 @@ def main():
|
||||
version, version))
|
||||
# yum localinstall rustdesk.rpm
|
||||
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(
|
||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
||||
@@ -557,7 +557,7 @@ def main():
|
||||
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
||||
build_flutter_deb(version, features)
|
||||
else:
|
||||
system2('cargo bundle --release --features ' + features)
|
||||
system2('cargo --locked bundle --release --features ' + features)
|
||||
if osx:
|
||||
system2(
|
||||
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
||||
|
||||
2
build.rs
2
build.rs
@@ -18,7 +18,7 @@ fn build_mac() {
|
||||
b.flag("-DNO_InputMonitoringAuthStatus=1");
|
||||
}
|
||||
}
|
||||
b.file(file).compile("macos");
|
||||
b.flag("-std=c++17").file(file).compile("macos");
|
||||
println!("cargo:rerun-if-changed={}", file);
|
||||
}
|
||||
|
||||
|
||||
143
docs/CODE_OF_CONDUCT-FR.md
Normal file
143
docs/CODE_OF_CONDUCT-FR.md
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
# Code de conduite des contributeurs
|
||||
|
||||
## Notre engagement
|
||||
|
||||
En tant que membres, contributeurs et responsables, nous nous engageons à faire
|
||||
de la participation à notre communauté une expérience exempte de harcèlement pour
|
||||
tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou
|
||||
invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité
|
||||
et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut
|
||||
socio-économique, de la nationalité, de l'apparence personnelle, de la race, de
|
||||
la religion ou de l'identité et de l'orientation sexuelle.
|
||||
|
||||
Nous nous engageons à agir et à interagir de manière à contribuer à une
|
||||
communauté ouverte, accueillante, diversifiée, inclusive et saine.
|
||||
|
||||
## Nos standards
|
||||
|
||||
Exemples de comportements qui contribuent à un environnement positif pour notre
|
||||
communauté :
|
||||
|
||||
* Faire preuve d'empathie et de bienveillance envers les autres
|
||||
* Respecter les opinions, les points de vue et les expériences différents
|
||||
* Donner et accepter gracieusement les retours constructifs
|
||||
* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos
|
||||
erreurs et apprendre de l'expérience
|
||||
* Se concentrer sur ce qui est le mieux non seulement pour nous en tant
|
||||
qu'individus, mais pour l'ensemble de la communauté
|
||||
|
||||
Exemples de comportements inacceptables :
|
||||
|
||||
* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou
|
||||
avances sexuelles de quelque nature que ce soit
|
||||
* Le trolling, les commentaires insultants ou désobligeants, et les attaques
|
||||
personnelles ou politiques
|
||||
* Le harcèlement public ou privé
|
||||
* La publication d'informations privées d'autrui, telles qu'une adresse physique
|
||||
ou électronique, sans autorisation explicite
|
||||
* Tout autre comportement qui pourrait raisonnablement être considéré comme
|
||||
inapproprié dans un cadre professionnel
|
||||
|
||||
## Responsabilités en matière d'application
|
||||
|
||||
Les responsables de la communauté sont chargés de clarifier et d'appliquer nos
|
||||
standards de comportement acceptable et prendront des mesures correctives
|
||||
appropriées et équitables en réponse à tout comportement qu'ils jugent
|
||||
inapproprié, menaçant, offensant ou nuisible.
|
||||
|
||||
Les responsables de la communauté ont le droit et la responsabilité de
|
||||
supprimer, modifier ou rejeter les commentaires, commits, code, modifications
|
||||
du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de
|
||||
conduite, et communiqueront les raisons de leurs décisions de modération le cas
|
||||
échéant.
|
||||
|
||||
## Portée
|
||||
|
||||
Ce Code de conduite s'applique dans tous les espaces communautaires, et
|
||||
s'applique également lorsqu'une personne représente officiellement la communauté
|
||||
dans les espaces publics. Les exemples de représentation de notre communauté
|
||||
incluent l'utilisation d'une adresse e-mail officielle, la publication via un
|
||||
compte de réseau social officiel, ou le fait d'agir en tant que représentant
|
||||
désigné lors d'un événement en ligne ou hors ligne.
|
||||
|
||||
## Application
|
||||
|
||||
Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent
|
||||
être signalés aux responsables de la communauté chargés de l'application à
|
||||
[info@rustdesk.com](mailto:info@rustdesk.com).
|
||||
Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et
|
||||
équitable.
|
||||
|
||||
Tous les responsables de la communauté sont tenus de respecter la vie privée et
|
||||
la sécurité de la personne ayant signalé un incident.
|
||||
|
||||
## Directives d'application
|
||||
|
||||
Les responsables de la communauté suivront ces Directives d'impact communautaire
|
||||
pour déterminer les conséquences de toute action qu'ils jugent en violation de ce
|
||||
Code de conduite :
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Impact communautaire** : Utilisation d'un langage inapproprié ou autre
|
||||
comportement jugé non professionnel ou indésirable dans la communauté.
|
||||
|
||||
**Conséquence** : Un avertissement écrit et privé de la part des responsables de
|
||||
la communauté, expliquant la nature de la violation et pourquoi le comportement
|
||||
était inapproprié. Des excuses publiques peuvent être demandées.
|
||||
|
||||
### 2. Avertissement
|
||||
|
||||
**Impact communautaire** : Une violation par un incident isolé ou une série
|
||||
d'actions.
|
||||
|
||||
**Conséquence** : Un avertissement avec des conséquences en cas de comportement
|
||||
répété. Aucune interaction avec les personnes impliquées, y compris les
|
||||
interactions non sollicitées avec les personnes chargées d'appliquer le Code de
|
||||
conduite, pendant une période déterminée. Cela inclut d'éviter les interactions
|
||||
dans les espaces communautaires ainsi que dans les canaux externes comme les
|
||||
réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion
|
||||
temporaire ou permanente.
|
||||
|
||||
### 3. Exclusion temporaire
|
||||
|
||||
**Impact communautaire** : Une violation grave des standards communautaires, y
|
||||
compris un comportement inapproprié persistant.
|
||||
|
||||
**Conséquence** : Une exclusion temporaire de toute interaction ou communication
|
||||
publique avec la communauté pendant une période déterminée. Aucune interaction
|
||||
publique ou privée avec les personnes impliquées, y compris les interactions non
|
||||
sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est
|
||||
autorisée pendant cette période. Le non-respect de ces conditions peut entraîner
|
||||
une exclusion permanente.
|
||||
|
||||
### 4. Exclusion permanente
|
||||
|
||||
**Impact communautaire** : Démontrer un schéma de violation des standards
|
||||
communautaires, y compris un comportement inapproprié persistant, le harcèlement
|
||||
d'une personne, ou une agression envers des catégories de personnes ou leur
|
||||
dénigrement.
|
||||
|
||||
**Conséquence** : Une exclusion permanente de toute interaction publique au sein
|
||||
de la communauté.
|
||||
|
||||
## Attribution
|
||||
|
||||
Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0,
|
||||
disponible à l'adresse
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Les Directives d'impact communautaire ont été inspirées par
|
||||
[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC].
|
||||
|
||||
Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la
|
||||
FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions
|
||||
sont disponibles à l'adresse
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
55
docs/CONTRIBUTING-FR.md
Normal file
55
docs/CONTRIBUTING-FR.md
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
# Contribuer à RustDesk
|
||||
|
||||
RustDesk accueille les contributions de tous. Voici les directives si vous
|
||||
envisagez de nous aider :
|
||||
|
||||
## Contributions
|
||||
|
||||
Les contributions à RustDesk ou à ses dépendances doivent être soumises sous
|
||||
forme de pull requests GitHub. Chaque pull request sera examinée par un
|
||||
contributeur principal (une personne ayant la permission d'intégrer des
|
||||
correctifs) et sera soit intégrée dans la branche principale, soit accompagnée
|
||||
de retours sur les modifications requises. Toutes les contributions doivent
|
||||
suivre ce format, même celles des contributeurs principaux.
|
||||
|
||||
Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en
|
||||
commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela
|
||||
permet d'éviter les efforts en double de la part des contributeurs sur la même
|
||||
issue.
|
||||
|
||||
## Liste de vérification pour les pull requests
|
||||
|
||||
- Partez de la branche master et, si nécessaire, effectuez un rebase sur la
|
||||
branche master actuelle avant de soumettre votre pull request. Si elle ne
|
||||
fusionne pas proprement avec master, il vous sera peut-être demandé de
|
||||
rebaser vos modifications.
|
||||
|
||||
- Les commits doivent être aussi petits que possible, tout en s'assurant que
|
||||
chaque commit est correct de manière indépendante (c.-à-d. que chaque commit
|
||||
doit compiler et passer les tests).
|
||||
|
||||
- Les commits doivent être accompagnés d'une signature Developer Certificate of
|
||||
Origin (http://developercertificate.org), indiquant que vous (et votre
|
||||
employeur le cas échéant) acceptez d'être liés par les termes de la
|
||||
[licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de
|
||||
`git commit`.
|
||||
|
||||
- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne
|
||||
spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une
|
||||
revue dans la pull request ou un commentaire, ou vous pouvez demander une
|
||||
revue par [e-mail](mailto:info@rustdesk.com).
|
||||
|
||||
- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité.
|
||||
|
||||
Pour des instructions git spécifiques, consultez le
|
||||
[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||
|
||||
## Conduite
|
||||
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## Communication
|
||||
|
||||
Les contributeurs de RustDesk se retrouvent fréquemment sur
|
||||
[Discord](https://discord.gg/nDceKgxnkV).
|
||||
@@ -1,15 +1,14 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#freie-öffentliche-server">Server</a> •
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Dein Remote-Desktop"><br>
|
||||
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
||||
<a href="#auf-docker-kompilieren">Docker</a> •
|
||||
<a href="#dateistruktur">Dateistruktur</a> •
|
||||
<a href="#screenshots">Screenshots</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-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>]<br>
|
||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
||||
</p>
|
||||
|
||||
> [!Vorsicht]
|
||||
> [!Caution]
|
||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||
|
||||
@@ -28,11 +27,14 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE
|
||||
|
||||
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on 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="Get it on Flathub"
|
||||
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
@@ -64,18 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
||||
```sh
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
```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)
|
||||
@@ -114,7 +117,7 @@ cd
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
@@ -129,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
@@ -157,6 +161,7 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
|
||||
@@ -167,10 +172,11 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface
|
||||
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`.
|
||||
|
||||
- Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||
- Linux/Osx : vcpkg install libvpx libyuv opus aom
|
||||
- Linux/macOS : vcpkg install libvpx libyuv opus aom
|
||||
|
||||
- Exécuter `cargo run`
|
||||
- Exécutez `cargo run`
|
||||
|
||||
## Comment compiler/build sous Linux
|
||||
|
||||
@@ -93,7 +93,7 @@ cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
mv libsciter-gtk.so target/debug
|
||||
Exécution du cargo
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Comment construire avec Docker
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#빌드를 위한 원시 단계">빌드</a> •
|
||||
<a href="#Docker로 빌드하는 방법">Docker</a> •
|
||||
<a href="#파일 구조">구조</a> •
|
||||
<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="#빌드를_위한_원시_단계">빌드</a> •
|
||||
<a href="#Docker로_빌드하는_방법">Docker</a> •
|
||||
<a href="#파일_구조">구조</a> •
|
||||
<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>] | [<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>
|
||||
</p>
|
||||
|
||||
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
||||
[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)
|
||||
|
||||
## 빌드를 위한 원시 단계
|
||||
## 빌드를_위한_원시_단계
|
||||
|
||||
- Rust 개발 환경과 C++ 빌드 환경을 준비합니다
|
||||
- Rust 개발 환경과 C++ 빌드 환경 준비
|
||||
|
||||
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
||||
|
||||
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Docker로 빌드하는 방법
|
||||
## Docker로_빌드하는_방법
|
||||
|
||||
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
||||
|
||||
@@ -156,7 +156,7 @@ target/release/rustdesk
|
||||
|
||||
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
||||
|
||||
## 파일 구조
|
||||
## 파일_구조
|
||||
|
||||
- **[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)**: 화면 캡쳐
|
||||
|
||||
@@ -13,7 +13,9 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
|
||||
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
## O projekcie
|
||||
|
||||
RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||

|
||||
|
||||
@@ -31,7 +33,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C
|
||||
|
||||
## Zależności
|
||||
|
||||
Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę sciter.
|
||||
Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie.
|
||||
|
||||
[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) |
|
||||
|
||||
@@ -1,55 +1,82 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
||||
<a href="#servidores-públicos-grátis">Servidores</a> •
|
||||
<a href="#compilação-crua">Compilar</a> •
|
||||
<a href="#como-compilar-com-docker">Docker</a> •
|
||||
<a href="#compilar">Compilar</a> •
|
||||
<a href="#como-compilar-com-o-docker">Docker</a> •
|
||||
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
||||
<a href="#screenshots">Screenshots</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>
|
||||
<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>
|
||||
<a href="#capturas-de-tela">Capturas de Tela</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 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>
|
||||
|
||||
> [!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)
|
||||
|
||||
[](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
|
||||
|
||||
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) |
|
||||
[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
|
||||
- Linux/MacOS: vcpkg install libvpx libyuv opus aom
|
||||
- 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`
|
||||
|
||||
- Execute `cargo run`
|
||||
|
||||
## Como compilar no Linux
|
||||
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Como Compilar no Linux
|
||||
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```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)
|
||||
|
||||
```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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### Instale vcpkg
|
||||
### Instalar o vcpkg
|
||||
|
||||
```sh
|
||||
git clone https://github.com/microsoft/vcpkg
|
||||
@@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
|
||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||
```
|
||||
|
||||
### Conserte libvpx (Para o Fedora)
|
||||
### Corrigir o libvpx (Para Fedora)
|
||||
|
||||
```sh
|
||||
cd vcpkg/buildtrees/libvpx/src
|
||||
@@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||
cd
|
||||
```
|
||||
|
||||
### Compile
|
||||
### Compilar
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
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
|
||||
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
|
||||
target/debug/rustdesk
|
||||
```
|
||||
|
||||
Ou, se estiver rodando um executável de release:
|
||||
Ou, se estiver executando o executável de lançamento:
|
||||
|
||||
```sh
|
||||
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/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
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||
- **[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/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
|
||||
- **[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/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
|
||||
- **[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/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
|
||||
- **[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/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
|
||||
- **[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]
|
||||
> **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.
|
||||
## Capturas de Tela
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -167,7 +167,7 @@ target/release/rustdesk
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером Rustdesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
||||
|
||||
16
docs/SECURITY-FR.md
Normal file
16
docs/SECURITY-FR.md
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
# Politique de sécurité
|
||||
|
||||
## Signaler une vulnérabilité
|
||||
|
||||
Nous accordons une très grande importance à la sécurité du projet. Nous
|
||||
encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils
|
||||
découvrent.
|
||||
|
||||
Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez
|
||||
la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com.
|
||||
|
||||
À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite
|
||||
équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler
|
||||
toute vulnérabilité de manière responsable afin que nous puissions continuer à
|
||||
développer une application sécurisée pour l'ensemble de la communauté.
|
||||
@@ -33,4 +33,4 @@ if [ -z $release ]; then
|
||||
fi
|
||||
set -f
|
||||
#shellcheck disable=2086
|
||||
VCPKG_ROOT=/vcpkg cargo build $argv
|
||||
VCPKG_ROOT=/vcpkg cargo build --locked $argv
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
|
||||
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
|
||||
<li> P2P connection with end-to-end encryption based on NaCl. </li>
|
||||
<li> No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand. </li>
|
||||
<li> No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand. </li>
|
||||
<li> We like to keep things simple and will strive to make simpler where possible. </li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -56,4 +56,4 @@
|
||||
<control>pointing</control>
|
||||
</supports>
|
||||
<content_rating type="oars-1.1"/>
|
||||
</component>
|
||||
</component>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
],
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--share=network",
|
||||
"--filesystem=home",
|
||||
|
||||
@@ -62,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return false
|
||||
}
|
||||
}
|
||||
audioRecorder = builder.build()
|
||||
val recorder = try {
|
||||
builder.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "createAudioRecorder failed", e)
|
||||
return false
|
||||
}
|
||||
audioRecorder = recorder
|
||||
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -311,7 +311,10 @@ class FloatingWindowService : Service(), View.OnTouchListener {
|
||||
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
|
||||
}
|
||||
val idStopService = 2
|
||||
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
|
||||
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
|
||||
if (!hideStopService) {
|
||||
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
|
||||
}
|
||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
idShowRustDesk -> {
|
||||
@@ -389,4 +392,3 @@ class FloatingWindowService : Service(), View.OnTouchListener {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ object FFI {
|
||||
external fun setFrameRawEnable(name: String, value: Boolean)
|
||||
external fun setCodecInfo(info: String)
|
||||
external fun getLocalOption(key: String): String
|
||||
external fun getBuildinOption(key: String): String
|
||||
external fun onClipboardUpdate(clips: ByteBuffer)
|
||||
external fun isServiceClipboardEnabled(): Boolean
|
||||
}
|
||||
|
||||
1
flutter/assets/auth-microsoft.svg
Normal file
1
flutter/assets/auth-microsoft.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="4" width="19" height="19" fill="#f25022"/><rect x="25" y="4" width="19" height="19" fill="#7fba00"/><rect x="4" y="25" width="19" height="19" fill="#00a4ef"/><rect x="25" y="25" width="19" height="19" fill="#ffb900"/></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" style="isolation:isolate" viewBox="541.937 521.772 32 32"><path fill="none" d="M541.937 521.772h32v32h-32v-32Z"/><path fill-rule="evenodd" d="M552.145 539.981h11.584c.446 0 .808.362.808.808v.536c0 .786-.639 1.425-1.425 1.425h-10.35a1.426 1.426 0 0 1-1.425-1.425v-.536c0-.446.362-.808.808-.808Zm-1.761-3.511h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.972.972 0 0 1-.972-.971v-.899c0-.536.436-.971.972-.971Zm3.551 0h.9c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.9a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .972.435.972.971v.899a.972.972 0 0 1-.972.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm-14.383-3.512h1.25c.44 0 .796.357.796.796v1.25a.796.796 0 0 1-.796.796h-1.25a.796.796 0 0 1-.795-.796v-1.25c0-.439.356-.796.795-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm-9.553-3.85h13.252c1.407 0 2.755.507 3.748 1.409.993.902 1.552 2.127 1.552 3.404v7.702c0 1.277-.559 2.501-1.552 3.403-.993.902-2.341 1.409-3.748 1.409h-13.252c-1.407 0-2.755-.507-3.748-1.409-.993-.902-1.552-2.126-1.552-3.403v-7.702c0-1.277.559-2.502 1.552-3.404.993-.902 2.341-1.409 3.748-1.409Zm13.105 3.85h1.25c.439 0 .795.357.795.796v1.25a.796.796 0 0 1-.795.796h-1.25a.796.796 0 0 1-.796-.796v-1.25c0-.439.356-.796.796-.796Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
1
flutter/assets/keyboard_mouse.svg
Normal file
1
flutter/assets/keyboard_mouse.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.4 KiB |
@@ -7,7 +7,7 @@
|
||||
# 2024, Vasyl Gello <vasek.gello@gmail.com>
|
||||
#
|
||||
|
||||
# The script is invoked by F-Droid builder system ste-by-step.
|
||||
# The script is invoked by F-Droid builder system step-by-step.
|
||||
#
|
||||
# It accepts the following arguments:
|
||||
#
|
||||
@@ -16,7 +16,6 @@
|
||||
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
|
||||
# - The build step to execute:
|
||||
#
|
||||
# + sudo-deps: as root, install needed Debian packages into builder VM
|
||||
# + prebuild: patch sources and do other stuff before the build
|
||||
# + build: perform actual build of APK file
|
||||
#
|
||||
@@ -184,13 +183,9 @@ prebuild)
|
||||
fi
|
||||
|
||||
# Map NDK version to revision
|
||||
|
||||
NDK_VERSION="$(wget \
|
||||
-qO- \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
'https://api.github.com/repos/android/ndk/releases' |
|
||||
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
|
||||
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
|
||||
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
|
||||
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
|
||||
|
||||
if [ -z "${NDK_VERSION}" ]; then
|
||||
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
||||
@@ -316,6 +311,18 @@ prebuild)
|
||||
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
|
||||
|
||||
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
|
||||
# Find first libclang.so and set BRIDGE_LLVM_PATH
|
||||
|
||||
BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)"
|
||||
|
||||
if [ -z "${BRIDGE_LLVM_PATH}" ]; then
|
||||
echo 'ERROR: Can not find libclang.so for bridge generator!' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
|
||||
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
|
||||
|
||||
# Install Flutter bridge version
|
||||
|
||||
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
|
||||
@@ -344,7 +351,8 @@ prebuild)
|
||||
|
||||
flutter_rust_bridge_codegen \
|
||||
--rust-input ./src/flutter_ffi.rs \
|
||||
--dart-output ./flutter/lib/generated_bridge.dart
|
||||
--dart-output ./flutter/lib/generated_bridge.dart \
|
||||
--llvm-path "${BRIDGE_LLVM_PATH}"
|
||||
|
||||
# Add bridge files to save-list
|
||||
|
||||
@@ -355,13 +363,15 @@ prebuild)
|
||||
git checkout '*'
|
||||
git clean -dffx
|
||||
git reset
|
||||
|
||||
unset BRIDGE_LLVM_PATH
|
||||
fi
|
||||
|
||||
# Install Flutter version for RustDesk library build
|
||||
|
||||
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
|
||||
|
||||
# gms is not in thoes files now, but we still keep the following line for future reference(maybe).
|
||||
# gms is not in these files now, but we still keep the following line for future reference(maybe).
|
||||
|
||||
sed \
|
||||
-i \
|
||||
@@ -414,13 +424,9 @@ build)
|
||||
.github/workflows/flutter-build.yml)"
|
||||
|
||||
# Map NDK version to revision
|
||||
|
||||
NDK_VERSION="$(wget \
|
||||
-qO- \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
'https://api.github.com/repos/android/ndk/releases' |
|
||||
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
|
||||
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
|
||||
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
|
||||
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
|
||||
|
||||
if [ -z "${NDK_VERSION}" ]; then
|
||||
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
||||
@@ -454,6 +460,7 @@ build)
|
||||
--target "${RUST_TARGET}" \
|
||||
--bindgen \
|
||||
build \
|
||||
--locked \
|
||||
--release \
|
||||
--features "${RUSTDESK_FEATURES}"
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/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
|
||||
cargo build --features flutter --release --target x86_64-apple-ios --lib
|
||||
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:window_size/window_size.dart' as window_size;
|
||||
|
||||
@@ -715,6 +716,17 @@ closeConnection({String? id}) {
|
||||
stateGlobal.isInMainPage = true;
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1010,13 +1022,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
|
||||
});
|
||||
}
|
||||
|
||||
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
|
||||
void showToast(String text,
|
||||
{Duration timeout = const Duration(seconds: 3),
|
||||
Alignment alignment = const Alignment(0.0, 0.8)}) {
|
||||
final overlayState = globalKey.currentState?.overlay;
|
||||
if (overlayState == null) return;
|
||||
final entry = OverlayEntry(builder: (context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: const Alignment(0.0, 0.8),
|
||||
alignment: alignment,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: MyTheme.color(context).toastBg,
|
||||
@@ -1121,18 +1135,23 @@ class CustomAlertDialog extends StatelessWidget {
|
||||
|
||||
Widget createDialogContent(String text) {
|
||||
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
||||
bool hasLink = linkRegExp.hasMatch(text);
|
||||
|
||||
// Early return: no link, use default theme color
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
final List<TextSpan> spans = [];
|
||||
int start = 0;
|
||||
bool hasLink = false;
|
||||
|
||||
linkRegExp.allMatches(text).forEach((match) {
|
||||
hasLink = true;
|
||||
if (match.start > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, match.start)));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: match.group(0) ?? '',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
@@ -1150,13 +1169,9 @@ Widget createDialogContent(String text) {
|
||||
spans.add(TextSpan(text: text.substring(start)));
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
return SelectableText.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(color: Colors.black, fontSize: 15),
|
||||
style: const TextStyle(fontSize: 15),
|
||||
children: spans,
|
||||
),
|
||||
);
|
||||
@@ -1575,7 +1590,7 @@ bool option2bool(String option, String value) {
|
||||
option == kOptionForceAlwaysRelay) {
|
||||
res = value == "Y";
|
||||
} else {
|
||||
assert(false);
|
||||
// "" is true
|
||||
res = value != "N";
|
||||
}
|
||||
return res;
|
||||
@@ -1593,9 +1608,6 @@ String bool2option(String option, bool b) {
|
||||
option == kOptionForceAlwaysRelay) {
|
||||
res = b ? 'Y' : defaultOptionNo;
|
||||
} else {
|
||||
if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
|
||||
assert(false);
|
||||
}
|
||||
res = b ? 'Y' : 'N';
|
||||
}
|
||||
return res;
|
||||
@@ -1932,44 +1944,41 @@ Future<Offset?> _adjustRestoreMainWindowOffset(
|
||||
return null;
|
||||
}
|
||||
|
||||
double? frameLeft;
|
||||
double? frameTop;
|
||||
double? frameRight;
|
||||
double? frameBottom;
|
||||
|
||||
if (isDesktop || isWebDesktop) {
|
||||
for (final screen in await window_size.getScreenList()) {
|
||||
frameLeft = frameLeft == null
|
||||
? screen.visibleFrame.left
|
||||
: min(screen.visibleFrame.left, frameLeft);
|
||||
frameTop = frameTop == null
|
||||
? screen.visibleFrame.top
|
||||
: min(screen.visibleFrame.top, frameTop);
|
||||
frameRight = frameRight == null
|
||||
? screen.visibleFrame.right
|
||||
: max(screen.visibleFrame.right, frameRight);
|
||||
frameBottom = frameBottom == null
|
||||
? screen.visibleFrame.bottom
|
||||
: max(screen.visibleFrame.bottom, frameBottom);
|
||||
final screens = await window_size.getScreenList();
|
||||
if (screens.isNotEmpty) {
|
||||
final windowRect = Rect.fromLTWH(left, top, width, height);
|
||||
bool isVisible = false;
|
||||
for (final screen in screens) {
|
||||
final intersection = windowRect.intersect(screen.visibleFrame);
|
||||
if (intersection.width >= 10.0 && intersection.height >= 10.0) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return Offset(left, top);
|
||||
}
|
||||
}
|
||||
if (frameLeft == null) {
|
||||
frameLeft = 0.0;
|
||||
frameTop = 0.0;
|
||||
frameRight = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
frameBottom = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
double frameLeft = 0.0;
|
||||
double frameTop = 0.0;
|
||||
double frameRight = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
double frameBottom = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
|
||||
final minWidth = 10.0;
|
||||
if ((left + minWidth) > frameRight! ||
|
||||
(top + minWidth) > frameBottom! ||
|
||||
if ((left + minWidth) > frameRight ||
|
||||
(top + minWidth) > frameBottom ||
|
||||
(left + width - minWidth) < frameLeft ||
|
||||
top < frameTop!) {
|
||||
top < frameTop) {
|
||||
return null;
|
||||
} else {
|
||||
return Offset(left, top);
|
||||
@@ -2367,6 +2376,19 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
id = uri.path.substring("/new/".length);
|
||||
} else if (uri.authority == "config") {
|
||||
if (isAndroid || isIOS) {
|
||||
final allowDeepLinkServerSettings =
|
||||
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) ==
|
||||
'Y';
|
||||
if (!allowDeepLinkServerSettings) {
|
||||
debugPrint(
|
||||
"Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled.");
|
||||
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
|
||||
// Delay toast to avoid missing overlay during cold-start deeplink handling.
|
||||
Timer(Duration(seconds: 1), () {
|
||||
showToast(translate('Failed'));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
final config = uri.path.substring("/".length);
|
||||
// add a timer to make showToast work
|
||||
Timer(Duration(seconds: 1), () {
|
||||
@@ -2376,11 +2398,24 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
return null;
|
||||
} else if (uri.authority == "password") {
|
||||
if (isAndroid || isIOS) {
|
||||
final allowDeepLinkPassword =
|
||||
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y';
|
||||
if (!allowDeepLinkPassword) {
|
||||
debugPrint(
|
||||
"Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled.");
|
||||
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
|
||||
// Delay toast to avoid missing overlay during cold-start deeplink handling.
|
||||
Timer(Duration(seconds: 1), () {
|
||||
showToast(translate('Failed'));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
final password = uri.path.substring("/".length);
|
||||
if (password.isNotEmpty) {
|
||||
Timer(Duration(seconds: 1), () async {
|
||||
await bind.mainSetPermanentPassword(password: password);
|
||||
showToast(translate('Successful'));
|
||||
final ok =
|
||||
await bind.mainSetPermanentPasswordWithResult(password: password);
|
||||
showToast(translate(ok ? 'Successful' : 'Failed'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2676,6 +2711,55 @@ class SimpleWrapper<T> {
|
||||
SimpleWrapper(this.value);
|
||||
}
|
||||
|
||||
/// Wakelock manager with reference counting for desktop.
|
||||
/// Ensures wakelock is only disabled when all sessions are closed/minimized.
|
||||
///
|
||||
/// Note: Each isolate has its own WakelockPlus instance with independent assertion.
|
||||
/// As long as one isolate has wakelock enabled, the screen stays awake.
|
||||
/// This manager handles multiple tabs within the same isolate.
|
||||
class WakelockManager {
|
||||
static final Set<UniqueKey> _enabledKeys = {};
|
||||
// Don't use WakelockPlus.enabled, it causes error on Android:
|
||||
// Unhandled Exception: FormatException: Message corrupted
|
||||
//
|
||||
// On Linux, multiple enable() calls create only one inhibit, but each disable()
|
||||
// only releases if _cookie != null. So we need our own _enabled state to avoid
|
||||
// calling disable() when not enabled.
|
||||
// See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48
|
||||
static bool _enabled = false;
|
||||
|
||||
static void enable(UniqueKey key, {bool isServer = false}) {
|
||||
// Check if we should keep awake during outgoing sessions
|
||||
if (!isServer) {
|
||||
final keepAwake =
|
||||
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||
if (!keepAwake) {
|
||||
return; // Don't enable wakelock if user disabled keep awake
|
||||
}
|
||||
}
|
||||
if (isDesktop) {
|
||||
_enabledKeys.add(key);
|
||||
}
|
||||
if (!_enabled) {
|
||||
_enabled = true;
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
static void disable(UniqueKey key) {
|
||||
if (isDesktop) {
|
||||
_enabledKeys.remove(key);
|
||||
if (_enabledKeys.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_enabled) {
|
||||
WakelockPlus.disable();
|
||||
_enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// call this to reload current window.
|
||||
///
|
||||
/// [Note]
|
||||
@@ -3016,10 +3100,26 @@ Future<void> start_service(bool is_start) async {
|
||||
}
|
||||
|
||||
Future<bool> canBeBlocked() async {
|
||||
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
|
||||
if (isWeb) {
|
||||
// Web can only act as a controller, never as a controlled side,
|
||||
// so it should never be blocked by a remote session.
|
||||
return false;
|
||||
}
|
||||
// First check control permission
|
||||
final controlPermission = await bind.mainGetCommon(
|
||||
key: "is-remote-modify-enabled-by-control-permissions");
|
||||
if (controlPermission == "true") {
|
||||
return false;
|
||||
} else if (controlPermission == "false") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check local settings
|
||||
var accessMode = await bind.mainGetOption(key: kOptionAccessMode);
|
||||
var isCustomAccessMode = accessMode != 'full' && accessMode != 'view';
|
||||
var option = option2bool(kOptionAllowRemoteConfigModification,
|
||||
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
||||
return access_mode == 'view' || (access_mode.isEmpty && !option);
|
||||
return accessMode == 'view' || (isCustomAccessMode && !option);
|
||||
}
|
||||
|
||||
// to-do: web not implemented
|
||||
@@ -3782,6 +3882,16 @@ setResizable(bool resizable) {
|
||||
|
||||
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
||||
|
||||
bool isChangePermanentPasswordDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) ==
|
||||
'Y';
|
||||
|
||||
bool isChangeIdDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y';
|
||||
|
||||
bool isUnlockPinDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y';
|
||||
|
||||
bool? _isCustomClient;
|
||||
bool get isCustomClient {
|
||||
_isCustomClient ??= bind.isCustomClient();
|
||||
@@ -4025,3 +4135,62 @@ String decode_http_response(http.Response resp) {
|
||||
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
|
||||
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
|
||||
}
|
||||
|
||||
// TODO: We should support individual bits combinations in the future.
|
||||
// But for now, just keep it simple, because the old code only supports single button.
|
||||
// No users have requested multi-button support yet.
|
||||
String mouseButtonsToPeer(int buttons) {
|
||||
switch (buttons) {
|
||||
case kPrimaryMouseButton:
|
||||
return 'left';
|
||||
case kSecondaryMouseButton:
|
||||
return 'right';
|
||||
case kMiddleMouseButton:
|
||||
return 'wheel';
|
||||
case kBackMouseButton:
|
||||
return 'back';
|
||||
case kForwardMouseButton:
|
||||
return 'forward';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an avatar widget from an avatar URL or data URI string.
|
||||
/// Returns [fallback] if avatar is empty or cannot be decoded.
|
||||
/// [borderRadius] defaults to [size]/2 (circle).
|
||||
Widget? buildAvatarWidget({
|
||||
required String avatar,
|
||||
required double size,
|
||||
double? borderRadius,
|
||||
Widget? fallback,
|
||||
}) {
|
||||
final trimmed = avatar.trim();
|
||||
if (trimmed.isEmpty) return fallback;
|
||||
|
||||
ImageProvider? imageProvider;
|
||||
if (trimmed.startsWith('data:image/')) {
|
||||
final comma = trimmed.indexOf(',');
|
||||
if (comma > 0) {
|
||||
try {
|
||||
imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1)));
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
imageProvider = NetworkImage(trimmed);
|
||||
}
|
||||
|
||||
if (imageProvider == null) return fallback;
|
||||
|
||||
final radius = borderRadius ?? size / 2;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
|
||||
// Is all the fields of the user needed?
|
||||
class UserPayload {
|
||||
String name = '';
|
||||
String displayName = '';
|
||||
String avatar = '';
|
||||
String email = '';
|
||||
String note = '';
|
||||
String? verifier;
|
||||
@@ -33,6 +35,8 @@ class UserPayload {
|
||||
|
||||
UserPayload.fromJson(Map<String, dynamic> json)
|
||||
: name = json['name'] ?? '',
|
||||
displayName = json['display_name'] ?? '',
|
||||
avatar = json['avatar'] ?? '',
|
||||
email = json['email'] ?? '',
|
||||
note = json['note'] ?? '',
|
||||
verifier = json['verifier'],
|
||||
@@ -46,6 +50,8 @@ class UserPayload {
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
'display_name': displayName,
|
||||
'avatar': avatar,
|
||||
'status': status == UserStatus.kDisabled
|
||||
? 0
|
||||
: status == UserStatus.kUnverified
|
||||
@@ -58,9 +64,14 @@ class UserPayload {
|
||||
Map<String, dynamic> toGroupCacheJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
'display_name': displayName,
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
String get displayNameOrName {
|
||||
return displayName.trim().isEmpty ? name : displayName;
|
||||
}
|
||||
}
|
||||
|
||||
class PeerPayload {
|
||||
|
||||
@@ -54,9 +54,9 @@ class _AddressBookState extends State<AddressBook> {
|
||||
const LinearProgressIndicator(),
|
||||
buildErrorBanner(context,
|
||||
loading: gFFI.abModel.currentAbLoading,
|
||||
err: gFFI.abModel.currentAbPullError,
|
||||
err: gFFI.abModel.abPullError,
|
||||
retry: null,
|
||||
close: () => gFFI.abModel.currentAbPullError.value = ''),
|
||||
close: gFFI.abModel.clearPullErrors),
|
||||
buildErrorBanner(context,
|
||||
loading: gFFI.abModel.currentAbLoading,
|
||||
err: gFFI.abModel.currentAbPushError,
|
||||
|
||||
@@ -25,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
GestureDragStartCallback? onOneFingerPanStart;
|
||||
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
||||
GestureDragEndCallback? onOneFingerPanEnd;
|
||||
GestureDragCancelCallback? onOneFingerPanCancel;
|
||||
|
||||
// twoFingerScale : scale + pan event
|
||||
GestureScaleStartCallback? onTwoFingerScaleStart;
|
||||
@@ -169,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
|
||||
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
||||
DragEndDetails(velocity: d.velocity);
|
||||
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
super.rejectGesture(pointer);
|
||||
switch (_currentState) {
|
||||
case GestureState.oneFingerPan:
|
||||
if (onOneFingerPanCancel != null) {
|
||||
onOneFingerPanCancel!();
|
||||
}
|
||||
break;
|
||||
case GestureState.twoFingerScale:
|
||||
// Reset scale state if needed, currently self-contained
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
// Reset drag state if needed, currently self-contained
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_currentState = GestureState.none;
|
||||
}
|
||||
}
|
||||
|
||||
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
|
||||
@@ -717,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({
|
||||
GestureDragStartCallback? onOneFingerPanStart,
|
||||
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
||||
GestureDragEndCallback? onOneFingerPanEnd,
|
||||
GestureDragCancelCallback? onOneFingerPanCancel,
|
||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
||||
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
||||
@@ -765,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({
|
||||
..onOneFingerPanStart = onOneFingerPanStart
|
||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||
|
||||
@@ -20,7 +20,8 @@ const kOpSvgList = [
|
||||
'okta',
|
||||
'facebook',
|
||||
'azure',
|
||||
'auth0'
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
|
||||
class _IconOP extends StatelessWidget {
|
||||
@@ -103,7 +104,7 @@ class ButtonOP extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text('${translate("Continue with")} $opLabel')),
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -224,21 +225,59 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: '$_stateMsg ',
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 12),
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: _failedMsg,
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.red,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SelectableText(
|
||||
translate(_stateMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_failedMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Builder(builder: (context) {
|
||||
final errorColor =
|
||||
Theme.of(context).colorScheme.error;
|
||||
final bgColor = Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
.withOpacity(0.3);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0, vertical: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
color: errorColor, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
translate(_failedMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -158,12 +158,18 @@ class _MyGroupState extends State<MyGroup> {
|
||||
return Obx(() {
|
||||
final userItems = gFFI.groupModel.users.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
return p0.name
|
||||
.toLowerCase()
|
||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
||||
final search = searchAccessibleItemNameText.value.toLowerCase();
|
||||
return p0.name.toLowerCase().contains(search) ||
|
||||
p0.displayNameOrName.toLowerCase().contains(search);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
// Count occurrences of each displayNameOrName to detect duplicates
|
||||
final displayNameCount = <String, int>{};
|
||||
for (final u in userItems) {
|
||||
final dn = u.displayNameOrName;
|
||||
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
|
||||
}
|
||||
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
return p0.name
|
||||
@@ -177,7 +183,8 @@ class _MyGroupState extends State<MyGroup> {
|
||||
itemCount: deviceGroupItems.length + userItems.length,
|
||||
itemBuilder: (context, index) => index < deviceGroupItems.length
|
||||
? _buildDeviceGroupItem(deviceGroupItems[index])
|
||||
: _buildUserItem(userItems[index - deviceGroupItems.length]));
|
||||
: _buildUserItem(userItems[index - deviceGroupItems.length],
|
||||
displayNameCount));
|
||||
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||
? listView(false)
|
||||
@@ -185,8 +192,14 @@ class _MyGroupState extends State<MyGroup> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildUserItem(UserPayload user) {
|
||||
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
|
||||
final username = user.name;
|
||||
final dn = user.displayNameOrName;
|
||||
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
|
||||
final displayName =
|
||||
isDuplicate && user.displayName.trim().isNotEmpty
|
||||
? '${user.displayName} (@$username)'
|
||||
: dn;
|
||||
return InkWell(onTap: () {
|
||||
isSelectedDeviceGroup.value = false;
|
||||
if (selectedAccessibleItemName.value != username) {
|
||||
@@ -222,14 +235,14 @@ class _MyGroupState extends State<MyGroup> {
|
||||
alignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Text(
|
||||
username.characters.first.toUpperCase(),
|
||||
displayName.characters.first.toUpperCase(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 4),
|
||||
if (isMe) Flexible(child: Text(username)),
|
||||
if (isMe) Flexible(child: Text(displayName)),
|
||||
if (isMe)
|
||||
Flexible(
|
||||
child: Container(
|
||||
@@ -246,7 +259,7 @@ class _MyGroupState extends State<MyGroup> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isMe) Expanded(child: Text(username)),
|
||||
if (!isMe) Expanded(child: Text(displayName)),
|
||||
],
|
||||
).paddingSymmetric(vertical: 4),
|
||||
),
|
||||
|
||||
@@ -570,11 +570,14 @@ class MyGroupPeerView extends BasePeersView {
|
||||
static bool filter(Peer peer) {
|
||||
final model = gFFI.groupModel;
|
||||
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
||||
final text = model.searchAccessibleItemNameText.value;
|
||||
final searchPeersOfUser = peer.loginName.contains(text) &&
|
||||
model.users.any((user) => user.name == peer.loginName);
|
||||
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
|
||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
||||
final text = model.searchAccessibleItemNameText.value.toLowerCase();
|
||||
final searchPeersOfUser = model.users.any((user) =>
|
||||
user.name == peer.loginName &&
|
||||
(user.name.toLowerCase().contains(text) ||
|
||||
user.displayNameOrName.toLowerCase().contains(text)));
|
||||
final searchPeersOfDeviceGroup =
|
||||
peer.device_group_name.toLowerCase().contains(text) &&
|
||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
||||
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget {
|
||||
// https://github.com/flutter/flutter/issues/154053
|
||||
final useRawKeyEvents = isLinux && !isWeb;
|
||||
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
|
||||
// while `Alt` and `Control` are seperated key events for en-US input method.
|
||||
// while `Alt` and `Control` are separated key events for en-US input method.
|
||||
return FocusScope(
|
||||
autofocus: true,
|
||||
child: Focus(
|
||||
@@ -107,6 +107,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||
// So we need to cache the last tap down position.
|
||||
Offset? _lastTapDownPositionForMouseMode;
|
||||
// Cache global position for onTap (which lacks position info).
|
||||
Offset? _lastTapDownGlobalPosition;
|
||||
|
||||
FFI get ffi => widget.ffi;
|
||||
FfiModel get ffiModel => widget.ffiModel;
|
||||
@@ -136,6 +138,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
|
||||
onTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
_lastTapDownGlobalPosition = d.globalPosition;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
@@ -154,11 +157,16 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
final isMoved =
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
if (isMoved) {
|
||||
if (lastTapDownDetails != null) {
|
||||
// If pan already handled 'down', don't send it again.
|
||||
if (lastTapDownDetails != null && !_touchModePanStarted) {
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
await inputModel.tapUp(MouseButtons.left);
|
||||
@@ -170,6 +178,11 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
final lastPos = _lastTapDownGlobalPosition;
|
||||
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||
@@ -372,7 +385,10 @@ class _RawTouchGestureDetectorRegionState
|
||||
await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
}
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
}
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
} else {
|
||||
final offset = ffi.cursorModel.offset;
|
||||
@@ -397,7 +413,12 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (handleTouch && !_touchModePanStarted) {
|
||||
return;
|
||||
}
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
// In relative mouse mode, send delta directly without position tracking.
|
||||
if (inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
|
||||
} else {
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
}
|
||||
}
|
||||
|
||||
onOneFingerPanEnd(DragEndDetails d) async {
|
||||
@@ -409,10 +430,21 @@ class _RawTouchGestureDetectorRegionState
|
||||
ffi.cursorModel.clearRemoteWindowCoords();
|
||||
}
|
||||
if (handleTouch) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
|
||||
// or rejected by the gesture arena. Without this, the flag can remain
|
||||
// stuck in the "started" state and cause issues such as the Magic Mouse
|
||||
// double-click problem on iPad with magic mouse.
|
||||
onOneFingerPanCancel() {
|
||||
_touchModePanStarted = false;
|
||||
}
|
||||
|
||||
// scale + pan event
|
||||
onTwoFingerScaleStart(ScaleStartDetails d) {
|
||||
_lastTapDownDetails = null;
|
||||
@@ -500,7 +532,9 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Official
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(), (instance) {
|
||||
() => TapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp
|
||||
@@ -508,14 +542,18 @@ class _RawTouchGestureDetectorRegionState
|
||||
}),
|
||||
DoubleTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(), (instance) {
|
||||
() => DoubleTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleTapDown = onDoubleTapDown
|
||||
..onDoubleTap = onDoubleTap;
|
||||
}),
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(), (instance) {
|
||||
() => LongPressGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
@@ -525,7 +563,9 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||
() => HoldTapMoveGestureRecognizer(),
|
||||
() => HoldTapMoveGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(instance) => instance
|
||||
..onHoldDragStart = onHoldDragStart
|
||||
..onHoldDragUpdate = onHoldDragUpdate
|
||||
@@ -533,19 +573,24 @@ class _RawTouchGestureDetectorRegionState
|
||||
..onHoldDragEnd = onHoldDragEnd),
|
||||
DoubleFinerTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||
() => DoubleFinerTapGestureRecognizer(), (instance) {
|
||||
() => DoubleFinerTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleFinerTap = onDoubleFinerTap
|
||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||
}),
|
||||
CustomTouchGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||
() => CustomTouchGestureRecognizer(), (instance) {
|
||||
() => CustomTouchGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance.onOneFingerPanStart =
|
||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||
instance
|
||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
|
||||
@@ -6,13 +6,77 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
bool isEditOsPassword = false;
|
||||
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
|
||||
const String kWaylandKeyboardIssueUrl =
|
||||
'https://github.com/rustdesk/rustdesk/issues/14586';
|
||||
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
|
||||
|
||||
Future<bool> openWaylandKeyboardIssueUrl() {
|
||||
return launchUrl(
|
||||
Uri.parse(kWaylandKeyboardIssueUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
|
||||
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
|
||||
}
|
||||
|
||||
void setWaylandKeyboardPromptSuppressedForConnection(
|
||||
String connectionId, bool suppressed) {
|
||||
if (suppressed) {
|
||||
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
|
||||
} else {
|
||||
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||
}
|
||||
|
||||
bool shouldShowWaylandKeyboardPrompt({
|
||||
required String connectionId,
|
||||
required bool isWaylandPeer,
|
||||
required bool allowWaylandKeyboardRemembered,
|
||||
}) {
|
||||
return isWaylandPeer &&
|
||||
!allowWaylandKeyboardRemembered &&
|
||||
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
|
||||
}
|
||||
|
||||
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays, so switching the remote
|
||||
// display does not weaken the local privacy protection.
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
|
||||
return pi.platform == kPeerPlatformMacOS;
|
||||
}
|
||||
|
||||
class TTextMenu {
|
||||
final Widget child;
|
||||
@@ -85,12 +149,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) {
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||
|
||||
List<TTextMenu> v = [];
|
||||
// elevation
|
||||
@@ -140,11 +371,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
Future<void> sendClipboardKeystrokes() async {
|
||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -193,14 +473,26 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// note
|
||||
if (isDefaultConn &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
if (isDefaultConn && !bind.isDisableAccount()) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Note')),
|
||||
onPressed: () => showAuditDialog(ffi)),
|
||||
onPressed: () async {
|
||||
bool isLogin =
|
||||
bind.mainGetLocalOption(key: 'access_token').isNotEmpty;
|
||||
if (!isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
// Desktop: send message to main window to refresh login status
|
||||
// Web: login is required before connection, so no need to refresh
|
||||
// Mobile: same isolate, no need to send message
|
||||
if (isDesktop) {
|
||||
rustDeskWinManager.call(
|
||||
WindowType.Main, kWindowRefreshCurrentUser, "");
|
||||
}
|
||||
}
|
||||
showAuditDialog(ffi);
|
||||
}),
|
||||
);
|
||||
}
|
||||
// divider
|
||||
@@ -261,7 +553,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
isDesktop &&
|
||||
ffiModel.keyboard &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
pi.platform != kPeerPlatformMacOS &&
|
||||
versionCmp(pi.version, '1.2.0') >= 0 &&
|
||||
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
||||
v.add(TTextMenu(
|
||||
@@ -671,8 +962,9 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Lock after session end'))));
|
||||
}
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
PrivacyModeState.find(id).isEmpty &&
|
||||
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
@@ -746,15 +1038,26 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final hasPrivacyModePermission =
|
||||
ffiModel.permissions['privacy_mode'] != false;
|
||||
|
||||
// Backend revocation already attempts to turn privacy mode off.
|
||||
// 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.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (ffiModel.pi.currentDisplay != 0 &&
|
||||
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||
ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
@@ -797,18 +1100,29 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
})
|
||||
];
|
||||
} 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 implName = (e)[1] as String;
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||
return TToggleMenu(
|
||||
child: Text(translate(implName)),
|
||||
value: privacyModeState.value == implKey,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
});
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
}
|
||||
: null);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -817,6 +1131,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
List<TToggleMenu> v = [];
|
||||
|
||||
// swap key
|
||||
@@ -838,6 +1153,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
child: Text(translate('Swap control-command key'))));
|
||||
}
|
||||
|
||||
// Relative mouse mode (gaming mode).
|
||||
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
|
||||
// Note: This feature is only available in Flutter client. Sciter client does not support this.
|
||||
// Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
|
||||
// Wayland is not supported due to cursor warping limitations.
|
||||
// Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
|
||||
final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
|
||||
if (isDesktop &&
|
||||
isDefaultConn &&
|
||||
!isWeb &&
|
||||
!isWayland &&
|
||||
ffiModel.keyboard &&
|
||||
!ffiModel.viewOnly &&
|
||||
ffi.inputModel.isRelativeMouseModeSupported) {
|
||||
v.add(TToggleMenu(
|
||||
value: ffi.inputModel.relativeMouseMode.value,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
final previousValue = ffi.inputModel.relativeMouseMode.value;
|
||||
final success = ffi.inputModel.setRelativeMouseMode(value);
|
||||
if (!success) {
|
||||
// Revert the observable toggle to reflect the actual state
|
||||
ffi.inputModel.relativeMouseMode.value = previousValue;
|
||||
}
|
||||
},
|
||||
child: Text(translate('Relative mouse mode'))));
|
||||
}
|
||||
|
||||
// reverse mouse wheel
|
||||
if (ffiModel.keyboard) {
|
||||
var optionValue =
|
||||
|
||||
@@ -50,6 +50,7 @@ const String kAppTypeDesktopPortForward = "port forward";
|
||||
const String kAppTypeDesktopTerminal = "terminal";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||
const String kWindowGetWindowInfo = "get_window_info";
|
||||
const String kWindowGetScreenList = "get_screen_list";
|
||||
// This method is not used, maybe it can be removed.
|
||||
@@ -113,6 +114,9 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||
const String kOptionEnablePermChangeInAcceptWindow =
|
||||
"enable-perm-change-in-accept-window";
|
||||
const String kOptionAllowRemoteConfigModification =
|
||||
"allow-remote-config-modification";
|
||||
const String kOptionVerificationMethod = "verification-method";
|
||||
@@ -120,6 +124,7 @@ const String kOptionApproveMode = "approve-mode";
|
||||
const String kOptionAllowNumericOneTimePassword =
|
||||
"allow-numeric-one-time-password";
|
||||
const String kOptionCollapseToolbar = "collapse_toolbar";
|
||||
const String kOptionHideToolbar = "hide-toolbar";
|
||||
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
||||
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
||||
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
||||
@@ -137,6 +142,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
|
||||
const String kOptionCodecPreference = "codec-preference";
|
||||
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
||||
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 kOptionRemoteMenubarState = "remoteMenubarState";
|
||||
const String kOptionPeerSorting = "peer-sorting";
|
||||
@@ -161,6 +170,7 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
@@ -172,13 +182,21 @@ const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
|
||||
const String kOptionHideServerSetting = "hide-server-settings";
|
||||
const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||
const String kOptionHideStopService = "hide-stop-service";
|
||||
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
||||
const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||
const String kOptionRemovePresetPasswordWarning =
|
||||
"remove-preset-password-warning";
|
||||
const String kOptionDisableChangePermanentPassword =
|
||||
"disable-change-permanent-password";
|
||||
const String kOptionDisableChangeId = "disable-change-id";
|
||||
const String kOptionDisableUnlockPin = "disable-unlock-pin";
|
||||
const kHideUsernameOnCard = "hide-username-on-card";
|
||||
const String kOptionHideHelpCards = "hide-help-cards";
|
||||
const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
|
||||
const String kOptionAllowDeepLinkServerSettings =
|
||||
"allow-deep-link-server-settings";
|
||||
|
||||
const String kOptionToggleViewOnly = "view-only";
|
||||
const String kOptionToggleShowMyCursor = "show-my-cursor";
|
||||
@@ -187,6 +205,9 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
|
||||
|
||||
const String kOptionKeepScreenOn = "keep-screen-on";
|
||||
|
||||
const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
|
||||
const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
|
||||
|
||||
const String kOptionShowMobileAction = "showMobileActions";
|
||||
|
||||
const String kUrlActionClose = "close";
|
||||
@@ -251,6 +272,33 @@ const int kMinTrackpadSpeed = 10;
|
||||
const int kDefaultTrackpadSpeed = 100;
|
||||
const int kMaxTrackpadSpeed = 1000;
|
||||
|
||||
// relative mouse mode
|
||||
/// Throttle duration (in milliseconds) for updating pointer lock center during
|
||||
/// window move/resize events. Lower values provide more responsive updates but
|
||||
/// may cause performance issues during rapid window operations.
|
||||
const int kDefaultPointerLockCenterThrottleMs = 100;
|
||||
|
||||
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
|
||||
/// Servers older than this version will ignore relative mouse events.
|
||||
///
|
||||
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
|
||||
const String kMinVersionForRelativeMouseMode = '1.4.5';
|
||||
|
||||
/// Maximum delta value for relative mouse movement.
|
||||
/// Large values could cause issues with i32 overflow on server side,
|
||||
/// and no reasonable mouse movement should exceed this bound.
|
||||
///
|
||||
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
|
||||
const int kMaxRelativeMouseDelta = 10000;
|
||||
|
||||
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
|
||||
/// This prevents double-toggle from race condition between Rust rdev grab loop
|
||||
/// and Flutter keyboard handling. Value should be small enough to allow
|
||||
/// intentional quick toggles but large enough to prevent accidental double-triggers.
|
||||
const int kRelativeMouseModeToggleDebounceMs = 150;
|
||||
|
||||
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
||||
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||
|
||||
@@ -450,7 +450,11 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
||||
btnText,
|
||||
onPressed,
|
||||
closeButton: true);
|
||||
closeButton: true,
|
||||
help: isToUpdate ? 'Changelog' : null,
|
||||
link: isToUpdate
|
||||
? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
|
||||
: null);
|
||||
}
|
||||
if (systemError.isNotEmpty) {
|
||||
return buildInstallCard("", systemError, "", () {});
|
||||
@@ -776,6 +780,8 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
if (call.method == kWindowMainWindowOnTop) {
|
||||
windowOnTop(null);
|
||||
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
} else if (call.method == kWindowGetWindowInfo) {
|
||||
final screen = (await window_size.getWindowInfo()).screen;
|
||||
if (screen == null) {
|
||||
@@ -902,12 +908,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
|
||||
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
final pw = await bind.mainGetPermanentPassword();
|
||||
final p0 = TextEditingController(text: pw);
|
||||
final p1 = TextEditingController(text: pw);
|
||||
final p0 = TextEditingController(text: "");
|
||||
final p1 = TextEditingController(text: "");
|
||||
var errMsg0 = "";
|
||||
var errMsg1 = "";
|
||||
final RxString rxPass = pw.trim().obs;
|
||||
final localPasswordSet =
|
||||
(await bind.mainGetCommon(key: "local-permanent-password-set")) == "true";
|
||||
final permanentPasswordSet =
|
||||
(await bind.mainGetCommon(key: "permanent-password-set")) == "true";
|
||||
final presetPassword = permanentPasswordSet && !localPasswordSet;
|
||||
var canSubmit = false;
|
||||
final RxString rxPass = "".obs;
|
||||
final rules = [
|
||||
DigitValidationRule(),
|
||||
UppercaseValidationRule(),
|
||||
@@ -916,9 +927,21 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
MinCharactersValidationRule(8),
|
||||
];
|
||||
final maxLength = bind.mainMaxEncryptLen();
|
||||
final statusTip = localPasswordSet
|
||||
? translate('password-hidden-tip')
|
||||
: (presetPassword ? translate('preset-password-in-use-tip') : '');
|
||||
final showStatusTipOnMobile =
|
||||
statusTip.isNotEmpty && !isDesktop && !isWebDesktop;
|
||||
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
updateCanSubmit() {
|
||||
canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
submit() async {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
errMsg0 = "";
|
||||
errMsg1 = "";
|
||||
@@ -941,7 +964,13 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
});
|
||||
return;
|
||||
}
|
||||
bind.mainSetPermanentPassword(password: pass);
|
||||
final ok = await bind.mainSetPermanentPasswordWithResult(password: pass);
|
||||
if (!ok) {
|
||||
setState(() {
|
||||
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pass.isNotEmpty) {
|
||||
notEmptyCallback?.call();
|
||||
}
|
||||
@@ -949,14 +978,20 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Set Password")),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.key, color: MyTheme.accent),
|
||||
Text(translate("Set Password")).paddingOnly(left: 10),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 500),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
SizedBox(
|
||||
height: showStatusTipOnMobile ? 0.0 : 6.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
@@ -972,6 +1007,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
rxPass.value = value.trim();
|
||||
setState(() {
|
||||
errMsg0 = '';
|
||||
updateCanSubmit();
|
||||
});
|
||||
},
|
||||
maxLength: maxLength,
|
||||
@@ -983,9 +1019,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
children: [
|
||||
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
|
||||
],
|
||||
).marginSymmetric(vertical: 8),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8),
|
||||
SizedBox(
|
||||
height: showStatusTipOnMobile ? 0.0 : 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
@@ -999,6 +1035,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
errMsg1 = '';
|
||||
updateCanSubmit();
|
||||
});
|
||||
},
|
||||
maxLength: maxLength,
|
||||
@@ -1006,11 +1043,23 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
if (statusTip.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info, color: Colors.amber, size: 18)
|
||||
.marginOnly(right: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusTip,
|
||||
style: const TextStyle(fontSize: 13, height: 1.1),
|
||||
))
|
||||
],
|
||||
).marginOnly(top: 6, bottom: 2),
|
||||
SizedBox(
|
||||
height: showStatusTipOnMobile ? 0.0 : 8.0,
|
||||
),
|
||||
Obx(() => Wrap(
|
||||
runSpacing: 8,
|
||||
runSpacing: showStatusTipOnMobile ? 2.0 : 8.0,
|
||||
spacing: 4,
|
||||
children: rules.map((e) {
|
||||
var checked = e.validate(rxPass.value.trim());
|
||||
@@ -1030,11 +1079,67 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
actions: (() {
|
||||
final cancelButton = dialogButton(
|
||||
"Cancel",
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: close,
|
||||
isOutline: true,
|
||||
);
|
||||
final removeButton = dialogButton(
|
||||
"Remove",
|
||||
icon: Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
errMsg0 = "";
|
||||
errMsg1 = "";
|
||||
});
|
||||
final ok =
|
||||
await bind.mainSetPermanentPasswordWithResult(password: "");
|
||||
if (!ok) {
|
||||
setState(() {
|
||||
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: MaterialStatePropertyAll(Colors.red)),
|
||||
);
|
||||
final okButton = dialogButton(
|
||||
"OK",
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: canSubmit ? submit : null,
|
||||
);
|
||||
if (!isDesktop && !isWebDesktop && localPasswordSet) {
|
||||
return [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
cancelButton,
|
||||
const SizedBox(width: 4),
|
||||
removeButton,
|
||||
const SizedBox(width: 4),
|
||||
okButton,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
cancelButton,
|
||||
if (localPasswordSet) removeButton,
|
||||
okButton,
|
||||
];
|
||||
})(),
|
||||
onSubmit: canSubmit ? submit : null,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -458,28 +458,46 @@ class _GeneralState extends State<_General> {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
return _Card(title: 'Service', children: [
|
||||
Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
|
||||
() async {
|
||||
serviceBtnEnabled.value = false;
|
||||
await start_service(serviceStop.value);
|
||||
// enable the button after 1 second
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
serviceBtnEnabled.value = true;
|
||||
});
|
||||
}();
|
||||
}, enabled: serviceBtnEnabled.value))
|
||||
]);
|
||||
final hideStopService =
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
|
||||
return Obx(() {
|
||||
if (hideStopService && !serviceStop.value) {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
return _Card(title: 'Service', children: [
|
||||
_Button(serviceStop.value ? 'Start' : 'Stop', () {
|
||||
() async {
|
||||
serviceBtnEnabled.value = false;
|
||||
await start_service(serviceStop.value);
|
||||
// enable the button after 1 second
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
serviceBtnEnabled.value = true;
|
||||
});
|
||||
}();
|
||||
}, enabled: serviceBtnEnabled.value)
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Widget other() {
|
||||
final showAutoUpdate =
|
||||
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !bind.isIncomingOnly())
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
kOptionEnableConfirmClosingTabs,
|
||||
isServer: false),
|
||||
if (!bind.isIncomingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'allow-remote-toolbar-docking-any-edge',
|
||||
kOptionAllowMultiEdgeToolbarDock,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
),
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb) wallpaper(),
|
||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||
@@ -557,16 +575,36 @@ class _GeneralState extends State<_General> {
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Add client-side wakelock option for desktop platforms
|
||||
if (!bind.isIncomingOnly()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'keep-awake-during-outgoing-sessions-label',
|
||||
kOptionKeepAwakeDuringOutgoingSessions,
|
||||
isServer: false,
|
||||
));
|
||||
}
|
||||
|
||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||
children.add(_OptionCheckBox(
|
||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||
}
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'note-at-conn-end-tip',
|
||||
kOptionAllowAskForNoteAtEndOfConnection,
|
||||
isServer: false,
|
||||
));
|
||||
if (!bind.isDisableAccount()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'note-at-conn-end-tip',
|
||||
kOptionAllowAskForNoteAtEndOfConnection,
|
||||
isServer: false,
|
||||
optSetter: (key, value) async {
|
||||
if (value && !gFFI.userModel.isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
}
|
||||
await mainSetLocalBoolOption(key, value);
|
||||
},
|
||||
));
|
||||
}
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -816,7 +854,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
permissions(context),
|
||||
password(context),
|
||||
_Card(title: '2FA', children: [tfa()]),
|
||||
_Card(title: 'ID', children: [changeId()]),
|
||||
if (!isChangeIdDisabled())
|
||||
_Card(title: 'ID', children: [changeId()]),
|
||||
more(context),
|
||||
]),
|
||||
),
|
||||
@@ -1033,6 +1072,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
_OptionCheckBox(context, 'Enable blocking user input',
|
||||
kOptionEnableBlockInput,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
_OptionCheckBox(
|
||||
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||
kOptionAllowRemoteConfigModification,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
@@ -1080,8 +1123,13 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
if (value ==
|
||||
passwordValues[passwordKeys
|
||||
.indexOf(kUsePermanentPassword)] &&
|
||||
(await bind.mainGetPermanentPassword())
|
||||
.isEmpty) {
|
||||
(await bind.mainGetCommon(
|
||||
key: "permanent-password-set")) !=
|
||||
"true") {
|
||||
if (isChangePermanentPasswordDisabled()) {
|
||||
await callback();
|
||||
return;
|
||||
}
|
||||
setPasswordDialog(notEmptyCallback: callback);
|
||||
} else {
|
||||
await callback();
|
||||
@@ -1186,7 +1234,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
enabled: tmpEnabled && !locked),
|
||||
if (usePassword) numericOneTimePassword,
|
||||
if (usePassword) radios[1],
|
||||
if (usePassword)
|
||||
if (usePassword && !isChangePermanentPasswordDisabled())
|
||||
_SubButton('Set permanent password', setPasswordDialog,
|
||||
permEnabled && !locked),
|
||||
// if (usePassword)
|
||||
@@ -1205,11 +1253,14 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
...directIp(context),
|
||||
whitelist(),
|
||||
...autoDisconnect(context),
|
||||
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||
kOptionKeepAwakeDuringIncomingSessions,
|
||||
reverse: false, enabled: enabled),
|
||||
if (bind.mainIsInstalled())
|
||||
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
||||
'allow-only-conn-window-open',
|
||||
reverse: false, enabled: enabled),
|
||||
if (bind.mainIsInstalled()) unlockPin()
|
||||
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1988,7 +2039,9 @@ class _AccountState extends State<_Account> {
|
||||
|
||||
Widget accountAction() {
|
||||
return Obx(() => _Button(
|
||||
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? 'Login'
|
||||
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||
() => {
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? loginDialog()
|
||||
@@ -1997,24 +2050,65 @@ class _AccountState extends State<_Account> {
|
||||
}
|
||||
|
||||
Widget useInfo() {
|
||||
text(String key, String value) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectionArea(child: Text('${translate(key)}: $value'))
|
||||
.marginSymmetric(vertical: 4),
|
||||
);
|
||||
}
|
||||
|
||||
return Obx(() => Offstage(
|
||||
offstage: gFFI.userModel.userName.value.isEmpty,
|
||||
child: Column(
|
||||
children: [
|
||||
text('Username', gFFI.userModel.userName.value),
|
||||
// text('Group', gFFI.groupModel.groupName.value),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
final avatarWidget = _buildUserAvatar();
|
||||
return Row(
|
||||
children: [
|
||||
if (avatarWidget != null) avatarWidget,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
gFFI.userModel.displayNameOrUserName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectionArea(
|
||||
child: Text(
|
||||
'@${gFFI.userModel.userName.value}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color:
|
||||
Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
)).marginOnly(left: 18, top: 16);
|
||||
}
|
||||
|
||||
Widget? _buildUserAvatar() {
|
||||
// Resolve relative avatar path at display time
|
||||
final avatar =
|
||||
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
|
||||
return buildAvatarWidget(
|
||||
avatar: avatar,
|
||||
size: 44,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Checkbox extends StatefulWidget {
|
||||
@@ -2102,7 +2196,9 @@ class _PluginState extends State<_Plugin> {
|
||||
|
||||
Widget accountAction() {
|
||||
return Obx(() => _Button(
|
||||
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? 'Login'
|
||||
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||
() => {
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? loginDialog()
|
||||
@@ -2510,6 +2606,49 @@ class WaylandCard extends StatefulWidget {
|
||||
|
||||
class _WaylandCardState extends State<WaylandCard> {
|
||||
final restoreTokenKey = 'wayland-restore-token';
|
||||
static const _kClearShortcutsInhibitorEventKey =
|
||||
'clear-gnome-shortcuts-inhibitor-permission-res';
|
||||
final _clearShortcutsInhibitorFailedMsg = ''.obs;
|
||||
// Don't show the shortcuts permission reset button for now.
|
||||
// Users can change it manually:
|
||||
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
|
||||
// For resetting(clearing) the permission from the portal permission store, you can
|
||||
// use (replace <desktop-id> with the RustDesk desktop file ID):
|
||||
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
|
||||
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
|
||||
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
|
||||
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
|
||||
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
|
||||
//
|
||||
// We may add it back in the future if needed.
|
||||
final showResetInhibitorPermission = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (showResetInhibitorPermission) {
|
||||
platformFFI.registerEventHandler(
|
||||
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
|
||||
(evt) async {
|
||||
if (!mounted) return;
|
||||
if (evt['success'] == true) {
|
||||
setState(() {});
|
||||
} else {
|
||||
_clearShortcutsInhibitorFailedMsg.value =
|
||||
evt['msg'] as String? ?? 'Unknown error';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (showResetInhibitorPermission) {
|
||||
platformFFI.unregisterEventHandler(
|
||||
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -2517,9 +2656,16 @@ class _WaylandCardState extends State<WaylandCard> {
|
||||
future: bind.mainHandleWaylandScreencastRestoreToken(
|
||||
key: restoreTokenKey, value: "get"),
|
||||
hasData: (restoreToken) {
|
||||
final hasShortcutsPermission = showResetInhibitorPermission &&
|
||||
bind.mainGetCommonSync(
|
||||
key: "has-gnome-shortcuts-inhibitor-permission") ==
|
||||
"true";
|
||||
|
||||
final children = [
|
||||
if (restoreToken.isNotEmpty)
|
||||
_buildClearScreenSelection(context, restoreToken),
|
||||
if (hasShortcutsPermission)
|
||||
_buildClearShortcutsInhibitorPermission(context),
|
||||
];
|
||||
return Offstage(
|
||||
offstage: children.isEmpty,
|
||||
@@ -2564,6 +2710,50 @@ class _WaylandCardState extends State<WaylandCard> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
|
||||
onConfirm() {
|
||||
_clearShortcutsInhibitorFailedMsg.value = '';
|
||||
bind.mainSetCommon(
|
||||
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
|
||||
gFFI.dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
showConfirmMsgBox() => msgBoxCommon(
|
||||
gFFI.dialogManager,
|
||||
'Confirmation',
|
||||
Text(
|
||||
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
|
||||
),
|
||||
[
|
||||
dialogButton('OK', onPressed: onConfirm),
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => gFFI.dialogManager.dismissAll())
|
||||
]);
|
||||
|
||||
return Column(children: [
|
||||
Obx(
|
||||
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(_clearShortcutsInhibitorFailedMsg.value,
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(color: Colors.red))
|
||||
.marginOnly(bottom: 10.0)),
|
||||
),
|
||||
_Button(
|
||||
'Reset keyboard shortcuts permission',
|
||||
showConfirmMsgBox,
|
||||
tip: 'clear-shortcuts-inhibitor-permission-tip',
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all<Color>(
|
||||
Theme.of(context).colorScheme.error.withOpacity(0.75)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
@@ -2645,7 +2835,7 @@ Widget _lock(
|
||||
]).marginSymmetric(vertical: 2)),
|
||||
onPressed: () async {
|
||||
final unlockPin = bind.mainGetUnlockPin();
|
||||
if (unlockPin.isEmpty) {
|
||||
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
|
||||
bool checked = await callMainCheckSuperUserPermission();
|
||||
if (checked) {
|
||||
onUnlock();
|
||||
|
||||
@@ -17,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
||||
|
||||
@@ -86,6 +85,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
|
||||
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
||||
final _overlayKeyState = OverlayKeyState();
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
late FFI _ffi;
|
||||
|
||||
@@ -107,9 +107,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
if (isWeb) {
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
@@ -127,9 +125,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
model.close().whenComplete(() {
|
||||
_ffi.close();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
||||
});
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -282,11 +278,9 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
item.state != JobState.inProgress,
|
||||
child: LinearPercentIndicator(
|
||||
animateFromLastPercent: true,
|
||||
center: Text(
|
||||
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
|
||||
),
|
||||
center: Text(item.percentText),
|
||||
barRadius: Radius.circular(15),
|
||||
percent: item.finishedSize / item.totalSize,
|
||||
percent: item.percent,
|
||||
progressColor: MyTheme.accent,
|
||||
backgroundColor: Theme.of(context).hoverColor,
|
||||
lineHeight: kDesktopFileTransferRowHeight,
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
@@ -16,6 +15,7 @@ import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
@@ -85,17 +85,25 @@ class _RemotePageState extends State<RemotePage>
|
||||
late RxBool _zoomCursor;
|
||||
late RxBool _remoteCursorMoved;
|
||||
late RxBool _keyboardEnabled;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||
|
||||
// Debounce timer for pointer lock center updates during window events.
|
||||
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
|
||||
Timer? _pointerLockCenterDebounceTimer;
|
||||
|
||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||
// to identify the toolbar instance and its callback function.
|
||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
Worker? _waylandKeyboardModeWorker;
|
||||
bool _waylandKeyboardModeNormalized = false;
|
||||
bool _waylandKeyboardModeNormalizing = false;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
@@ -138,9 +146,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
@@ -171,6 +177,58 @@ class _RemotePageState extends State<RemotePage>
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController?.onSelected?.call(widget.id);
|
||||
});
|
||||
|
||||
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||
_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
|
||||
void _cancelPointerLockCenterDebounceTimer() {
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -186,6 +244,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
// The cursor will be re-hidden and re-centered when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -196,6 +261,12 @@ class _RemotePageState extends State<RemotePage>
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Restore relative mouse mode constraints when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -206,25 +277,59 @@ class _RemotePageState extends State<RemotePage>
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
// Update pointer lock center when window is restored
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
// Update pointer lock center when window is maximized
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResize() {
|
||||
super.onWindowResize();
|
||||
// Update pointer lock center when window is resized
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMove() {
|
||||
super.onWindowMove();
|
||||
// Update pointer lock center when window is moved
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
/// Update pointer lock center with debouncing to avoid excessive updates
|
||||
/// during rapid window move/resize events.
|
||||
void _updatePointerLockCenterIfNeeded() {
|
||||
if (!_ffi.inputModel.relativeMouseMode.value) return;
|
||||
|
||||
// Cancel any pending update and schedule a new one (debounce pattern)
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = Timer(
|
||||
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.updatePointerLockCenter();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +356,17 @@ class _RemotePageState extends State<RemotePage>
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
// Defensive cleanup: ensure host system-key propagation is reset even if
|
||||
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
_waylandKeyboardModeWorker?.dispose();
|
||||
// Clear callback reference to prevent memory leaks and stale references
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
@@ -261,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.imageModel.disposeImage();
|
||||
_ffi.cursorModel.disposeImages();
|
||||
_rawKeyFocusNode.dispose();
|
||||
if (closeSession) {
|
||||
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||
}
|
||||
await _ffi.close(closeSession: closeSession);
|
||||
_timer?.cancel();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
@@ -268,9 +387,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
@@ -354,10 +471,15 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
}(),
|
||||
// Use Overlay to enable rebuild every time on menu button click.
|
||||
_ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(
|
||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
||||
: remoteToolbar(context),
|
||||
// Hide toolbar when relative mouse mode is active to prevent
|
||||
// cursor from escaping to toolbar area.
|
||||
Obx(() => _ffi.inputModel.relativeMouseMode.value
|
||||
? const Offstage()
|
||||
: _ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(initialEntries: [
|
||||
OverlayEntry(builder: remoteToolbar)
|
||||
])
|
||||
: remoteToolbar(context)),
|
||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||
],
|
||||
),
|
||||
@@ -425,6 +547,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
@@ -450,6 +573,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
@@ -497,33 +621,39 @@ class _RemotePageState extends State<RemotePage>
|
||||
|
||||
Widget getBodyForDesktop(BuildContext context) {
|
||||
var paints = <Widget>[
|
||||
MouseRegion(onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
}, onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
zoomCursor: _zoomCursor,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
keyboardEnabled: _keyboardEnabled,
|
||||
remoteCursorMoved: _remoteCursorMoved,
|
||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}))
|
||||
MouseRegion(
|
||||
onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
},
|
||||
onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
},
|
||||
child: _ViewStyleUpdater(
|
||||
canvasModel: _ffi.canvasModel,
|
||||
inputModel: _ffi.inputModel,
|
||||
child: Builder(builder: (context) {
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
zoomCursor: _zoomCursor,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
keyboardEnabled: _keyboardEnabled,
|
||||
remoteCursorMoved: _remoteCursorMoved,
|
||||
listenerBuilder: (child) =>
|
||||
_buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
if (!_ffi.canvasModel.cursorEmbedded) {
|
||||
@@ -552,6 +682,63 @@ class _RemotePageState extends State<RemotePage>
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
|
||||
/// and InputModel.updateImageWidgetSize() only when size actually changes.
|
||||
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
|
||||
class _ViewStyleUpdater extends StatefulWidget {
|
||||
final CanvasModel canvasModel;
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
|
||||
const _ViewStyleUpdater({
|
||||
Key? key,
|
||||
required this.canvasModel,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
|
||||
}
|
||||
|
||||
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
||||
Size? _lastSize;
|
||||
bool _callbackScheduled = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxHeight = constraints.maxHeight;
|
||||
// Guard against infinite constraints (e.g., unconstrained ancestor).
|
||||
if (!maxWidth.isFinite || !maxHeight.isFinite) {
|
||||
return widget.child;
|
||||
}
|
||||
final newSize = Size(maxWidth, maxHeight);
|
||||
if (_lastSize != newSize) {
|
||||
_lastSize = newSize;
|
||||
// Schedule the update for after the current frame to avoid setState during build.
|
||||
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
|
||||
// when size changes rapidly before any callback executes.
|
||||
if (!_callbackScheduled) {
|
||||
_callbackScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_callbackScheduled = false;
|
||||
final currentSize = _lastSize;
|
||||
if (mounted && currentSize != null) {
|
||||
widget.canvasModel.updateViewStyle();
|
||||
widget.inputModel.updateImageWidgetSize(currentSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return widget.child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePaint extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final String id;
|
||||
@@ -616,21 +803,24 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
cursor: cursorOverImage.isTrue
|
||||
? c.cursorEmbedded
|
||||
? SystemMouseCursors.none
|
||||
: keyboardEnabled.isTrue
|
||||
? (() {
|
||||
if (remoteCursorMoved.isTrue) {
|
||||
_lastRemoteCursorMoved = true;
|
||||
return SystemMouseCursors.none;
|
||||
} else {
|
||||
if (_lastRemoteCursorMoved) {
|
||||
_lastRemoteCursorMoved = false;
|
||||
_firstEnterImage.value = true;
|
||||
}
|
||||
return _buildCustomCursor(
|
||||
context, getCursorScale());
|
||||
}
|
||||
}())
|
||||
: _buildDisabledCursor(context, getCursorScale())
|
||||
// Hide cursor when relative mouse mode is active
|
||||
: widget.ffi.inputModel.relativeMouseMode.value
|
||||
? SystemMouseCursors.none
|
||||
: keyboardEnabled.isTrue
|
||||
? (() {
|
||||
if (remoteCursorMoved.isTrue) {
|
||||
_lastRemoteCursorMoved = true;
|
||||
return SystemMouseCursors.none;
|
||||
} else {
|
||||
if (_lastRemoteCursorMoved) {
|
||||
_lastRemoteCursorMoved = false;
|
||||
_firstEnterImage.value = true;
|
||||
}
|
||||
return _buildCustomCursor(
|
||||
context, getCursorScale());
|
||||
}
|
||||
}())
|
||||
: _buildDisabledCursor(context, getCursorScale())
|
||||
: MouseCursor.defer,
|
||||
onHover: (evt) {},
|
||||
child: child);
|
||||
|
||||
@@ -135,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
body: DesktopTab(
|
||||
controller: tabController,
|
||||
onWindowCloseButton: handleWindowCloseButton,
|
||||
tail: const AddButton(),
|
||||
tail: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RelativeMouseModeHint(tabController: tabController),
|
||||
const AddButton(),
|
||||
],
|
||||
),
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
pageViewBuilder: (pageView) => pageView,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
@@ -251,11 +257,11 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
toolbarState.switchHide(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -374,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
loopCloseWindow();
|
||||
}
|
||||
ConnectionTypeState.delete(id);
|
||||
// Clean up relative mouse mode state for this peer.
|
||||
stateGlobal.relativeMouseModeState.remove(id);
|
||||
_update_remote_count();
|
||||
}
|
||||
|
||||
@@ -548,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
|
||||
/// This helps users remember how to exit relative mouse mode.
|
||||
class _RelativeMouseModeHint extends StatelessWidget {
|
||||
final DesktopTabController tabController;
|
||||
|
||||
const _RelativeMouseModeHint({Key? key, required this.tabController})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
// Check if there are any tabs
|
||||
if (tabController.state.value.tabs.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Get current selected tab's RemotePage
|
||||
final selectedTabInfo = tabController.state.value.selectedTabInfo;
|
||||
if (selectedTabInfo.page is! RemotePage) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final remotePage = selectedTabInfo.page as RemotePage;
|
||||
final String peerId = remotePage.id;
|
||||
|
||||
// Use global state to check relative mouse mode (synced from InputModel).
|
||||
// This avoids timing issues with FFI registration.
|
||||
final isRelativeMouseMode =
|
||||
stateGlobal.relativeMouseModeState[peerId] ?? false;
|
||||
|
||||
if (!isRelativeMouseMode) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mouse,
|
||||
size: 14,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
translate(
|
||||
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: str2color(client.name),
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
child: Text(
|
||||
client.name[0],
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 55,
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 10.0),
|
||||
_buildClientAvatar().marginOnly(right: 10.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@@ -582,6 +566,36 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
Widget _buildClientAvatar() {
|
||||
return buildAvatarWidget(
|
||||
avatar: client.avatar,
|
||||
size: 70,
|
||||
borderRadius: 15,
|
||||
fallback: _buildInitialAvatar(),
|
||||
) ??
|
||||
_buildInitialAvatar();
|
||||
}
|
||||
|
||||
Widget _buildInitialAvatar() {
|
||||
return Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: str2color(client.name),
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
child: Text(
|
||||
client.name.isNotEmpty ? client.name[0] : '?',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 55,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivilegeBoard extends StatefulWidget {
|
||||
@@ -596,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget {
|
||||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
late final client = widget.client;
|
||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||
Function(bool)? onTap, String tooltipText) {
|
||||
Function(bool)? onTap, String tooltipText,
|
||||
{required bool canModify}) {
|
||||
return Tooltip(
|
||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||
waitDuration: Duration.zero,
|
||||
child: Container(
|
||||
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),
|
||||
),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
||||
onTap: canModify
|
||||
? () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
@@ -629,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = 4;
|
||||
final spacing = 10.0;
|
||||
final canModifyPermission =
|
||||
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||
'N';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 160.0,
|
||||
@@ -675,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -689,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
]
|
||||
: [
|
||||
@@ -705,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
@@ -719,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
@@ -733,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
@@ -747,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
@@ -761,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -775,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
@@ -791,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
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,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
@@ -15,6 +16,7 @@ class TerminalPage extends StatefulWidget {
|
||||
required this.tabController,
|
||||
required this.isSharedPassword,
|
||||
required this.terminalId,
|
||||
required this.tabKey,
|
||||
this.forceRelay,
|
||||
this.connToken,
|
||||
}) : super(key: key);
|
||||
@@ -25,6 +27,9 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
final String tabKey;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
|
||||
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
||||
@@ -39,14 +44,22 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
static const EdgeInsets _defaultTerminalPadding =
|
||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Listen for tab selection changes to request focus
|
||||
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
|
||||
|
||||
// Use shared FFI instance from connection manager
|
||||
_ffi = TerminalConnectionManager.getConnection(
|
||||
peerId: widget.id,
|
||||
@@ -64,6 +77,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||
_cellHeight = ph * 1.0;
|
||||
|
||||
// Enable focus once terminal has valid dimensions (first valid resize)
|
||||
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
|
||||
_terminalFocusNode.canRequestFocus = true;
|
||||
// Auto-focus if this tab is currently selected
|
||||
_requestFocusIfSelected();
|
||||
}
|
||||
|
||||
// Schedule the setState for the next frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
@@ -99,25 +119,67 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Cancel tab state subscription to prevent memory leak
|
||||
_tabStateSubscription?.cancel();
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_terminalFocusNode.dispose();
|
||||
// Release connection reference instead of closing directly
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabStateChanged(DesktopTabState state) {
|
||||
// Check if this tab is now selected and request focus
|
||||
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
||||
final selectedTab = state.tabs[state.selected];
|
||||
if (selectedTab.key == widget.tabKey && mounted) {
|
||||
_requestFocusIfSelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _requestFocusIfSelected() {
|
||||
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
|
||||
// Use post-frame callback to ensure widget is fully laid out in focus tree
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
|
||||
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
|
||||
final state = widget.tabController.state.value;
|
||||
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
||||
if (state.tabs[state.selected].key == widget.tabKey) {
|
||||
_terminalFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This method ensures that the number of visible rows is an integer by computing the
|
||||
// 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.
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cellHeight = _cellHeight;
|
||||
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;
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -131,7 +193,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
focusNode: _terminalFocusNode,
|
||||
// Note: autofocus is not used here because focus is managed manually
|
||||
// via _onTabStateChanged() to handle tab switching properly.
|
||||
backgroundOpacity: 0.7,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
|
||||
@@ -34,6 +34,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
static const IconData selectedIcon = Icons.terminal;
|
||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||
int _nextTerminalId = 1;
|
||||
// Lightweight idempotency guard for async close operations
|
||||
final Set<String> _closingTabs = {};
|
||||
// When true, all session cleanup should persist (window-level close in progress)
|
||||
bool _windowClosing = false;
|
||||
|
||||
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||
@@ -42,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
peerId: params['id'],
|
||||
@@ -70,28 +75,12 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
label: tabLabel,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabKey,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
// Close the terminal session first
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
|
||||
if (ffi != null) {
|
||||
final terminalModel = ffi.terminalModels[terminalId];
|
||||
if (terminalModel != null) {
|
||||
await terminalModel.closeTerminal();
|
||||
}
|
||||
}
|
||||
// Then close the tab
|
||||
tabController.closeBy(tabKey);
|
||||
},
|
||||
onTabCloseButton: () => _closeTab(tabKey),
|
||||
page: TerminalPage(
|
||||
key: ValueKey(tabKey),
|
||||
id: peerId,
|
||||
terminalId: terminalId,
|
||||
tabKey: tabKey,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
tabController: tabController,
|
||||
@@ -101,6 +90,161 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
|
||||
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
|
||||
Future<void> _closeTab(String tabKey) async {
|
||||
// Idempotency guard: skip if already closing this tab
|
||||
if (_closingTabs.contains(tabKey)) return;
|
||||
_closingTabs.add(tabKey);
|
||||
|
||||
try {
|
||||
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
|
||||
// _closeAllTabs clearing tabController (which would make the live count
|
||||
// drop to 0 and incorrectly trigger session persistence).
|
||||
// Note: the snapshot may become stale if other individual tabs are closed
|
||||
// during the audit dialog, but this is an acceptable trade-off.
|
||||
int? snapshotPeerTabCount;
|
||||
final parsed = _parseTabKey(tabKey);
|
||||
if (parsed != null) {
|
||||
final (peerId, _) = parsed;
|
||||
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
|
||||
final p = _parseTabKey(t.key);
|
||||
return p != null && p.$1 == peerId;
|
||||
}).length;
|
||||
}
|
||||
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabKey,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close terminal session if not in persistent mode.
|
||||
// Wrapped separately so session cleanup failure never blocks UI tab removal.
|
||||
try {
|
||||
await _closeTerminalSessionIfNeeded(tabKey,
|
||||
peerTabCount: snapshotPeerTabCount);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
|
||||
}
|
||||
// Always close the tab from UI, regardless of session cleanup result
|
||||
tabController.closeBy(tabKey);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
|
||||
} finally {
|
||||
_closingTabs.remove(tabKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all tabs with session cleanup.
|
||||
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
|
||||
/// UI tabs are removed immediately; session cleanup runs in parallel with a
|
||||
/// bounded timeout so window close is not blocked indefinitely.
|
||||
Future<void> _closeAllTabs() async {
|
||||
_windowClosing = true;
|
||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||
// 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();
|
||||
// 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.
|
||||
final futures = tabKeys
|
||||
.where((tabKey) => !_closingTabs.contains(tabKey))
|
||||
.map((tabKey) async {
|
||||
try {
|
||||
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
|
||||
}
|
||||
}).toList();
|
||||
if (futures.isNotEmpty) {
|
||||
await Future.wait(futures).timeout(
|
||||
const Duration(seconds: 4),
|
||||
onTimeout: () {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Session cleanup timed out for batch close');
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the terminal session on server side based on persistent mode.
|
||||
///
|
||||
/// [persistAll] controls behavior when persistent mode is enabled:
|
||||
/// - `true` (window close): persist all sessions, don't close any.
|
||||
/// - `false` (tab close): only persist the last session for the peer,
|
||||
/// close others so only the most recent disconnected session survives.
|
||||
///
|
||||
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
|
||||
/// in-flight _closeTab() calls don't accidentally close sessions that the
|
||||
/// window-close flow intends to preserve.
|
||||
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
|
||||
{bool persistAll = false, int? peerTabCount}) async {
|
||||
// If window close is in progress, override to persist all sessions
|
||||
// even if this call originated from an individual tab close.
|
||||
if (_windowClosing) {
|
||||
persistAll = true;
|
||||
}
|
||||
final parsed = _parseTabKey(tabKey);
|
||||
if (parsed == null) return;
|
||||
final (peerId, terminalId) = parsed;
|
||||
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
|
||||
if (ffi == null) return;
|
||||
|
||||
final isPersistent = bind.sessionGetToggleOptionSync(
|
||||
sessionId: ffi.sessionId,
|
||||
arg: kOptionTerminalPersistent,
|
||||
);
|
||||
|
||||
if (isPersistent) {
|
||||
if (persistAll) {
|
||||
// Window close: persist all sessions
|
||||
return;
|
||||
}
|
||||
// Tab close: only persist if this is the last tab for this peer.
|
||||
// Use the snapshot value if provided (avoids race with concurrent tab removal).
|
||||
final effectivePeerTabCount = peerTabCount ??
|
||||
tabController.state.value.tabs.where((t) {
|
||||
final p = _parseTabKey(t.key);
|
||||
return p != null && p.$1 == peerId;
|
||||
}).length;
|
||||
if (effectivePeerTabCount <= 1) {
|
||||
// Last tab for this peer — persist the session
|
||||
return;
|
||||
}
|
||||
// Not the last tab — fall through to close the session
|
||||
}
|
||||
|
||||
final terminalModel = ffi.terminalModels[terminalId];
|
||||
if (terminalModel != null) {
|
||||
// closeTerminal() has internal 3s timeout, no need for external timeout
|
||||
await terminalModel.closeTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse tabKey (format: "peerId_terminalId") into its components.
|
||||
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
|
||||
/// Returns null if tabKey format is invalid.
|
||||
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
|
||||
final lastUnderscore = tabKey.lastIndexOf('_');
|
||||
if (lastUnderscore <= 0) {
|
||||
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
|
||||
return null;
|
||||
}
|
||||
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
|
||||
final terminalId = int.tryParse(terminalIdStr);
|
||||
if (terminalId == null) {
|
||||
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
|
||||
return null;
|
||||
}
|
||||
final peerId = tabKey.substring(0, lastUnderscore);
|
||||
return (peerId, terminalId);
|
||||
}
|
||||
|
||||
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
|
||||
final List<MenuEntryBase<String>> menu = [];
|
||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||
@@ -184,7 +328,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
} else if (call.method == kWindowEventRestoreTerminalSessions) {
|
||||
_restoreSessions(call.arguments);
|
||||
} else if (call.method == "onDestroy") {
|
||||
tabController.clear();
|
||||
// Clean up sessions before window destruction (bounded wait)
|
||||
await _closeAllTabs();
|
||||
} else if (call.method == kWindowActionRebuild) {
|
||||
reloadCurrentWindow();
|
||||
} else if (call.method == kWindowEventActiveSession) {
|
||||
@@ -194,7 +339,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
assert(call.arguments is String,
|
||||
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
|
||||
if (currentTab.key.startsWith(call.arguments)) {
|
||||
// Use lastIndexOf to handle peerIds containing underscores
|
||||
final lastUnderscore = currentTab.key.lastIndexOf('_');
|
||||
if (lastUnderscore > 0 &&
|
||||
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
|
||||
windowOnTop(windowId());
|
||||
return true;
|
||||
}
|
||||
@@ -223,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final persistentSessions =
|
||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
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) {
|
||||
_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
|
||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||
// may be called prematurely while the tab widget is still in the tab controller.
|
||||
@@ -265,7 +439,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
// macOS: Cmd+W (standard for close tab)
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
tabController.closeBy(currentTab.key);
|
||||
_closeTab(currentTab.key);
|
||||
return true;
|
||||
}
|
||||
} else if (!isMacOS &&
|
||||
@@ -274,7 +448,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
tabController.closeBy(currentTab.key);
|
||||
_closeTab(currentTab.key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -329,7 +503,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
void _addNewTerminal(String peerId, {int? terminalId}) {
|
||||
// Find first tab for this peer to get connection parameters
|
||||
final firstTab = tabController.state.value.tabs.firstWhere(
|
||||
(tab) => tab.key.startsWith('$peerId\_'),
|
||||
(tab) {
|
||||
final last = tab.key.lastIndexOf('_');
|
||||
return last > 0 && tab.key.substring(0, last) == peerId;
|
||||
},
|
||||
);
|
||||
if (firstTab.page is TerminalPage) {
|
||||
final page = firstTab.page as TerminalPage;
|
||||
@@ -350,11 +527,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
|
||||
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
final parts = currentTab.key.split('_');
|
||||
if (parts.isNotEmpty) {
|
||||
final peerId = parts[0];
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
}
|
||||
final parsed = _parseTabKey(currentTab.key);
|
||||
if (parsed == null) return;
|
||||
final (peerId, _) = parsed;
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -368,10 +544,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
tabMenuBuilder: (key) {
|
||||
// Extract peerId from tab key (format: "peerId_terminalId")
|
||||
final parts = key.split('_');
|
||||
if (parts.isEmpty) return Container();
|
||||
final peerId = parts[0];
|
||||
final parsed = _parseTabKey(key);
|
||||
if (parsed == null) return Container();
|
||||
final (peerId, _) = parsed;
|
||||
return _tabMenuBuilder(peerId, () {});
|
||||
},
|
||||
));
|
||||
@@ -400,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeWindowFromConnection() async {
|
||||
await _closeAllTabs();
|
||||
await WindowController.fromWindowId(windowId()).close();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
@@ -426,7 +606,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
}
|
||||
}
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
await _closeAllTabs();
|
||||
return true;
|
||||
} else {
|
||||
final bool res;
|
||||
@@ -437,7 +617,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
tabController.clear();
|
||||
await _closeAllTabs();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
@@ -77,6 +76,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
final _cursorOverImage = false.obs;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
@@ -124,9 +124,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
@@ -185,26 +183,20 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -247,9 +239,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
@@ -465,7 +455,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
|
||||
@@ -250,11 +250,11 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
toolbarState.switchHide(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ class DesktopTabController {
|
||||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
@@ -592,14 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
}
|
||||
|
||||
Widget _buildBar() {
|
||||
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
// custom double tap handler
|
||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
||||
showMaximize
|
||||
onTap: !isIncomingHomePage && showMaximize
|
||||
? () {
|
||||
final current = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = current - _lastClickTime;
|
||||
@@ -610,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
.then((value) => stateGlobal.setMaximized(value));
|
||||
}
|
||||
}
|
||||
: null,
|
||||
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||
onPanStart: (_) => startDragging(isMainWindow),
|
||||
onPanCancel: () {
|
||||
// We want to disable dragging of the tab area in the tab bar.
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'common.dart';
|
||||
import 'consts.dart';
|
||||
import 'mobile/pages/home_page.dart';
|
||||
import 'mobile/pages/server_page.dart';
|
||||
import 'mobile/widgets/deploy_dialog.dart';
|
||||
import 'models/platform_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||
@@ -575,6 +576,14 @@ _registerEventHandler() {
|
||||
NativeUiHandler.instance.onEvent(evt);
|
||||
});
|
||||
}
|
||||
if (isAndroid) {
|
||||
platformFFI.registerEventHandler(
|
||||
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showDeployPromptDialog();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
@@ -72,6 +71,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
showLocal ? model.localController : model.remoteController;
|
||||
FileDirectory get currentDir => currentFileController.directory.value;
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -86,7 +86,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
|
||||
WakelockPlus.enable();
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -94,7 +94,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
model.close().whenComplete(() {
|
||||
gFFI.close();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
WakelockPlus.disable();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
});
|
||||
model.jobController.clear();
|
||||
super.dispose();
|
||||
@@ -355,15 +355,21 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return Offstage();
|
||||
}
|
||||
|
||||
switch (jobTable.last.state) {
|
||||
// Find the first job that is in progress (the one actually transferring data)
|
||||
// Rust backend processes jobs sequentially, so the first inProgress job is the active one
|
||||
final activeJob = jobTable
|
||||
.firstWhereOrNull((job) => job.state == JobState.inProgress) ??
|
||||
jobTable.last;
|
||||
|
||||
switch (activeJob.state) {
|
||||
case JobState.inProgress:
|
||||
return BottomSheetBody(
|
||||
leading: CircularProgressIndicator(),
|
||||
title: translate("Waiting"),
|
||||
text:
|
||||
"${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s",
|
||||
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||
onCanceled: () {
|
||||
model.jobController.cancelJob(jobTable.last.id);
|
||||
model.jobController.cancelJob(activeJob.id);
|
||||
jobTable.clear();
|
||||
},
|
||||
);
|
||||
@@ -371,7 +377,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return BottomSheetBody(
|
||||
leading: Icon(Icons.check),
|
||||
title: "${translate("Successful")}!",
|
||||
text: jobTable.last.display(),
|
||||
text: activeJob.display(),
|
||||
onCanceled: () => jobTable.clear(),
|
||||
);
|
||||
case JobState.error:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -14,7 +13,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
@@ -66,9 +64,8 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
bool _showGestureHelp = false;
|
||||
String _value = '';
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
Timer? _timerDidChangeMetrics;
|
||||
final _uniqueKey = UniqueKey();
|
||||
Timer? _iosKeyboardWorkaroundTimer;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
@@ -78,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
|
||||
Worker? _waylandKeyboardGateWorker;
|
||||
bool _waylandKeyboardGateInitialized = false;
|
||||
|
||||
InputModel get inputModel => gFFI.inputModel;
|
||||
SessionID get sessionId => gFFI.sessionId;
|
||||
|
||||
@@ -105,9 +105,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
@@ -126,11 +124,33 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
});
|
||||
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
|
||||
Future<void> dispose() async {
|
||||
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
|
||||
super.dispose();
|
||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||
@@ -140,15 +160,16 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.dispose();
|
||||
_physicalFocusNode.dispose();
|
||||
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||
_waylandKeyboardGateWorker?.dispose();
|
||||
inputModel.keyboardInputAllowed = true;
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await keyboardSubscription.cancel();
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
@@ -170,24 +191,38 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.invokeMethod("try_sync_clipboard");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
|
||||
// Don't try reset the view style and focus the cursor.
|
||||
if (gFFI.cursorModel.lastKeyboardIsVisible &&
|
||||
gFFI.canvasModel.isMobileCanvasChanged) {
|
||||
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;
|
||||
}
|
||||
|
||||
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
|
||||
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
|
||||
if (newBottom != _viewInsetsBottom) {
|
||||
gFFI.canvasModel.mobileFocusCanvasCursor();
|
||||
_viewInsetsBottom = newBottom;
|
||||
}
|
||||
});
|
||||
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.
|
||||
@@ -211,7 +246,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.ffiModel.pi.version.isNotEmpty) {
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
|
||||
// Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden
|
||||
// https://github.com/flutter/flutter/issues/39900
|
||||
// https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue
|
||||
if (isIOS) {
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () {
|
||||
if (!mounted) return;
|
||||
_physicalFocusNode.unfocus();
|
||||
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () {
|
||||
if (!mounted) return;
|
||||
_physicalFocusNode.requestFocus();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
_iosKeyboardWorkaroundTimer = null;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
@@ -304,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
content == '【】')) {
|
||||
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
|
||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||
openKeyboard();
|
||||
_openKeyboardUnlocked();
|
||||
return;
|
||||
}
|
||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||
@@ -316,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
|
||||
// handle mobile virtual keyboard
|
||||
void handleSoftKeyboardInput(String newValue) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (isIOS) {
|
||||
_handleIOSSoftKeyboardInput(newValue);
|
||||
} else {
|
||||
@@ -324,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void inputChar(String char) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (char == '\n') {
|
||||
char = 'VK_RETURN';
|
||||
} else if (char == ' ') {
|
||||
@@ -333,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
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);
|
||||
// destroy first, so that our _value trick can work
|
||||
_value = initText;
|
||||
@@ -436,12 +517,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -569,7 +648,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
bool get showCursorPaint =>
|
||||
!gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
|
||||
!gFFI.ffiModel.isPeerAndroid &&
|
||||
!gFFI.canvasModel.cursorEmbedded &&
|
||||
!gFFI.inputModel.relativeMouseMode.value;
|
||||
|
||||
Widget getBodyForMobile() {
|
||||
final keyboardIsVisible = keyboardVisibilityController.isVisible;
|
||||
@@ -808,6 +889,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
|
||||
},
|
||||
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
|
||||
inputModel: gFFI.inputModel,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1192,7 +1274,8 @@ void showOptions(
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
if (privacyModeList.length == 1) {
|
||||
displayToggles.add(privacyModeList[0]);
|
||||
|
||||
@@ -61,12 +61,13 @@ class _DropDownAction extends StatelessWidget {
|
||||
final isAllowNumericOneTimePassword =
|
||||
gFFI.serverModel.allowNumericOneTimePassword;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
enabled: gFFI.serverModel.connectStatus > 0,
|
||||
value: "changeID",
|
||||
child: Text(translate("Change ID")),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
if (!isChangeIdDisabled())
|
||||
PopupMenuItem(
|
||||
enabled: gFFI.serverModel.connectStatus > 0,
|
||||
value: "changeID",
|
||||
child: Text(translate("Change ID")),
|
||||
),
|
||||
if (!isChangeIdDisabled()) const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'AcceptSessionsViaPassword',
|
||||
child: listTile(
|
||||
@@ -87,7 +88,8 @@ class _DropDownAction extends StatelessWidget {
|
||||
),
|
||||
if (showPasswordOption) const PopupMenuDivider(),
|
||||
if (showPasswordOption &&
|
||||
verificationMethod != kUseTemporaryPassword)
|
||||
verificationMethod != kUseTemporaryPassword &&
|
||||
!isChangePermanentPasswordDisabled())
|
||||
PopupMenuItem(
|
||||
value: "setPermanentPassword",
|
||||
child: Text(translate("Set permanent password")),
|
||||
@@ -148,7 +150,12 @@ class _DropDownAction extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (value == kUsePermanentPassword &&
|
||||
(await bind.mainGetPermanentPassword()).isEmpty) {
|
||||
(await bind.mainGetCommon(key: "permanent-password-set")) !=
|
||||
"true") {
|
||||
if (isChangePermanentPasswordDisabled()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
setPasswordDialog(notEmptyCallback: callback);
|
||||
} else {
|
||||
callback();
|
||||
@@ -576,10 +583,20 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
Widget build(BuildContext context) {
|
||||
final serverModel = Provider.of<ServerModel>(context);
|
||||
final hasAudioPermission = androidVersion >= 30;
|
||||
final hideStopService = isAndroid &&
|
||||
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(
|
||||
title: translate("Permissions"),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
serverModel.mediaOk
|
||||
serverModel.mediaOk && !hideStopService
|
||||
? ElevatedButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor:
|
||||
@@ -589,21 +606,30 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
label: Text(translate("Stop service")))
|
||||
.marginOnly(bottom: 8)
|
||||
: SizedBox.shrink(),
|
||||
if (!hideStopService || !serverModel.mediaOk)
|
||||
PermissionRow(
|
||||
translate("Screen Capture"),
|
||||
serverModel.mediaOk,
|
||||
!serverModel.mediaOk &&
|
||||
gFFI.userModel.userName.value.isEmpty &&
|
||||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||
? () => showScamWarning(context, serverModel)
|
||||
: serverModel.toggleService),
|
||||
PermissionRow(
|
||||
translate("Screen Capture"),
|
||||
serverModel.mediaOk,
|
||||
!serverModel.mediaOk &&
|
||||
gFFI.userModel.userName.value.isEmpty &&
|
||||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||
? () => showScamWarning(context, serverModel)
|
||||
: serverModel.toggleService),
|
||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
||||
serverModel.toggleInput),
|
||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
||||
serverModel.toggleFile),
|
||||
translate("Input Control"),
|
||||
serverModel.inputOk,
|
||||
serverModel.toggleInput,
|
||||
),
|
||||
PermissionRow(
|
||||
translate("Transfer file"),
|
||||
serverModel.fileOk,
|
||||
serverModel.toggleFile,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
hasAudioPermission
|
||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||
serverModel.toggleAudio)
|
||||
serverModel.toggleAudio,
|
||||
enabled: !permissionChangeLocked)
|
||||
: Row(children: [
|
||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||
Expanded(
|
||||
@@ -612,19 +638,25 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
style: const TextStyle(color: MyTheme.darkGray),
|
||||
))
|
||||
]),
|
||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard),
|
||||
PermissionRow(
|
||||
translate("Enable clipboard"),
|
||||
serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
final String name;
|
||||
final bool isOk;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -633,9 +665,11 @@ class PermissionRow extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
title: Text(name),
|
||||
value: isOk,
|
||||
onChanged: (bool value) {
|
||||
onPressed();
|
||||
});
|
||||
onChanged: enabled
|
||||
? (bool value) {
|
||||
onPressed();
|
||||
}
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +682,8 @@ class ConnectionManager extends StatelessWidget {
|
||||
return Column(
|
||||
children: serverModel.clients
|
||||
.map((client) => PaddingCard(
|
||||
title: translate(client.isFileTransfer
|
||||
? "Transfer file"
|
||||
: "Share screen"),
|
||||
title: translate(
|
||||
client.isFileTransfer ? "Transfer file" : "Share screen"),
|
||||
titleIcon: client.isFileTransfer
|
||||
? Icon(Icons.folder_outlined)
|
||||
: Icon(Icons.mobile_screen_share),
|
||||
@@ -836,13 +869,7 @@ class ClientInfo extends StatelessWidget {
|
||||
flex: -1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: str2color(
|
||||
client.name,
|
||||
Theme.of(context).brightness == Brightness.light
|
||||
? 255
|
||||
: 150),
|
||||
child: Text(client.name[0])))),
|
||||
child: _buildAvatar(context))),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -855,6 +882,20 @@ class ClientInfo extends StatelessWidget {
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context) {
|
||||
final fallback = CircleAvatar(
|
||||
backgroundColor: str2color(client.name,
|
||||
Theme.of(context).brightness == Brightness.light ? 255 : 150),
|
||||
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
|
||||
);
|
||||
return buildAvatarWidget(
|
||||
avatar: client.avatar,
|
||||
size: 40,
|
||||
fallback: fallback,
|
||||
) ??
|
||||
fallback;
|
||||
}
|
||||
}
|
||||
|
||||
void androidChannelInit() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../common/widgets/login.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/deploy_dialog.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import 'home_page.dart';
|
||||
import 'scan_page.dart';
|
||||
@@ -71,6 +72,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _ignoreBatteryOpt = false;
|
||||
var _enableStartOnBoot = false;
|
||||
var _checkUpdateOnStartup = false;
|
||||
var _showTerminalExtraKeys = false;
|
||||
var _floatingWindowDisabled = false;
|
||||
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
|
||||
var _enableAbr = false;
|
||||
@@ -99,6 +101,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _enableIpv6Punch = false;
|
||||
var _isUsingPublicServer = false;
|
||||
var _allowAskForNoteAtEndOfConnection = false;
|
||||
var _preventSleepWhileConnected = true;
|
||||
|
||||
_SettingsState() {
|
||||
_enableAbr = option2bool(
|
||||
@@ -139,6 +142,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
_preventSleepWhileConnected =
|
||||
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||
_showTerminalExtraKeys =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -602,6 +609,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
enhancementsTiles.add(
|
||||
SettingsTile.switchTile(
|
||||
initialValue: _showTerminalExtraKeys,
|
||||
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(translate('Show terminal extra keys')),
|
||||
]),
|
||||
onToggle: (bool v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
setState(() {
|
||||
_showTerminalExtraKeys = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
onFloatingWindowChanged(bool toValue) async {
|
||||
if (toValue) {
|
||||
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
|
||||
@@ -665,8 +689,18 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
SettingsTile(
|
||||
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
|
||||
? translate('Login')
|
||||
: '${translate('Logout')} (${gFFI.userModel.userName.value})')),
|
||||
leading: Icon(Icons.person),
|
||||
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
|
||||
leading: Obx(() {
|
||||
final avatar = bind.mainResolveAvatarUrl(
|
||||
avatar: gFFI.userModel.avatar.value);
|
||||
return buildAvatarWidget(
|
||||
avatar: avatar,
|
||||
size: 28,
|
||||
borderRadius: null,
|
||||
fallback: Icon(Icons.person),
|
||||
) ??
|
||||
Icon(Icons.person);
|
||||
}),
|
||||
onPressed: (context) {
|
||||
if (gFFI.userModel.userName.value.isEmpty) {
|
||||
loginDialog();
|
||||
@@ -695,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
onPressed: (context) {
|
||||
changeSocks5Proxy();
|
||||
}),
|
||||
if (isAndroid && !bind.isOutgoingOnly())
|
||||
SettingsTile(
|
||||
title: Text(translate('Deploy')),
|
||||
leading: Icon(Icons.cloud_upload),
|
||||
onPressed: (context) {
|
||||
showDeployDialog();
|
||||
}),
|
||||
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Use WebSocket')),
|
||||
@@ -786,19 +827,37 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
showThemeSettings(gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
initialValue: _allowAskForNoteAtEndOfConnection,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowAskForNoteAtEndOfConnection, v);
|
||||
final newValue = mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection);
|
||||
setState(() {
|
||||
_allowAskForNoteAtEndOfConnection = newValue;
|
||||
});
|
||||
},
|
||||
)
|
||||
if (!bind.isDisableAccount())
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
initialValue: _allowAskForNoteAtEndOfConnection,
|
||||
onToggle: (v) async {
|
||||
if (v && !gFFI.userModel.isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
}
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowAskForNoteAtEndOfConnection, v);
|
||||
final newValue = mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection);
|
||||
setState(() {
|
||||
_allowAskForNoteAtEndOfConnection = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title:
|
||||
Text(translate('keep-awake-during-outgoing-sessions-label')),
|
||||
initialValue: _preventSleepWhileConnected,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionKeepAwakeDuringOutgoingSessions, v);
|
||||
setState(() {
|
||||
_preventSleepWhileConnected = v;
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
if (isAndroid)
|
||||
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
@@ -7,6 +10,7 @@ import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
@@ -29,9 +33,18 @@ class TerminalPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
double _sysKeyboardHeight = 0;
|
||||
Timer? _keyboardDebounce;
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -44,6 +57,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
debugPrint(
|
||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||
@@ -62,13 +76,25 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||
_cellHeight = ph * 1.0;
|
||||
};
|
||||
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Web desktop users have full hardware keyboard access, so the on-screen
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
|
||||
if (_showTerminalExtraKeys) {
|
||||
_updateKeyboardHeight();
|
||||
}
|
||||
});
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
@@ -78,10 +104,43 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
|
||||
_keyboardDebounce?.cancel();
|
||||
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
setState(() {
|
||||
_sysKeyboardHeight = bottomInset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _updateKeyboardHeight() {
|
||||
if (_keyboardKey.currentContext != null) {
|
||||
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
|
||||
_keyboardHeight = renderBox.size.height;
|
||||
}
|
||||
}
|
||||
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
}
|
||||
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
|
||||
final rows = (realHeight / _cellHeight!).floor();
|
||||
final extraSpace = realHeight - rows * _cellHeight!;
|
||||
final topBottom = max(0.0, extraSpace / 2.0);
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -95,31 +154,275 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
return Scaffold(
|
||||
final scaffold = Scaffold(
|
||||
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
// The following comment is from xterm.dart source code:
|
||||
// Workaround to detect delete key for platforms and IMEs that do not
|
||||
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||
// default.
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
|
||||
// iOS-style circular close button in top-right corner
|
||||
if (isIOS) _buildCloseButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add iOS edge swipe gesture to exit (similar to Android back button)
|
||||
if (isIOS) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenWidth = constraints.maxWidth;
|
||||
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
|
||||
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
|
||||
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
|
||||
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
|
||||
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
|
||||
() => HorizontalDragGestureRecognizer(
|
||||
debugOwner: this,
|
||||
// Only respond to touch input, exclude mouse/trackpad
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(HorizontalDragGestureRecognizer instance) {
|
||||
instance
|
||||
// Capture initial touch-down position (before touch slop)
|
||||
..onDown = (details) {
|
||||
_swipeStartX = details.localPosition.dx;
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onUpdate = (details) {
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onEnd = (details) {
|
||||
// Check if swipe started from left edge and moved right
|
||||
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
|
||||
clientClose(sessionId, _ffi);
|
||||
}
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
}
|
||||
..onCancel = () {
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: scaffold,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return scaffold;
|
||||
}
|
||||
|
||||
Widget _buildCloseButton() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
minimum: const EdgeInsets.only(
|
||||
top: 16, // iOS standard margin
|
||||
right: 16, // iOS standard margin
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: translate('Close'),
|
||||
child: Container(
|
||||
width: 44, // iOS standard tap target size
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5), // Half transparency
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
clientClose(sessionId, _ffi);
|
||||
},
|
||||
child: Tooltip(
|
||||
message: translate('Close'),
|
||||
child: const Icon(
|
||||
Icons.chevron_left, // iOS-style back arrow
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingKeyboard() {
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _sysKeyboardHeight,
|
||||
child: Container(
|
||||
key: _keyboardKey,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyButton(String label) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(48, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String? send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
send = '\x1B';
|
||||
break;
|
||||
case 'Tab':
|
||||
send = '\t';
|
||||
break;
|
||||
case 'Ctrl+C':
|
||||
send = '\x03';
|
||||
break;
|
||||
|
||||
case '↑':
|
||||
send = '\x1B[A';
|
||||
break;
|
||||
case '↓':
|
||||
send = '\x1B[B';
|
||||
break;
|
||||
case '→':
|
||||
send = '\x1B[C';
|
||||
break;
|
||||
case '←':
|
||||
send = '\x1B[D';
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
send = '\x1B[H';
|
||||
break;
|
||||
case 'End':
|
||||
send = '\x1B[F';
|
||||
break;
|
||||
case 'PgUp':
|
||||
send = '\x1B[5~';
|
||||
break;
|
||||
case 'PgDn':
|
||||
send = '\x1B[6~';
|
||||
break;
|
||||
|
||||
default:
|
||||
send = key;
|
||||
break;
|
||||
}
|
||||
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
@@ -62,7 +61,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
bool _showGestureHelp = false;
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
final _uniqueKey = UniqueKey();
|
||||
Timer? _timerDidChangeMetrics;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
@@ -100,9 +99,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
@@ -139,9 +136,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
@@ -264,13 +259,11 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
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);
|
||||
}
|
||||
@@ -12,100 +12,6 @@ void _showSuccess() {
|
||||
showToast(translate("Successful"));
|
||||
}
|
||||
|
||||
void _showError() {
|
||||
showToast(translate("Error"));
|
||||
}
|
||||
|
||||
void setPermanentPasswordDialog(OverlayDialogManager dialogManager) async {
|
||||
final pw = await bind.mainGetPermanentPassword();
|
||||
final p0 = TextEditingController(text: pw);
|
||||
final p1 = TextEditingController(text: pw);
|
||||
var validateLength = false;
|
||||
var validateSame = false;
|
||||
dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
close();
|
||||
dialogManager.showLoading(translate("Waiting"));
|
||||
if (await gFFI.serverModel.setPermanentPassword(p0.text)) {
|
||||
dialogManager.dismissAll();
|
||||
_showSuccess();
|
||||
} else {
|
||||
dialogManager.dismissAll();
|
||||
_showError();
|
||||
}
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.password_rounded, color: MyTheme.accent),
|
||||
Text(translate('Set your own password')).paddingOnly(left: 10),
|
||||
],
|
||||
),
|
||||
content: Form(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: translate('Password'),
|
||||
),
|
||||
controller: p0,
|
||||
validator: (v) {
|
||||
if (v == null) return null;
|
||||
final val = v.trim().length > 5;
|
||||
if (validateLength != val) {
|
||||
// use delay to make setState success
|
||||
Future.delayed(Duration(microseconds: 1),
|
||||
() => setState(() => validateLength = val));
|
||||
}
|
||||
return val
|
||||
? null
|
||||
: translate('Too short, at least 6 characters.');
|
||||
},
|
||||
).workaroundFreezeLinuxMint(),
|
||||
TextFormField(
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: translate('Confirmation'),
|
||||
),
|
||||
controller: p1,
|
||||
validator: (v) {
|
||||
if (v == null) return null;
|
||||
final val = p0.text == v;
|
||||
if (validateSame != val) {
|
||||
Future.delayed(Duration(microseconds: 1),
|
||||
() => setState(() => validateSame = val));
|
||||
}
|
||||
return val
|
||||
? null
|
||||
: translate('The confirmation is not identical.');
|
||||
},
|
||||
).workaroundFreezeLinuxMint(),
|
||||
])),
|
||||
onCancel: close,
|
||||
onSubmit: (validateLength && validateSame) ? submit : null,
|
||||
actions: [
|
||||
dialogButton(
|
||||
'Cancel',
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: close,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
'OK',
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: (validateLength && validateSame) ? submit : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void setTemporaryPasswordLengthDialog(
|
||||
OverlayDialogManager dialogManager) async {
|
||||
List<String> lengths = ['6', '8', '10'];
|
||||
|
||||
@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
if (virtualMouseMode.showVirtualJoystick)
|
||||
VirtualJoystick(cursorModel: _cursorModel),
|
||||
VirtualJoystick(
|
||||
cursorModel: _cursorModel,
|
||||
inputModel: _inputModel,
|
||||
),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: true,
|
||||
inputModel: _inputModel,
|
||||
@@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter {
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// Virtual joystick sends the absolute movement for now.
|
||||
// Maybe we need to change it to relative movement in the future.
|
||||
// Virtual joystick can send either absolute movement (via updatePan)
|
||||
// or relative movement (via sendMobileRelativeMouseMove) depending on the
|
||||
// InputModel.relativeMouseMode setting.
|
||||
class VirtualJoystick extends StatefulWidget {
|
||||
final CursorModel cursorModel;
|
||||
final InputModel inputModel;
|
||||
|
||||
const VirtualJoystick({super.key, required this.cursorModel});
|
||||
const VirtualJoystick({
|
||||
super.key,
|
||||
required this.cursorModel,
|
||||
required this.inputModel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VirtualJoystick> createState() => _VirtualJoystickState();
|
||||
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
final double _moveStep = 3.0;
|
||||
final double _speed = 1.0;
|
||||
|
||||
/// Scale factor for relative mouse movement sensitivity.
|
||||
/// Higher values result in faster cursor movement on the remote machine.
|
||||
static const double _kRelativeMouseScale = 3.0;
|
||||
|
||||
// One-shot timer to detect a drag gesture
|
||||
Timer? _dragStartTimer;
|
||||
// Periodic timer for continuous movement
|
||||
@@ -701,6 +714,9 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
Size? _lastScreenSize;
|
||||
bool _isPressed = false;
|
||||
|
||||
/// Check if relative mouse mode is enabled.
|
||||
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -746,6 +762,18 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send movement delta to remote machine.
|
||||
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
|
||||
void _sendMovement(Offset delta) {
|
||||
if (_useRelativeMouse) {
|
||||
widget.inputModel.sendMobileRelativeMouseMove(
|
||||
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
|
||||
} else {
|
||||
// In absolute mode, use cursorModel.updatePan which tracks position.
|
||||
widget.cursorModel.updatePan(delta, Offset.zero, false);
|
||||
}
|
||||
}
|
||||
|
||||
void _stopSendEventTimer() {
|
||||
_dragStartTimer?.cancel();
|
||||
_continuousMoveTimer?.cancel();
|
||||
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
// The movement is small for a gentle start.
|
||||
final initialDelta = _offsetToPanDelta(_offset);
|
||||
if (initialDelta.distance > 0) {
|
||||
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
|
||||
_sendMovement(initialDelta);
|
||||
}
|
||||
|
||||
// 2. Start a one-shot timer to check if the user is holding for a drag.
|
||||
@@ -784,10 +812,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
_continuousMoveTimer =
|
||||
periodic_immediate(const Duration(milliseconds: 20), () async {
|
||||
if (_offset != Offset.zero) {
|
||||
widget.cursorModel.updatePan(
|
||||
_offsetToPanDelta(_offset) * _moveStep * _speed,
|
||||
Offset.zero,
|
||||
false);
|
||||
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
class GestureIcons {
|
||||
@@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget {
|
||||
{Key? key,
|
||||
required this.touchMode,
|
||||
required this.onTouchModeChange,
|
||||
required this.virtualMouseMode})
|
||||
required this.virtualMouseMode,
|
||||
this.inputModel})
|
||||
: super(key: key);
|
||||
final bool touchMode;
|
||||
final OnTouchModeChange onTouchModeChange;
|
||||
final VirtualMouseMode virtualMouseMode;
|
||||
final InputModel? inputModel;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() =>
|
||||
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
_selectedIndex = _touchMode ? 1 : 0;
|
||||
}
|
||||
|
||||
/// Helper to exit relative mouse mode when certain conditions are met.
|
||||
/// This reduces code duplication across multiple UI callbacks.
|
||||
void _exitRelativeMouseModeIf(bool condition) {
|
||||
if (condition) {
|
||||
widget.inputModel?.setRelativeMouseMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
@@ -103,6 +115,8 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
_selectedIndex = index ?? 0;
|
||||
_touchMode = index == 0 ? false : true;
|
||||
widget.onTouchModeChange(_touchMode);
|
||||
// Exit relative mouse mode when switching to touch mode
|
||||
_exitRelativeMouseModeIf(_touchMode);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -117,12 +131,18 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(translate('Show virtual mouse')),
|
||||
@@ -196,6 +216,10 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
@@ -203,6 +227,10 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
onTap: () async {
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(
|
||||
@@ -211,6 +239,39 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
],
|
||||
)),
|
||||
),
|
||||
// Relative mouse mode option - only visible when joystick is shown
|
||||
if (!_touchMode &&
|
||||
_virtualMouseMode.showVirtualMouse &&
|
||||
_virtualMouseMode.showVirtualJoystick &&
|
||||
widget.inputModel != null)
|
||||
Obx(() => Transform.translate(
|
||||
offset: const Offset(-10.0, -24.0),
|
||||
child: Padding(
|
||||
// Indent further for 'Relative mouse mode'
|
||||
padding: const EdgeInsets.only(left: 48.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: widget.inputModel!
|
||||
.relativeMouseMode.value,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
widget.inputModel!
|
||||
.setRelativeMouseMode(value);
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.inputModel!
|
||||
.toggleRelativeMouseMode();
|
||||
},
|
||||
child: Text(
|
||||
translate('Relative mouse mode')),
|
||||
),
|
||||
],
|
||||
)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
@@ -53,7 +52,9 @@ class AbModel {
|
||||
|
||||
RxBool get currentAbLoading => current.abLoading;
|
||||
bool get currentAbEmpty => current.peers.isEmpty && current.tags.isEmpty;
|
||||
RxString get currentAbPullError => current.pullError;
|
||||
final _listPullError = ''.obs;
|
||||
RxString get abPullError =>
|
||||
_listPullError.value.isNotEmpty ? _listPullError : current.pullError;
|
||||
RxString get currentAbPushError => current.pushError;
|
||||
String? _personalAbGuid;
|
||||
RxBool legacyMode = false.obs;
|
||||
@@ -68,6 +69,7 @@ class AbModel {
|
||||
var _syncFromRecentLock = false;
|
||||
var _timerCounter = 0;
|
||||
var _cacheLoadOnceFlag = false;
|
||||
var _pulledOnce = false;
|
||||
var listInitialized = false;
|
||||
var _maxPeerOneAb = 0;
|
||||
|
||||
@@ -97,10 +99,17 @@ class AbModel {
|
||||
print("reset ab model");
|
||||
addressbooks.clear();
|
||||
_currentName.value = '';
|
||||
_listPullError.value = '';
|
||||
_pulledOnce = false;
|
||||
await bind.mainClearAb();
|
||||
listInitialized = false;
|
||||
}
|
||||
|
||||
void clearPullErrors() {
|
||||
_listPullError.value = '';
|
||||
current.pullError.value = '';
|
||||
}
|
||||
|
||||
// #region ab
|
||||
/// Pulls the address book data from the server.
|
||||
///
|
||||
@@ -110,31 +119,41 @@ class AbModel {
|
||||
var _pulling = false;
|
||||
Future<void> pullAb(
|
||||
{required ForcePullAb? force, required bool quiet}) async {
|
||||
if (bind.isDisableAb()) return;
|
||||
if (!gFFI.userModel.isLogin) return;
|
||||
if (gFFI.userModel.networkError.isNotEmpty) return;
|
||||
if (_pulling) return;
|
||||
if (force == null && _pulledOnce) {
|
||||
return;
|
||||
}
|
||||
_pulling = true;
|
||||
if (!quiet) {
|
||||
_listPullError.value = '';
|
||||
current.pullError.value = '';
|
||||
}
|
||||
try {
|
||||
await _pullAb(force: force, quiet: quiet);
|
||||
_refreshTab();
|
||||
} catch (_) {}
|
||||
_pulling = false;
|
||||
_pulledOnce = true;
|
||||
}
|
||||
|
||||
Future<void> _pullAb(
|
||||
{required ForcePullAb? force, required bool quiet}) async {
|
||||
if (bind.isDisableAb()) return;
|
||||
if (!gFFI.userModel.isLogin) return;
|
||||
if (gFFI.userModel.networkError.isNotEmpty) return;
|
||||
if (force == null && listInitialized && current.initialized) return;
|
||||
debugPrint("pullAb, force: $force, quiet: $quiet");
|
||||
if (!listInitialized || force == ForcePullAb.listAndCurrent) {
|
||||
try {
|
||||
// Read personal guid every time to avoid upgrading the server without closing the main window
|
||||
_personalAbGuid = null;
|
||||
await _getPersonalAbGuid();
|
||||
// Determine legacy mode based on whether _personalAbGuid is null
|
||||
// `true`: continue init. `false`: stop, error already recorded.
|
||||
if (!await _getPersonalAbGuid(quiet: quiet)) {
|
||||
return;
|
||||
}
|
||||
legacyMode.value = _personalAbGuid == null;
|
||||
if (!legacyMode.value && _maxPeerOneAb == 0) {
|
||||
await _getAbSettings();
|
||||
await _getAbSettings(quiet: quiet);
|
||||
}
|
||||
if (_personalAbGuid != null) {
|
||||
debugPrint("pull ab list");
|
||||
@@ -142,7 +161,7 @@ class AbModel {
|
||||
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
|
||||
gFFI.userModel.userName.value, null, ShareRule.read.value, null));
|
||||
// get all address book name
|
||||
await _getSharedAbProfiles(abProfiles);
|
||||
await _getSharedAbProfiles(abProfiles, quiet: quiet);
|
||||
addressbooks.removeWhere((key, value) =>
|
||||
abProfiles.firstWhereOrNull((e) => e.name == key) == null);
|
||||
for (int i = 0; i < abProfiles.length; i++) {
|
||||
@@ -182,6 +201,7 @@ class AbModel {
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("pull ab list error: $e");
|
||||
_setListPullError(e, quiet: quiet);
|
||||
}
|
||||
} else if (listInitialized &&
|
||||
(!current.initialized || force == ForcePullAb.current)) {
|
||||
@@ -197,13 +217,26 @@ class AbModel {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _getAbSettings() async {
|
||||
void _setListPullError(Object err, {required bool quiet, int? statusCode}) {
|
||||
if (!quiet) {
|
||||
_listPullError.value =
|
||||
'${translate('pull_ab_failed_tip')}: ${translate(err.toString())}';
|
||||
}
|
||||
if (statusCode == 401) {
|
||||
gFFI.userModel.reset(resetOther: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _getAbSettings({required bool quiet}) async {
|
||||
int? statusCode;
|
||||
try {
|
||||
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(Uri.parse(api), headers: headers);
|
||||
if (resp.statusCode == 404) {
|
||||
statusCode = resp.statusCode;
|
||||
if (statusCode == 404) {
|
||||
debugPrint("HTTP 404, api server doesn't support shared address book");
|
||||
return false;
|
||||
}
|
||||
@@ -212,45 +245,57 @@ class AbModel {
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
if (statusCode != 200) {
|
||||
throw 'HTTP $statusCode';
|
||||
}
|
||||
_maxPeerOneAb = json['max_peer_one_ab'] ?? 0;
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get ab settings err: ${err.toString()}');
|
||||
_setListPullError(err, quiet: quiet, statusCode: statusCode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _getPersonalAbGuid() async {
|
||||
/// Loads `/api/ab/personal`.
|
||||
/// Returns `true` to continue init, `false` to stop after a real error.
|
||||
Future<bool> _getPersonalAbGuid({required bool quiet}) async {
|
||||
int? statusCode;
|
||||
try {
|
||||
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(Uri.parse(api), headers: headers);
|
||||
if (resp.statusCode == 404) {
|
||||
statusCode = resp.statusCode;
|
||||
if (statusCode == 404) {
|
||||
debugPrint("HTTP 404, current api server is legacy mode");
|
||||
return false;
|
||||
// Old server: keep `_personalAbGuid` null and continue in legacy mode.
|
||||
return true;
|
||||
}
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
if (statusCode != 200) {
|
||||
throw 'HTTP $statusCode';
|
||||
}
|
||||
_personalAbGuid = json['guid'];
|
||||
// New server: guid is available, continue in non-legacy mode.
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get personal ab err: ${err.toString()}');
|
||||
_setListPullError(err, quiet: quiet, statusCode: statusCode);
|
||||
}
|
||||
// Real error: stop the current pull.
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles) async {
|
||||
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles,
|
||||
{required bool quiet}) async {
|
||||
final api = "${await bind.mainGetApiServer()}/api/ab/shared/profiles";
|
||||
int? statusCode;
|
||||
try {
|
||||
var uri0 = Uri.parse(api);
|
||||
final pageSize = 100;
|
||||
@@ -269,14 +314,21 @@ class AbModel {
|
||||
});
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
if (statusCode == 404) {
|
||||
debugPrint(
|
||||
"HTTP 404, api server doesn't support shared address book");
|
||||
return false;
|
||||
}
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
if (statusCode != 200) {
|
||||
throw 'HTTP $statusCode';
|
||||
}
|
||||
if (json.containsKey('total')) {
|
||||
if (total == 0) total = json['total'];
|
||||
@@ -299,6 +351,7 @@ class AbModel {
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('_getSharedAbProfiles err: ${err.toString()}');
|
||||
_setListPullError(err, quiet: quiet, statusCode: statusCode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1012,16 +1065,8 @@ class LegacyAb extends BaseAb {
|
||||
var authHeaders = getHttpHeaders();
|
||||
authHeaders['Content-Type'] = "application/json";
|
||||
final body = jsonEncode({"data": jsonEncode(_serialize())});
|
||||
http.Response resp;
|
||||
// support compression
|
||||
if (licensedDevices > 0 && body.length > 1024) {
|
||||
authHeaders['Content-Encoding'] = "gzip";
|
||||
resp = await http.post(Uri.parse(api),
|
||||
headers: authHeaders, body: GZipCodec().encode(utf8.encode(body)));
|
||||
} else {
|
||||
resp =
|
||||
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||
}
|
||||
http.Response resp =
|
||||
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||
if (resp.statusCode == 200 &&
|
||||
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
|
||||
ret = true;
|
||||
@@ -1406,6 +1451,7 @@ class Ab extends BaseAb {
|
||||
});
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
@@ -1463,6 +1509,7 @@ class Ab extends BaseAb {
|
||||
);
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
List<dynamic> json =
|
||||
@@ -1977,3 +2024,8 @@ String _jsonDecodeActionResp(http.Response resp) {
|
||||
}
|
||||
return errMsg;
|
||||
}
|
||||
|
||||
// https://github.com/seanmonstar/reqwest/issues/838
|
||||
void _setEmptyBody(Map<String, String> headers) {
|
||||
headers['Content-Length'] = '0';
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ class TransferJobSerdeData {
|
||||
: this(
|
||||
connId: d['connId'] ?? 0,
|
||||
id: int.tryParse(d['id'].toString()) ?? 0,
|
||||
path: d['path'] ?? '',
|
||||
path: d['dataSource'] ?? '',
|
||||
isRemote: d['isRemote'] ?? false,
|
||||
totalSize: d['totalSize'] ?? 0,
|
||||
finishedSize: d['finishedSize'] ?? 0,
|
||||
|
||||
@@ -113,6 +113,34 @@ class FileModel {
|
||||
fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']);
|
||||
}
|
||||
|
||||
// This method fixes a deadlock that occurred when the previous code directly
|
||||
// called jobController.jobError(evt) in the job_error event handler.
|
||||
//
|
||||
// The problem with directly calling jobController.jobError():
|
||||
// 1. fetchDirectoryRecursiveToRemove(jobID) registers readRecursiveTasks[jobID]
|
||||
// and waits for completion
|
||||
// 2. If the remote has no permission (or some other errors), it returns a FileTransferError
|
||||
// 3. The error triggers job_error event, which called jobController.jobError()
|
||||
// 4. jobController.jobError() calls getJob(jobID) to find the job in jobTable
|
||||
// 5. But addDeleteDirJob() is called AFTER fetchDirectoryRecursiveToRemove(),
|
||||
// so the job doesn't exist yet in jobTable
|
||||
// 6. Result: jobController.jobError() does nothing useful, and
|
||||
// readRecursiveTasks[jobID] never completes, causing a 2s timeout
|
||||
//
|
||||
// Solution: Before calling jobController.jobError(), we first check if there's
|
||||
// a pending readRecursiveTasks with this ID and complete it with the error.
|
||||
void handleJobError(Map<String, dynamic> evt) {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id != null) {
|
||||
final err = evt['err']?.toString() ?? 'Unknown error';
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
}
|
||||
// Always call jobController.jobError(evt) to ensure all error events are processed,
|
||||
// even if the event does not have a valid job ID. This allows for generic error handling
|
||||
// or logging of unexpected errors.
|
||||
jobController.jobError(evt);
|
||||
}
|
||||
|
||||
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||
evtLoop.pushEvent(
|
||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||
@@ -363,14 +391,30 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final dir = (await bind.sessionGetPeerOption(
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
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));
|
||||
|
||||
if (directory.value.path.isEmpty) {
|
||||
openDirectory(options.value.home);
|
||||
if (!opened) {
|
||||
// The peer may become ready during the reconnect delay, so retry the
|
||||
// same candidates instead of only retrying the default home directory.
|
||||
await tryOpenReadyDirs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,19 +445,23 @@ class FileController {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await openDirectory(directory.value.path);
|
||||
Future<bool> refresh() async {
|
||||
// "." 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 {
|
||||
if (path == ".") {
|
||||
refresh();
|
||||
return;
|
||||
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (!isBack && path == ".") {
|
||||
return await refresh();
|
||||
}
|
||||
if (path == "..") {
|
||||
goToParentDirectory();
|
||||
return;
|
||||
if (!isBack && path == "..") {
|
||||
return await _goToParentDirectory(isBack: isBack);
|
||||
}
|
||||
return await _openDirectoryPath(path, isBack: isBack);
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -430,8 +478,10 @@ class FileController {
|
||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,19 +509,22 @@ class FileController {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
openDirectory(path, isBack: true);
|
||||
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||
}
|
||||
|
||||
void goToParentDirectory() {
|
||||
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||
}
|
||||
|
||||
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
openDirectory('/');
|
||||
return;
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
}
|
||||
openDirectory(parent);
|
||||
return await _openDirectoryPath(parent, isBack: isBack);
|
||||
}
|
||||
|
||||
// TODO deprecated this
|
||||
@@ -591,8 +644,21 @@ class FileController {
|
||||
} else if (item.isDirectory) {
|
||||
title = translate("Not an empty directory");
|
||||
dialogManager?.showLoading(translate("Waiting"));
|
||||
final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
|
||||
jobID, item.path, items.isLocal, true);
|
||||
final FileDirectory fd;
|
||||
try {
|
||||
fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
|
||||
jobID, item.path, items.isLocal, true);
|
||||
} catch (e) {
|
||||
dialogManager?.dismissAll();
|
||||
final dm = dialogManager;
|
||||
if (dm != null) {
|
||||
msgBox(sessionId, 'custom-error-nook-nocancel-hasclose',
|
||||
translate("Error"), e.toString(), '', dm);
|
||||
} else {
|
||||
debugPrint("removeAction error msgbox failed: $e");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fd.path.isEmpty) {
|
||||
fd.path = item.path;
|
||||
}
|
||||
@@ -606,7 +672,7 @@ class FileController {
|
||||
item.name,
|
||||
false);
|
||||
if (confirm == true) {
|
||||
sendRemoveEmptyDir(
|
||||
await sendRemoveEmptyDir(
|
||||
item.path,
|
||||
0,
|
||||
deleteJobId,
|
||||
@@ -647,7 +713,7 @@ class FileController {
|
||||
// handle remove res;
|
||||
if (item.isDirectory &&
|
||||
res['file_num'] == (entries.length - 1).toString()) {
|
||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
}
|
||||
} else {
|
||||
jobController.updateJobStatus(deleteJobId,
|
||||
@@ -660,7 +726,7 @@ class FileController {
|
||||
final res = await jobController.jobResultListener.start();
|
||||
if (item.isDirectory &&
|
||||
res['file_num'] == (entries.length - 1).toString()) {
|
||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -755,9 +821,9 @@ class FileController {
|
||||
fileNum: fileNum);
|
||||
}
|
||||
|
||||
void sendRemoveEmptyDir(String path, int fileNum, int actId) {
|
||||
Future<void> sendRemoveEmptyDir(String path, int fileNum, int actId) async {
|
||||
history.removeWhere((element) => element.contains(path));
|
||||
bind.sessionRemoveAllEmptyDirs(
|
||||
await bind.sessionRemoveAllEmptyDirs(
|
||||
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
|
||||
}
|
||||
|
||||
@@ -1275,6 +1341,15 @@ class FileFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete a pending recursive read task with an error.
|
||||
// See FileModel.handleJobError() for why this is necessary.
|
||||
void tryCompleteRecursiveTaskWithError(int id, String error) {
|
||||
final completer = readRecursiveTasks.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FileDirectory>> readEmptyDirs(
|
||||
String path, bool isLocal, bool showHidden) async {
|
||||
try {
|
||||
@@ -1438,6 +1513,10 @@ class JobProgress {
|
||||
var err = "";
|
||||
int lastTransferredSize = 0;
|
||||
|
||||
double get percent =>
|
||||
totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0;
|
||||
String get percentText => '${(percent * 100).toStringAsFixed(0)}%';
|
||||
|
||||
clear() {
|
||||
type = JobType.none;
|
||||
state = JobState.none;
|
||||
|
||||
@@ -343,6 +343,7 @@ class GroupModel {
|
||||
}
|
||||
|
||||
reset() async {
|
||||
initialized = false;
|
||||
groupLoadError.value = '';
|
||||
deviceGroups.clear();
|
||||
users.clear();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
@@ -14,11 +15,14 @@ import 'package:get/get.dart';
|
||||
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
import 'input_modifier_utils.dart';
|
||||
import 'relative_mouse_model.dart';
|
||||
import '../common.dart';
|
||||
import '../consts.dart';
|
||||
|
||||
/// Mouse button enum.
|
||||
enum MouseButtons { left, right, wheel, back }
|
||||
enum MouseButtons { left, right, wheel, back, forward }
|
||||
|
||||
const _kMouseEventDown = 'mousedown';
|
||||
const _kMouseEventUp = 'mouseup';
|
||||
@@ -57,7 +61,8 @@ class CanvasCoords {
|
||||
model.scale = json['scale'];
|
||||
model.scrollX = json['scrollX'];
|
||||
model.scrollY = json['scrollY'];
|
||||
model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||
model.scrollStyle =
|
||||
ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||
model.size = Size(json['size']['w'], json['size']['h']);
|
||||
return model;
|
||||
}
|
||||
@@ -154,6 +159,8 @@ extension ToString on MouseButtons {
|
||||
return 'wheel';
|
||||
case MouseButtons.back:
|
||||
return 'back';
|
||||
case MouseButtons.forward:
|
||||
return 'forward';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +331,80 @@ class ToReleaseKeys {
|
||||
}
|
||||
|
||||
class InputModel {
|
||||
// Side mouse button support for Linux.
|
||||
// Flutter's Linux embedder drops X11 button 8/9 events, so we capture them
|
||||
// natively via GDK and forward through the platform channel.
|
||||
static InputModel? _activeSideButtonModel;
|
||||
// Tracks per-button which model received a side button down event, so the
|
||||
// matching up event is routed there even if the pointer has left the view
|
||||
// or a different button was pressed in between.
|
||||
static final Map<MouseButtons, InputModel> _sideButtonDownModels = {};
|
||||
static bool _sideButtonChannelInitialized = false;
|
||||
|
||||
/// Each Flutter engine (main window + sub-windows from desktop_multi_window)
|
||||
/// runs its own Dart isolate with its own statics. Called from initEnv()
|
||||
/// which runs per-engine, so each isolate registers its own handler tied
|
||||
/// to its own set of InputModels.
|
||||
static void initSideButtonChannel() {
|
||||
if (!isLinux) return;
|
||||
if (_sideButtonChannelInitialized) return;
|
||||
_sideButtonChannelInitialized = true;
|
||||
|
||||
const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'onSideMouseButton') {
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
final button = args['button'] as String;
|
||||
final type = args['type'] as String;
|
||||
final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward;
|
||||
|
||||
if (type == 'down') {
|
||||
final model = _activeSideButtonModel;
|
||||
if (model != null &&
|
||||
!(model.isViewOnly && !model.showMyCursor) &&
|
||||
model.keyboardPerm &&
|
||||
!model.isViewCamera) {
|
||||
_sideButtonDownModels[mb] = model;
|
||||
// Fire-and-forget to avoid blocking the platform channel handler.
|
||||
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
|
||||
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Only route 'up' when we recorded the matching 'down';
|
||||
// dropping avoids sending unpaired 'up' to an unrelated session.
|
||||
// Use _sendMouseUnchecked to bypass permission checks so the
|
||||
// release always goes through even if permissions changed.
|
||||
final model = _sideButtonDownModels.remove(mb);
|
||||
if (model != null) {
|
||||
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
|
||||
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear any static references to this model (prevents stale routing).
|
||||
/// Releases any held side buttons on the peer so closing a session
|
||||
/// mid-press does not leave a stuck button.
|
||||
void disposeSideButtonTracking() {
|
||||
if (_activeSideButtonModel == this) _activeSideButtonModel = null;
|
||||
final held = _sideButtonDownModels.entries
|
||||
.where((e) => e.value == this)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
for (final mb in held) {
|
||||
_sideButtonDownModels.remove(mb);
|
||||
// Best-effort release; session may already be tearing down.
|
||||
unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) {
|
||||
debugPrint('[InputModel] failed to release side button $mb: $e');
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
final WeakReference<FFI> parent;
|
||||
String keyboardMode = '';
|
||||
|
||||
@@ -345,18 +426,47 @@ class InputModel {
|
||||
final _trackpadAdjustPeerLinux = 0.06;
|
||||
// This is an experience value.
|
||||
final _trackpadAdjustMacToWin = 2.50;
|
||||
// Ignore directional locking for very small deltas on both axes (including
|
||||
// tiny single-axis movement) to avoid over-filtering near zero.
|
||||
static const double _trackpadAxisNoiseThreshold = 0.2;
|
||||
// Lock to dominant axis only when one axis is clearly stronger.
|
||||
// 1.6 means the dominant axis must be >= 60% larger than the other.
|
||||
static const double _trackpadAxisLockRatio = 1.6;
|
||||
int _trackpadSpeed = kDefaultTrackpadSpeed;
|
||||
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
|
||||
var _trackpadScrollUnsent = Offset.zero;
|
||||
|
||||
// Mobile relative mouse delta accumulators (for slow/fine movements).
|
||||
double _mobileDeltaRemainderX = 0.0;
|
||||
double _mobileDeltaRemainderY = 0.0;
|
||||
|
||||
var _lastScale = 1.0;
|
||||
|
||||
bool _pointerMovedAfterEnter = false;
|
||||
bool _pointerInsideImage = false;
|
||||
|
||||
// mouse
|
||||
final isPhysicalMouse = false.obs;
|
||||
int _lastButtons = 0;
|
||||
Offset lastMousePos = Offset.zero;
|
||||
int _lastWheelTsUs = 0;
|
||||
|
||||
// Wheel acceleration thresholds.
|
||||
static const int _wheelAccelFastThresholdUs = 40000; // 40ms
|
||||
static const int _wheelAccelMediumThresholdUs = 80000; // 80ms
|
||||
static const double _wheelBurstVelocityThreshold =
|
||||
0.002; // delta units per microsecond
|
||||
// Wheel burst acceleration (empirical tuning).
|
||||
// Applies only to fast, non-smooth bursts to preserve single-step scrolling.
|
||||
// Flutter uses microseconds for dt, so velocity is in delta/us.
|
||||
|
||||
// Relative mouse mode (for games/3D apps).
|
||||
final relativeMouseMode = false.obs;
|
||||
late final RelativeMouseModel _relativeMouse;
|
||||
// Callback to cancel external throttle timer when relative mouse mode is disabled.
|
||||
VoidCallback? onRelativeMouseModeDisabled;
|
||||
// Disposer for the relativeMouseMode observer (to prevent memory leaks).
|
||||
Worker? _relativeMouseModeDisposer;
|
||||
|
||||
bool _queryOtherWindowCoords = false;
|
||||
Rect? _windowRect;
|
||||
@@ -364,18 +474,116 @@ class InputModel {
|
||||
|
||||
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;
|
||||
String get id => parent.target?.id ?? '';
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
int get trackpadSpeed => _trackpadSpeed;
|
||||
bool get useEdgeScroll => parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
|
||||
bool get useEdgeScroll =>
|
||||
parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
|
||||
|
||||
/// Check if the connected server supports relative mouse mode.
|
||||
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
|
||||
|
||||
InputModel(this.parent) {
|
||||
initSideButtonChannel();
|
||||
sessionId = parent.target!.sessionId;
|
||||
_relativeMouse = RelativeMouseModel(
|
||||
sessionId: sessionId,
|
||||
enabled: relativeMouseMode,
|
||||
keyboardPerm: () => keyboardPerm,
|
||||
isViewCamera: () => isViewCamera,
|
||||
peerVersion: () => peerVersion,
|
||||
peerPlatform: () => peerPlatform,
|
||||
modify: (msg) => modify(msg),
|
||||
getPointerInsideImage: () => _pointerInsideImage,
|
||||
setPointerInsideImage: (inside) => _pointerInsideImage = inside,
|
||||
);
|
||||
_relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call();
|
||||
|
||||
// Sync relative mouse mode state to global state for UI components (e.g., tab bar hint).
|
||||
_relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) {
|
||||
final peerId = id;
|
||||
if (peerId.isNotEmpty) {
|
||||
stateGlobal.relativeMouseModeState[peerId] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// https://github.com/flutter/flutter/issues/157241
|
||||
// Infer CapsLock state from the character output.
|
||||
// This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
|
||||
// incorrect CapsLock state on iOS.
|
||||
bool _getIosCapsFromCharacter(KeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(
|
||||
ch, HardwareKeyboard.instance.isShiftPressed);
|
||||
}
|
||||
|
||||
// RawKeyEvent version of _getIosCapsFromCharacter.
|
||||
bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
|
||||
}
|
||||
|
||||
// Shared implementation for inferring CapsLock state from character.
|
||||
// Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
|
||||
//
|
||||
// Limitations:
|
||||
// 1. This inference assumes the client and server use the same keyboard layout.
|
||||
// If layouts differ (e.g., client uses EN, server uses DE), the character output
|
||||
// may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
|
||||
// layout, making it impossible to correctly infer CapsLock state from the
|
||||
// character alone.
|
||||
// 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
|
||||
// produces lowercase). This method cannot handle that case correctly.
|
||||
bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
|
||||
if (ch == null || ch.length != 1) return false;
|
||||
// Use Dart's built-in Unicode-aware case detection
|
||||
final upper = ch.toUpperCase();
|
||||
final lower = ch.toLowerCase();
|
||||
final isUpper = upper == ch && lower != ch;
|
||||
final isLower = lower == ch && upper != ch;
|
||||
// Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
|
||||
if (!isUpper && !isLower) return false;
|
||||
return isUpper != shiftPressed;
|
||||
}
|
||||
|
||||
int _buildLockModes(bool iosCapsLock) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (isIOS) {
|
||||
if (iosCapsLock) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
// Ignore "NumLock/ScrollLock" on iOS for now.
|
||||
} else {
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
}
|
||||
return lockModes;
|
||||
}
|
||||
|
||||
// This function must be called after the peer info is received.
|
||||
@@ -495,6 +703,38 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Safe: this only re-dispatches synthesized Shift key-up events.
|
||||
// The key-up path clears the tracked Shift state so this does not loop.
|
||||
void _releaseTrackedShiftKeyEventIfNeeded() {
|
||||
final leftShift = toReleaseKeys.lastLShiftKeyEvent;
|
||||
final rightShift = toReleaseKeys.lastRShiftKeyEvent;
|
||||
if (leftShift != null) {
|
||||
handleKeyEvent(leftShift);
|
||||
}
|
||||
if (rightShift != null) {
|
||||
handleKeyEvent(rightShift);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe: this only re-dispatches synthesized Shift key-up events.
|
||||
// The raw key-up path clears the tracked Shift state so this does not loop.
|
||||
void _releaseTrackedRawShiftKeyEventIfNeeded() {
|
||||
final leftShift = toReleaseRawKeys.lastLShiftKeyEvent;
|
||||
final rightShift = toReleaseRawKeys.lastRShiftKeyEvent;
|
||||
if (leftShift != null) {
|
||||
handleRawKeyEvent(RawKeyUpEvent(
|
||||
data: leftShift.data,
|
||||
character: leftShift.character,
|
||||
));
|
||||
}
|
||||
if (rightShift != null) {
|
||||
handleRawKeyEvent(RawKeyUpEvent(
|
||||
data: rightShift.data,
|
||||
character: rightShift.character,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
@@ -506,6 +746,15 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
if (_relativeMouse.handleRawKeyEvent(e)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && e is RawKeyDownEvent) {
|
||||
iosCapsLock = _getIosCapsFromRawCharacter(e);
|
||||
}
|
||||
|
||||
final key = e.logicalKey;
|
||||
if (e is RawKeyDownEvent) {
|
||||
if (!e.repeat) {
|
||||
@@ -540,9 +789,30 @@ class InputModel {
|
||||
toReleaseRawKeys.updateKeyUp(key, e);
|
||||
}
|
||||
|
||||
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
|
||||
// set even though the current raw key event is not shifted anymore.
|
||||
if (e is RawKeyDownEvent &&
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: isMobile,
|
||||
cachedShiftPressed: shift,
|
||||
actualShiftPressed: e.isShiftPressed,
|
||||
logicalKey: e.logicalKey,
|
||||
hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null ||
|
||||
toReleaseRawKeys.lastRShiftKeyEvent != null,
|
||||
)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'input: releasing stale mobile Shift before replaying tracked raw '
|
||||
'key-up (logicalKey=${e.logicalKey.keyLabel}, '
|
||||
'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)',
|
||||
);
|
||||
}
|
||||
_releaseTrackedRawShiftKeyEventIfNeeded();
|
||||
}
|
||||
|
||||
// * Currently mobile does not enable map mode
|
||||
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
|
||||
mapKeyboardModeRaw(e);
|
||||
mapKeyboardModeRaw(e, iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardModeRaw(e);
|
||||
}
|
||||
@@ -568,6 +838,23 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
if (_relativeMouse.handleKeyEvent(
|
||||
e,
|
||||
ctrlPressed: ctrl,
|
||||
shiftPressed: shift,
|
||||
altPressed: alt,
|
||||
commandPressed: command,
|
||||
)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
|
||||
iosCapsLock = _getIosCapsFromCharacter(e);
|
||||
}
|
||||
|
||||
// Update cached modifier state before sending the event. The stale mobile
|
||||
// Shift release check below relies on this cached state.
|
||||
if (e is KeyUpEvent) {
|
||||
handleKeyUpEventModifiers(e);
|
||||
} else if (e is KeyDownEvent) {
|
||||
@@ -605,6 +892,21 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
|
||||
// set even though the current key event is not shifted anymore.
|
||||
if (e is KeyDownEvent &&
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: isMobile,
|
||||
cachedShiftPressed: shift,
|
||||
actualShiftPressed: HardwareKeyboard.instance.isShiftPressed,
|
||||
logicalKey: e.logicalKey,
|
||||
hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null ||
|
||||
toReleaseKeys.lastRShiftKeyEvent != null,
|
||||
)) {
|
||||
_releaseTrackedShiftKeyEventIfNeeded();
|
||||
}
|
||||
|
||||
final isDesktopAndMapMode =
|
||||
isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode);
|
||||
if (isMobileAndMapMode || isDesktopAndMapMode) {
|
||||
@@ -613,7 +915,8 @@ class InputModel {
|
||||
e.character ?? '',
|
||||
e.physicalKey.usbHidUsage & 0xFFFF,
|
||||
// Show repeat event be converted to "release+press" events?
|
||||
e is KeyDownEvent || e is KeyRepeatEvent);
|
||||
e is KeyDownEvent || e is KeyRepeatEvent,
|
||||
iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardMode(e);
|
||||
}
|
||||
@@ -622,23 +925,9 @@ class InputModel {
|
||||
}
|
||||
|
||||
/// Send Key Event
|
||||
void newKeyboardMode(String character, int usbHid, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void newKeyboardMode(
|
||||
String character, int usbHid, bool down, bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterKeyEvent(
|
||||
sessionId: sessionId,
|
||||
character: character,
|
||||
@@ -647,7 +936,7 @@ class InputModel {
|
||||
downOrUp: down);
|
||||
}
|
||||
|
||||
void mapKeyboardModeRaw(RawKeyEvent e) {
|
||||
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
|
||||
int positionCode = -1;
|
||||
int platformCode = -1;
|
||||
bool down;
|
||||
@@ -678,27 +967,14 @@ class InputModel {
|
||||
} else {
|
||||
down = false;
|
||||
}
|
||||
inputRawKey(e.character ?? '', platformCode, positionCode, down);
|
||||
inputRawKey(
|
||||
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
|
||||
}
|
||||
|
||||
/// Send raw Key Event
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down,
|
||||
bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterRawKeyEvent(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -772,6 +1048,9 @@ class InputModel {
|
||||
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
||||
final Map<String, dynamic> out = {};
|
||||
|
||||
bool hasStaleButtonsOnMouseUp =
|
||||
type == _kMouseEventUp && evt.buttons == _lastButtons;
|
||||
|
||||
// Check update event type and set buttons to be sent.
|
||||
int buttons = _lastButtons;
|
||||
if (type == _kMouseEventMove) {
|
||||
@@ -796,7 +1075,7 @@ class InputModel {
|
||||
buttons = evt.buttons;
|
||||
}
|
||||
}
|
||||
_lastButtons = evt.buttons;
|
||||
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
|
||||
|
||||
out['buttons'] = buttons;
|
||||
out['type'] = type;
|
||||
@@ -840,24 +1119,41 @@ class InputModel {
|
||||
return evt;
|
||||
}
|
||||
|
||||
/// Send mouse event unconditionally (no permission checks).
|
||||
/// Used for side button releases that must go through even if permissions
|
||||
/// changed after the matching down was sent.
|
||||
Future<void> _sendMouseUnchecked(String type, MouseButtons button) async {
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
}
|
||||
|
||||
/// Send mouse press event.
|
||||
Future<void> sendMouse(String type, MouseButtons button) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
await _sendMouseUnchecked(type, button);
|
||||
}
|
||||
|
||||
void enterOrLeave(bool enter) {
|
||||
toReleaseKeys.release(handleKeyEvent);
|
||||
toReleaseRawKeys.release(handleRawKeyEvent);
|
||||
_pointerMovedAfterEnter = false;
|
||||
_pointerInsideImage = enter;
|
||||
_lastWheelTsUs = 0;
|
||||
|
||||
// Track active model for side button events (Linux).
|
||||
if (enter) {
|
||||
_activeSideButtonModel = this;
|
||||
} else if (_activeSideButtonModel == this) {
|
||||
_activeSideButtonModel = null;
|
||||
}
|
||||
|
||||
// Fix status
|
||||
if (!enter) {
|
||||
resetModifiers();
|
||||
}
|
||||
_relativeMouse.onEnterOrLeaveImage(enter);
|
||||
_flingTimer?.cancel();
|
||||
if (!isInputSourceFlutter) {
|
||||
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
|
||||
@@ -878,15 +1174,142 @@ class InputModel {
|
||||
msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
|
||||
}
|
||||
|
||||
/// Send relative mouse movement for mobile clients (virtual joystick).
|
||||
/// This method is for touch-based controls that want to send delta values.
|
||||
/// Uses the 'move_relative' type which bypasses absolute position tracking.
|
||||
///
|
||||
/// Accumulates fractional deltas to avoid losing slow/fine movements.
|
||||
/// Only sends events when relative mouse mode is enabled and supported.
|
||||
Future<void> sendMobileRelativeMouseMove(double dx, double dy) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
// Only send relative mouse events when relative mode is enabled and supported.
|
||||
if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
|
||||
_mobileDeltaRemainderX += dx;
|
||||
_mobileDeltaRemainderY += dy;
|
||||
final x = _mobileDeltaRemainderX.truncate();
|
||||
final y = _mobileDeltaRemainderY.truncate();
|
||||
_mobileDeltaRemainderX -= x;
|
||||
_mobileDeltaRemainderY -= y;
|
||||
if (x == 0 && y == 0) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({
|
||||
'type': 'move_relative',
|
||||
'x': '$x',
|
||||
'y': '$y',
|
||||
})));
|
||||
}
|
||||
|
||||
/// Update the pointer lock center position based on current window frame.
|
||||
Future<void> updatePointerLockCenter({Offset? localCenter}) {
|
||||
return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
|
||||
}
|
||||
|
||||
/// Get the current image widget size (for comparison to avoid unnecessary updates).
|
||||
Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
|
||||
|
||||
/// Update the image widget size for center calculation.
|
||||
void updateImageWidgetSize(Size size) {
|
||||
_relativeMouse.updateImageWidgetSize(size);
|
||||
}
|
||||
|
||||
void toggleRelativeMouseMode() {
|
||||
_relativeMouse.toggleRelativeMouseMode();
|
||||
}
|
||||
|
||||
bool setRelativeMouseMode(bool enabled) {
|
||||
return _relativeMouse.setRelativeMouseMode(enabled);
|
||||
}
|
||||
|
||||
/// Exit relative mouse mode and release all modifier keys to the remote.
|
||||
/// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
|
||||
/// We need to send key-up events for all modifiers because the shortcut itself may have
|
||||
/// blocked some key events, leaving the remote in a state where modifiers are stuck.
|
||||
void exitRelativeMouseModeWithKeyRelease() {
|
||||
if (!_relativeMouse.enabled.value) return;
|
||||
|
||||
// First, send release events for all modifier keys to the remote.
|
||||
// This ensures the remote doesn't have stuck modifier keys after exiting.
|
||||
// Use press: false, down: false to send key-up events without modifiers attached.
|
||||
final modifiersToRelease = [
|
||||
'Control_L',
|
||||
'Control_R',
|
||||
'Alt_L',
|
||||
'Alt_R',
|
||||
'Shift_L',
|
||||
'Shift_R',
|
||||
'Meta_L', // Command/Super left
|
||||
'Meta_R', // Command/Super right
|
||||
];
|
||||
|
||||
for (final key in modifiersToRelease) {
|
||||
bind.sessionInputKey(
|
||||
sessionId: sessionId,
|
||||
name: key,
|
||||
down: false,
|
||||
press: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
command: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Reset local modifier state
|
||||
resetModifiers();
|
||||
|
||||
// Now exit relative mouse mode
|
||||
_relativeMouse.setRelativeMouseMode(false);
|
||||
}
|
||||
|
||||
void disposeRelativeMouseMode() {
|
||||
_relativeMouse.dispose();
|
||||
onRelativeMouseModeDisabled = null;
|
||||
// Cancel the relative mouse mode observer and clean up global state.
|
||||
_relativeMouseModeDisposer?.dispose();
|
||||
_relativeMouseModeDisposer = null;
|
||||
final peerId = id;
|
||||
if (peerId.isNotEmpty) {
|
||||
stateGlobal.relativeMouseModeState.remove(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
void onWindowBlur() {
|
||||
_relativeMouse.onWindowBlur();
|
||||
}
|
||||
|
||||
void onWindowFocus() {
|
||||
_relativeMouse.onWindowFocus();
|
||||
}
|
||||
|
||||
void onPointHoverImage(PointerHoverEvent e) {
|
||||
_stopFling = true;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
|
||||
// May fix https://github.com/rustdesk/rustdesk/issues/13009
|
||||
if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) {
|
||||
// iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected.
|
||||
// Ignore this event to prevent cursor jumping.
|
||||
debugPrint('Ignored synthesized hover at (0,0) on iOS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update pointer region when relative mouse mode is enabled.
|
||||
// This avoids unnecessary tracking when not in relative mode.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (!isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = true;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,6 +1345,7 @@ class InputModel {
|
||||
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
|
||||
delta *= _trackpadAdjustMacToWin;
|
||||
}
|
||||
delta = _filterTrackpadDeltaAxis(delta);
|
||||
_trackpadLastDelta = delta;
|
||||
|
||||
var x = delta.dx.toInt();
|
||||
@@ -954,6 +1378,24 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
Offset _filterTrackpadDeltaAxis(Offset delta) {
|
||||
final absDx = delta.dx.abs();
|
||||
final absDy = delta.dy.abs();
|
||||
// Keep diagonal intent when movement is tiny on both axes.
|
||||
if (absDx < _trackpadAxisNoiseThreshold &&
|
||||
absDy < _trackpadAxisNoiseThreshold) {
|
||||
return delta;
|
||||
}
|
||||
// Dominant-axis lock to reduce accidental cross-axis scrolling noise.
|
||||
if (absDy >= absDx * _trackpadAxisLockRatio) {
|
||||
return Offset(0, delta.dy);
|
||||
}
|
||||
if (absDx >= absDy * _trackpadAxisLockRatio) {
|
||||
return Offset(delta.dx, 0);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
void _scheduleFling(double x, double y, int delay) {
|
||||
if (isViewCamera) return;
|
||||
if ((x == 0 && y == 0) || _stopFling) {
|
||||
@@ -1035,6 +1477,38 @@ class InputModel {
|
||||
_trackpadLastDelta = Offset.zero;
|
||||
}
|
||||
|
||||
// iOS Magic Mouse duplicate event detection.
|
||||
// When using Magic Mouse on iPad, iOS may emit both mouse and touch events
|
||||
// for the same click in certain areas (like top-left corner).
|
||||
int _lastMouseDownTimeMs = 0;
|
||||
ui.Offset _lastMouseDownPos = ui.Offset.zero;
|
||||
|
||||
/// Check if a touch tap event should be ignored because it's a duplicate
|
||||
/// of a recent mouse event (iOS Magic Mouse issue).
|
||||
bool shouldIgnoreTouchTap(ui.Offset pos) {
|
||||
if (!isIOS) return false;
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final dt = nowMs - _lastMouseDownTimeMs;
|
||||
final distance = (_lastMouseDownPos - pos).distance;
|
||||
// If touch tap is within 2000ms and 80px of the last mouse down,
|
||||
// it's likely a duplicate event from the same Magic Mouse click.
|
||||
if (dt >= 0 && dt < 2000 && distance < 80.0) {
|
||||
debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// iOS may emit a synthesized touch event after a real mouse click.
|
||||
/// This helper ignores touch-down events that arrive shortly after a mouse down,
|
||||
/// even when the position is far (e.g., near the top edge).
|
||||
bool _shouldIgnoreTouchAfterMouse(int nowMs) {
|
||||
if (!isIOS) return false;
|
||||
const int kTouchAfterMouseWindowMs = 700;
|
||||
final dt = nowMs - _lastMouseDownTimeMs;
|
||||
return dt >= 0 && dt < kTouchAfterMouseWindowMs;
|
||||
}
|
||||
|
||||
void onPointDownImage(PointerDownEvent e) {
|
||||
debugPrint("onPointDownImage ${e.kind}");
|
||||
_stopFling = true;
|
||||
@@ -1043,13 +1517,39 @@ class InputModel {
|
||||
_windowRect = null;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
|
||||
// Track mouse down events for duplicate detection on iOS.
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
if (e.kind == ui.PointerDeviceKind.mouse) {
|
||||
if (!isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = true;
|
||||
}
|
||||
_lastMouseDownTimeMs = nowMs;
|
||||
_lastMouseDownPos = e.position;
|
||||
}
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
// Ignore duplicate touch events that follow a recent mouse click (iOS Magic Mouse issue).
|
||||
if (isPhysicalMouse.value && _shouldIgnoreTouchAfterMouse(nowMs)) {
|
||||
return;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = false;
|
||||
}
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
// In relative mouse mode, send button events without position.
|
||||
// Use _relativeMouse.enabled.value consistently with the guard above.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,9 +1557,21 @@ class InputModel {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
// In relative mouse mode, send button events without position.
|
||||
// Use _relativeMouse.enabled.value consistently with the guard above.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1579,11 @@ class InputModel {
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (_queryOtherWindowCoords) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
|
||||
@@ -1074,7 +1591,10 @@ class InputModel {
|
||||
_queryOtherWindowCoords = false;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,21 +1618,53 @@ class InputModel {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handle scroll/wheel events.
|
||||
/// Note: Scroll events intentionally use absolute positioning even in relative mouse mode.
|
||||
/// This is because scroll events don't need relative positioning - they represent
|
||||
/// scroll deltas that are independent of cursor position. Games and 3D applications
|
||||
/// handle scroll events the same way regardless of mouse mode.
|
||||
void onPointerSignalImage(PointerSignalEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e is PointerScrollEvent) {
|
||||
var dx = e.scrollDelta.dx.toInt();
|
||||
var dy = e.scrollDelta.dy.toInt();
|
||||
final rawDx = e.scrollDelta.dx;
|
||||
final rawDy = e.scrollDelta.dy;
|
||||
final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs();
|
||||
final isSmooth = dominantDelta < 1;
|
||||
final nowUs = DateTime.now().microsecondsSinceEpoch;
|
||||
final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs;
|
||||
_lastWheelTsUs = nowUs;
|
||||
int accel = 1;
|
||||
if (!isSmooth &&
|
||||
dtUs > 0 &&
|
||||
dtUs <= _wheelAccelMediumThresholdUs &&
|
||||
(isWindows || isLinux) &&
|
||||
peerPlatform == kPeerPlatformMacOS) {
|
||||
final velocity = dominantDelta / dtUs;
|
||||
if (velocity >= _wheelBurstVelocityThreshold) {
|
||||
if (dtUs < _wheelAccelFastThresholdUs) {
|
||||
accel = 3;
|
||||
} else {
|
||||
accel = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
var dx = rawDx.toInt();
|
||||
var dy = rawDy.toInt();
|
||||
if (rawDx.abs() > rawDy.abs()) {
|
||||
dy = 0;
|
||||
} else {
|
||||
dx = 0;
|
||||
}
|
||||
if (dx > 0) {
|
||||
dx = -1;
|
||||
dx = -accel;
|
||||
} else if (dx < 0) {
|
||||
dx = 1;
|
||||
dx = accel;
|
||||
}
|
||||
if (dy > 0) {
|
||||
dy = -1;
|
||||
dy = -accel;
|
||||
} else if (dy < 0) {
|
||||
dy = 1;
|
||||
dy = accel;
|
||||
}
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
@@ -1285,14 +1837,18 @@ class InputModel {
|
||||
evt['y'] = '${pos.y.toInt()}';
|
||||
}
|
||||
|
||||
Map<int, String> mapButtons = {
|
||||
kPrimaryMouseButton: 'left',
|
||||
kSecondaryMouseButton: 'right',
|
||||
kMiddleMouseButton: 'wheel',
|
||||
kBackMouseButton: 'back',
|
||||
kForwardMouseButton: 'forward'
|
||||
};
|
||||
evt['buttons'] = mapButtons[evt['buttons']] ?? '';
|
||||
final buttons = evt['buttons'];
|
||||
if (buttons is int) {
|
||||
evt['buttons'] = mouseButtonsToPeer(buttons);
|
||||
} else {
|
||||
// Log warning if buttons exists but is not an int (unexpected caller).
|
||||
// Keep empty string fallback for missing buttons to preserve move/hover behavior.
|
||||
if (buttons != null) {
|
||||
debugPrint(
|
||||
'[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
|
||||
}
|
||||
evt['buttons'] = '';
|
||||
}
|
||||
return evt;
|
||||
}
|
||||
|
||||
@@ -1303,8 +1859,8 @@ class InputModel {
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
final evtToPeer =
|
||||
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
|
||||
final evtToPeer = processEventToPeer(evt, offset,
|
||||
onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
|
||||
if (evtToPeer != null) {
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
|
||||
@@ -1544,9 +2100,9 @@ class InputModel {
|
||||
// Simulate a key press event.
|
||||
// `usbHidUsage` is the USB HID usage code of the key.
|
||||
Future<void> tapHidKey(int usbHidUsage) async {
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
|
||||
}
|
||||
|
||||
Future<void> onMobileVolumeUp() async =>
|
||||
|
||||
38
flutter/lib/models/input_modifier_utils.dart
Normal file
38
flutter/lib/models/input_modifier_utils.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
///
|
||||
/// This is only valid on mobile when Flutter's cached Shift state is still on
|
||||
/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
|
||||
/// Shift as off (`actualShiftPressed == false`).
|
||||
///
|
||||
/// A tracked Shift key-down is required so the caller can safely synthesize the
|
||||
/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
|
||||
/// Shift key event itself must be processed first; otherwise we could release
|
||||
/// the tracked key while still handling the original Shift press/release.
|
||||
/// Callers should evaluate this only after their cached modifier state has been
|
||||
/// updated for the current event.
|
||||
///
|
||||
/// When this returns true, the caller logs a line like:
|
||||
/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
|
||||
/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
|
||||
bool shouldReleaseStaleMobileShift({
|
||||
required bool isMobile,
|
||||
required bool cachedShiftPressed,
|
||||
required bool actualShiftPressed,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool hasTrackedShiftKeyDown,
|
||||
}) {
|
||||
if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
|
||||
return false;
|
||||
}
|
||||
if (!hasTrackedShiftKeyDown) {
|
||||
return false;
|
||||
}
|
||||
if (logicalKey == LogicalKeyboardKey.shiftLeft ||
|
||||
logicalKey == LogicalKeyboardKey.shiftRight) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -120,6 +120,7 @@ class FfiModel with ChangeNotifier {
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -213,6 +214,9 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
updatePermission(Map<String, dynamic> evt, String id) {
|
||||
// Track previous keyboard permission to detect revocation.
|
||||
final hadKeyboardPerm = _permissions['keyboard'] != false;
|
||||
|
||||
evt.forEach((k, v) {
|
||||
if (k == 'name' || k.isEmpty) return;
|
||||
_permissions[k] = v == 'true';
|
||||
@@ -221,6 +225,18 @@ class FfiModel with ChangeNotifier {
|
||||
if (parent.target?.connType == ConnType.defaultConn) {
|
||||
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
|
||||
}
|
||||
|
||||
// If keyboard permission was revoked while relative mouse mode is active,
|
||||
// forcefully disable relative mouse mode to prevent the user from being trapped.
|
||||
final hasKeyboardPerm = _permissions['keyboard'] != false;
|
||||
if (hadKeyboardPerm && !hasKeyboardPerm) {
|
||||
final inputModel = parent.target?.inputModel;
|
||||
if (inputModel != null && inputModel.relativeMouseMode.value) {
|
||||
inputModel.setRelativeMouseMode(false);
|
||||
showToast(translate('rel-mouse-permission-lost-tip'));
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('updatePermission: $_permissions');
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -363,7 +379,7 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.fileModel.refreshAll();
|
||||
}
|
||||
} else if (name == 'job_error') {
|
||||
parent.target?.fileModel.jobController.jobError(evt);
|
||||
parent.target?.fileModel.handleJobError(evt);
|
||||
} else if (name == 'override_file_confirm') {
|
||||
parent.target?.fileModel.postOverrideFileConfirm(evt);
|
||||
} else if (name == 'load_last_job') {
|
||||
@@ -457,6 +473,9 @@ class FfiModel with ChangeNotifier {
|
||||
_handlePrinterRequest(evt, sessionId, peerId);
|
||||
} else if (name == 'screenshot') {
|
||||
_handleScreenshot(evt, sessionId, peerId);
|
||||
} else if (name == 'exit_relative_mouse_mode') {
|
||||
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
||||
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
@@ -765,7 +784,8 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) {
|
||||
Future<void> updateCurDisplay(SessionID sessionId,
|
||||
{updateCursorPos = false}) async {
|
||||
final newRect = displaysRect();
|
||||
if (newRect == null) {
|
||||
return;
|
||||
@@ -777,9 +797,19 @@ class FfiModel with ChangeNotifier {
|
||||
updateCursorPos: updateCursorPos);
|
||||
}
|
||||
_rect = newRect;
|
||||
parent.target?.canvasModel
|
||||
// Await updateViewStyle to ensure view geometry is fully updated before
|
||||
// updating pointer lock center. This prevents stale center calculations.
|
||||
await parent.target?.canvasModel
|
||||
.updateViewStyle(refreshMousePos: updateCursorPos);
|
||||
_updateSessionWidthHeight(sessionId);
|
||||
|
||||
// Keep pointer lock center in sync when using relative mouse mode.
|
||||
// Note: updatePointerLockCenter is async-safe (handles errors internally),
|
||||
// so we fire-and-forget here.
|
||||
final inputModel = parent.target?.inputModel;
|
||||
if (inputModel != null && inputModel.relativeMouseMode.value) {
|
||||
inputModel.updatePointerLockCenter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,6 +893,17 @@ class FfiModel with ChangeNotifier {
|
||||
final title = evt['title'];
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
if (title == 'Connection Error' ||
|
||||
type == 'error' ||
|
||||
type == 'restarting' ||
|
||||
(type is String && type.contains('error'))) {
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
}
|
||||
|
||||
if (type == 're-input-password') {
|
||||
wrongPasswordDialog(sessionId, dialogManager, type, title, text);
|
||||
} else if (type == 'input-2fa') {
|
||||
@@ -900,11 +941,46 @@ class FfiModel with ChangeNotifier {
|
||||
showPrivacyFailedDialog(
|
||||
sessionId, type, title, text, link, hasRetry, dialogManager);
|
||||
} else {
|
||||
final hasRetry = evt['hasRetry'] == 'true';
|
||||
var hasRetry = evt['hasRetry'] == 'true';
|
||||
if (!hasRetry) {
|
||||
hasRetry = shouldAutoRetryOnOffline(type, title, text);
|
||||
}
|
||||
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
String type,
|
||||
String title,
|
||||
String text,
|
||||
) {
|
||||
if (type == 'error' &&
|
||||
title == 'Connection Error' &&
|
||||
text == 'Remote desktop is offline' &&
|
||||
_pi.isSet.isTrue) {
|
||||
// Auto retry for ~30s (server's peer offline threshold) when controlled peer's account changes
|
||||
// (e.g., signout, switch user, login into OS) causes temporary offline via websocket/tcp connection.
|
||||
// The actual wait may exceed 30s (e.g., 20s elapsed + 16s next retry = 36s), which is acceptable
|
||||
// since the controlled side reconnects quickly after account changes.
|
||||
// Uses time-based check instead of _reconnects count because user can manually retry.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/14048
|
||||
if (_offlineReconnectStartTime == null) {
|
||||
// First offline, record time and start retry
|
||||
_offlineReconnectStartTime = DateTime.now();
|
||||
return true;
|
||||
} else {
|
||||
final elapsed =
|
||||
DateTime.now().difference(_offlineReconnectStartTime!).inSeconds;
|
||||
if (elapsed < 30) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleToast(Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
final type = evt['type'] ?? 'info';
|
||||
final text = evt['text'] ?? '';
|
||||
@@ -940,19 +1016,31 @@ class FfiModel with ChangeNotifier {
|
||||
showMsgBox(SessionID sessionId, String type, String title, String text,
|
||||
String link, bool hasRetry, OverlayDialogManager dialogManager,
|
||||
{bool? hasCancel}) async {
|
||||
final showNoteEdit = parent.target != null &&
|
||||
final noteAllowed = parent.target != null &&
|
||||
allowAskForNoteAtEndOfConnection(parent.target, false) &&
|
||||
(title == "Connection Error" || type == "restarting") &&
|
||||
!hasRetry;
|
||||
(title == "Connection Error" || type == "restarting");
|
||||
final showNoteEdit = noteAllowed && !hasRetry;
|
||||
if (showNoteEdit) {
|
||||
await showConnEndAuditDialogCloseCanceled(
|
||||
ffi: parent.target!, type: type, title: title, text: text);
|
||||
closeConnection();
|
||||
} else {
|
||||
VoidCallback? onSubmit;
|
||||
if (noteAllowed && hasRetry) {
|
||||
final ffi = parent.target!;
|
||||
onSubmit = () async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await showConnEndAuditDialogCloseCanceled(
|
||||
ffi: ffi, type: type, title: title, text: text);
|
||||
closeConnection();
|
||||
};
|
||||
}
|
||||
msgBox(sessionId, type, title, text, link, dialogManager,
|
||||
hasCancel: hasCancel,
|
||||
reconnect: hasRetry ? reconnect : null,
|
||||
reconnectTimeout: hasRetry ? _reconnects : null);
|
||||
reconnectTimeout: hasRetry ? _reconnects : null,
|
||||
onSubmit: onSubmit);
|
||||
}
|
||||
_timer?.cancel();
|
||||
if (hasRetry) {
|
||||
@@ -962,11 +1050,14 @@ class FfiModel with ChangeNotifier {
|
||||
_reconnects *= 2;
|
||||
} else {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
|
||||
bool forceRelay) {
|
||||
// Disable relative mouse mode before reconnecting to ensure cursor is released.
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
|
||||
clearPermissions();
|
||||
dialogManager.dismissAll();
|
||||
@@ -1081,7 +1172,8 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.length == 1) {
|
||||
bind.sessionSetSize(
|
||||
sessionId: sessionId,
|
||||
display: pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
|
||||
display:
|
||||
pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
|
||||
width: displays[0].width,
|
||||
height: displays[0].height,
|
||||
);
|
||||
@@ -1100,6 +1192,14 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
void _queryAuditGuid(String peerId) async {
|
||||
try {
|
||||
if (bind.isDisableAccount()) {
|
||||
return;
|
||||
}
|
||||
if (bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn/active")
|
||||
.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection)) {
|
||||
return;
|
||||
@@ -1183,9 +1283,6 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
_queryAuditGuid(peerId);
|
||||
|
||||
// This call is to ensuer the keyboard mode is updated depending on the peer version.
|
||||
parent.target?.inputModel.updateKeyboardMode();
|
||||
|
||||
// Map clone is required here, otherwise "evt" may be changed by other threads through the reference.
|
||||
// Because this function is asynchronous, there's an "await" in this function.
|
||||
cachedPeerData.peerInfo = {...evt};
|
||||
@@ -1197,6 +1294,17 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
parent.target?.dialogManager.dismissAll();
|
||||
_pi.version = evt['version'];
|
||||
// Note: Relative mouse mode is NOT auto-enabled on connect.
|
||||
// Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M).
|
||||
//
|
||||
// For desktop/webDesktop, keyboard mode initialization is handled later by
|
||||
// checkDesktopKeyboardMode() which may change the mode if not supported,
|
||||
// followed by updateKeyboardMode() to sync InputModel.keyboardMode.
|
||||
// For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web),
|
||||
// but we call it here for consistency and future-proofing.
|
||||
if (isMobile) {
|
||||
parent.target?.inputModel.updateKeyboardMode();
|
||||
}
|
||||
_pi.isSupportMultiUiSession =
|
||||
bind.isSupportMultiUiSession(version: _pi.version);
|
||||
_pi.username = evt['username'];
|
||||
@@ -1265,6 +1373,7 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -1298,7 +1407,11 @@ class FfiModel with ChangeNotifier {
|
||||
stateGlobal.resetLastResolutionGroupValues(peerId);
|
||||
|
||||
if (isDesktop || isWebDesktop) {
|
||||
checkDesktopKeyboardMode();
|
||||
// checkDesktopKeyboardMode may change the keyboard mode if the current
|
||||
// mode is not supported. Re-sync InputModel.keyboardMode afterwards.
|
||||
// Note: updateKeyboardMode() is a no-op on mobile (early-returns).
|
||||
await checkDesktopKeyboardMode();
|
||||
await parent.target?.inputModel.updateKeyboardMode();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -2051,6 +2164,9 @@ class CanvasModel with ChangeNotifier {
|
||||
ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
|
||||
Timer? _timerMobileFocusCanvasCursor;
|
||||
Timer? _timerMobileRestoreCanvasOffset;
|
||||
Offset? _offsetBeforeMobileSoftKeyboard;
|
||||
double? _scaleBeforeMobileSoftKeyboard;
|
||||
|
||||
// `isMobileCanvasChanged` is used to avoid canvas reset when changing the input method
|
||||
// after showing the soft keyboard.
|
||||
@@ -2114,10 +2230,32 @@ class CanvasModel with ChangeNotifier {
|
||||
double w = size.width - leftToEdge - rightToEdge;
|
||||
double h = size.height - topToEdge - bottomToEdge;
|
||||
if (isMobile) {
|
||||
// Account for horizontal safe area insets on both orientations.
|
||||
w = w - mediaData.padding.left - mediaData.padding.right;
|
||||
// Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any
|
||||
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
|
||||
h = h -
|
||||
mediaData.viewInsets.bottom -
|
||||
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
|
||||
0);
|
||||
// Orientation-specific handling:
|
||||
// - Portrait: additionally subtract top padding (e.g. status bar / notch)
|
||||
// - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides)
|
||||
final isPortrait = size.height > size.width;
|
||||
if (isPortrait) {
|
||||
// In portrait mode, subtract the top safe-area padding (e.g. status bar / notch)
|
||||
// so the remote image is not truncated, while keeping the bottom inset to avoid
|
||||
// introducing unnecessary blank space around the canvas.
|
||||
//
|
||||
// iOS -> Android, portrait, adjust mode:
|
||||
// h = h (no padding subtracted): top and bottom are truncated
|
||||
// https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998
|
||||
// h = h - top - bottom: extra blank spaces appear
|
||||
// https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e
|
||||
// h = h - top (current): works fine
|
||||
// https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81
|
||||
h = h - mediaData.padding.top;
|
||||
}
|
||||
}
|
||||
return Size(w < 0 ? 0 : w, h < 0 ? 0 : h);
|
||||
}
|
||||
@@ -2516,6 +2654,9 @@ class CanvasModel with ChangeNotifier {
|
||||
_scale = 1.0;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
_offsetBeforeMobileSoftKeyboard = null;
|
||||
_scaleBeforeMobileSoftKeyboard = null;
|
||||
}
|
||||
|
||||
updateScrollPercent() {
|
||||
@@ -2544,6 +2685,31 @@ class CanvasModel with ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
void saveMobileOffsetBeforeSoftKeyboard() {
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
_offsetBeforeMobileSoftKeyboard = Offset(_x, _y);
|
||||
_scaleBeforeMobileSoftKeyboard = _scale;
|
||||
}
|
||||
|
||||
void restoreMobileOffsetAfterSoftKeyboard() {
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
final targetOffset = _offsetBeforeMobileSoftKeyboard;
|
||||
final targetScale = _scaleBeforeMobileSoftKeyboard;
|
||||
if (targetOffset == null || targetScale == null) {
|
||||
return;
|
||||
}
|
||||
_timerMobileRestoreCanvasOffset = Timer(Duration(milliseconds: 100), () {
|
||||
updateSize();
|
||||
_x = targetOffset.dx;
|
||||
_y = targetOffset.dy;
|
||||
_scale = targetScale;
|
||||
_offsetBeforeMobileSoftKeyboard = null;
|
||||
_scaleBeforeMobileSoftKeyboard = null;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
// mobile only
|
||||
// Move the canvas to make the cursor visible(center) on the screen.
|
||||
void _moveToCenterCursor() {
|
||||
@@ -2796,8 +2962,13 @@ class CursorModel with ChangeNotifier {
|
||||
_lastIsBlocked = true;
|
||||
}
|
||||
if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) {
|
||||
parent.target?.canvasModel.mobileFocusCanvasCursor();
|
||||
parent.target?.canvasModel.isMobileCanvasChanged = false;
|
||||
if (keyboardIsVisible) {
|
||||
parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard();
|
||||
parent.target?.canvasModel.mobileFocusCanvasCursor();
|
||||
parent.target?.canvasModel.isMobileCanvasChanged = false;
|
||||
} else {
|
||||
parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard();
|
||||
}
|
||||
}
|
||||
_lastKeyboardIsVisible = keyboardIsVisible;
|
||||
}
|
||||
@@ -3759,6 +3930,9 @@ class FFI {
|
||||
ffiModel.clear();
|
||||
canvasModel.clear();
|
||||
inputModel.resetModifiers();
|
||||
// Dispose relative mouse mode resources to ensure cursor is restored
|
||||
inputModel.disposeRelativeMouseMode();
|
||||
inputModel.disposeSideButtonTracking();
|
||||
if (closeSession) {
|
||||
await bind.sessionClose(sessionId: sessionId);
|
||||
}
|
||||
|
||||
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import 'package:flutter_hbb/mobile/pages/settings_page.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../common.dart';
|
||||
@@ -51,6 +50,8 @@ class ServerModel with ChangeNotifier {
|
||||
|
||||
Timer? cmHiddenTimer;
|
||||
|
||||
final _wakelockKey = UniqueKey();
|
||||
|
||||
bool get isStart => _isStart;
|
||||
|
||||
bool get mediaOk => _mediaOk;
|
||||
@@ -297,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
@@ -315,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
@@ -344,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
@@ -466,21 +467,8 @@ class ServerModel with ChangeNotifier {
|
||||
await parent.target?.invokeMethod("stop_service");
|
||||
await bind.mainStopService();
|
||||
notifyListeners();
|
||||
if (!isLinux) {
|
||||
// current linux is not supported
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setPermanentPassword(String newPW) async {
|
||||
await bind.mainSetPermanentPassword(password: newPW);
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
final pw = await bind.mainGetPermanentPassword();
|
||||
if (newPW == pw) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// for androidUpdatekeepScreenOn only
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
|
||||
fetchID() async {
|
||||
@@ -561,10 +549,19 @@ class ServerModel with ChangeNotifier {
|
||||
if (index < 0) {
|
||||
_clients.add(client);
|
||||
} else {
|
||||
if (_clients[index].authorized) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients[index].authorized = true;
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
_clients.add(client);
|
||||
@@ -613,12 +610,12 @@ class ServerModel with ChangeNotifier {
|
||||
void showLoginDialog(Client client) {
|
||||
showClientDialog(
|
||||
client,
|
||||
client.isFileTransfer
|
||||
? "Transfer file"
|
||||
client.isFileTransfer
|
||||
? "Transfer file"
|
||||
: client.isViewCamera
|
||||
? "View camera"
|
||||
: client.isTerminal
|
||||
? "Terminal"
|
||||
: client.isTerminal
|
||||
? "Terminal"
|
||||
: "Share screen",
|
||||
'Do you accept?',
|
||||
'android_new_connection_tip',
|
||||
@@ -797,12 +794,10 @@ class ServerModel with ChangeNotifier {
|
||||
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
|
||||
(keepScreenOn == KeepScreenOn.duringControlled &&
|
||||
_clients.map((e) => !e.disconnected).isNotEmpty);
|
||||
if (on != await WakelockPlus.enabled) {
|
||||
if (on) {
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
if (on) {
|
||||
WakelockManager.enable(_wakelockKey, isServer: true);
|
||||
} else {
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -823,6 +818,7 @@ class Client {
|
||||
bool isTerminal = false;
|
||||
String portForward = "";
|
||||
String name = "";
|
||||
String avatar = "";
|
||||
String peerId = ""; // peer user's id,show at app
|
||||
bool keyboard = false;
|
||||
bool clipboard = false;
|
||||
@@ -831,6 +827,7 @@ class Client {
|
||||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
@@ -850,6 +847,7 @@ class Client {
|
||||
isTerminal = json['is_terminal'] ?? false;
|
||||
portForward = json['port_forward'];
|
||||
name = json['name'];
|
||||
avatar = json['avatar'] ?? '';
|
||||
peerId = json['peer_id'];
|
||||
keyboard = json['keyboard'];
|
||||
clipboard = json['clipboard'];
|
||||
@@ -858,6 +856,7 @@ class Client {
|
||||
restart = json['restart'];
|
||||
recording = json['recording'];
|
||||
blockInput = json['block_input'];
|
||||
privacyMode = json['privacy_mode'] ?? privacyMode;
|
||||
disconnected = json['disconnected'];
|
||||
fromSwitch = json['from_switch'];
|
||||
inVoiceCall = json['in_voice_call'];
|
||||
@@ -873,6 +872,7 @@ class Client {
|
||||
data['is_terminal'] = isTerminal;
|
||||
data['port_forward'] = portForward;
|
||||
data['name'] = name;
|
||||
data['avatar'] = avatar;
|
||||
data['peer_id'] = peerId;
|
||||
data['keyboard'] = keyboard;
|
||||
data['clipboard'] = clipboard;
|
||||
@@ -881,6 +881,7 @@ class Client {
|
||||
data['restart'] = restart;
|
||||
data['recording'] = recording;
|
||||
data['block_input'] = blockInput;
|
||||
data['privacy_mode'] = privacyMode;
|
||||
data['disconnected'] = disconnected;
|
||||
data['from_switch'] = fromSwitch;
|
||||
data['in_voice_call'] = inVoiceCall;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
@@ -30,6 +29,11 @@ class StateGlobal {
|
||||
|
||||
String _inputSource = '';
|
||||
|
||||
// Track relative mouse mode state for each peer connection.
|
||||
// Key: peerId, Value: true if relative mouse mode is active.
|
||||
// Note: This is session-only runtime state, NOT persisted to config.
|
||||
final RxMap<String, bool> relativeMouseModeState = <String, bool>{}.obs;
|
||||
|
||||
// Use for desktop -> remote toolbar -> resolution
|
||||
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};
|
||||
|
||||
|
||||
@@ -24,21 +24,33 @@ class TerminalModel with ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
|
||||
final _inputBuffer = <String>[];
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
final _pendingOutputSuppressFlags = <bool>[];
|
||||
int _pendingOutputSize = 0;
|
||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||
// View ready state: true when terminal has valid dimensions, safe to write
|
||||
bool _terminalViewReady = false;
|
||||
bool _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||
// Desktop -> Desktop works fine.
|
||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\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));
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
@@ -63,7 +75,10 @@ class TerminalModel with ChangeNotifier {
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = _handleInput;
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
@@ -74,6 +89,12 @@ class TerminalModel with ChangeNotifier {
|
||||
// This piece of code must be placed before the conditional check in order to initialize properly.
|
||||
onResizeExternal?.call(w, h, pw, ph);
|
||||
|
||||
// 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.
|
||||
if (!_terminalViewReady) {
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Notify remote terminal of resize
|
||||
try {
|
||||
@@ -97,14 +118,16 @@ class TerminalModel with ChangeNotifier {
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) return;
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
// Request the remote side to open a terminal with default shell
|
||||
// The remote side will decide which shell to use based on its OS
|
||||
|
||||
@@ -141,11 +164,15 @@ class TerminalModel with ChangeNotifier {
|
||||
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
||||
// Optionally show error to user
|
||||
if (e is TimeoutException) {
|
||||
terminal.write('Failed to open terminal: Connection timeout\r\n');
|
||||
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendVirtualKey(String data) async {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
@@ -249,8 +276,8 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
void _handleTerminalOpened(Map<String, dynamic> evt) {
|
||||
final bool success = getSuccessFromEvt(evt);
|
||||
final String message = evt['message'] ?? '';
|
||||
final String? serviceId = evt['service_id'];
|
||||
final String message = evt['message']?.toString() ?? '';
|
||||
final String? serviceId = evt['service_id']?.toString();
|
||||
|
||||
debugPrint(
|
||||
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
|
||||
@@ -258,7 +285,21 @@ class TerminalModel with ChangeNotifier {
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// Service ID is now saved on the Rust side in handle_terminal_response
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[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
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
// mark view ready now to avoid output stuck in buffer indefinitely.
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
_processBufferedInputAsync().then((_) {
|
||||
@@ -269,17 +310,21 @@ class TerminalModel with ChangeNotifier {
|
||||
});
|
||||
|
||||
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) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
terminal.write('Failed to open terminal: $message\r\n');
|
||||
_writeToTerminal('Failed to open terminal: $message\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +349,8 @@ class TerminalModel with ChangeNotifier {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
@@ -323,29 +370,127 @@ class TerminalModel with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.write(text);
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write text to terminal, buffering if the view is not yet ready.
|
||||
/// All terminal output should go through this method to avoid NaN errors
|
||||
/// from writing before the terminal view has valid layout dimensions.
|
||||
void _writeToTerminal(
|
||||
String text, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
// which can cause a brief visual glitch on flush. This is acceptable
|
||||
// because it only affects the pre-layout buffering window and the
|
||||
// terminal will self-correct on subsequent output.
|
||||
if (text.length >= _kMaxOutputBufferChars) {
|
||||
final truncated = text.substring(text.length - _kMaxOutputBufferChars);
|
||||
_pendingOutputChunks
|
||||
..clear()
|
||||
..add(truncated);
|
||||
_pendingOutputSuppressFlags
|
||||
..clear()
|
||||
..add(suppressTerminalOutput);
|
||||
_pendingOutputSize = truncated.length;
|
||||
} else {
|
||||
_pendingOutputChunks.add(text);
|
||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||
_pendingOutputSize += text.length;
|
||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||
_pendingOutputChunks.length > 1) {
|
||||
final removed = _pendingOutputChunks.removeAt(0);
|
||||
_pendingOutputSuppressFlags.removeAt(0);
|
||||
_pendingOutputSize -= removed.length;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
}
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_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.
|
||||
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() {
|
||||
if (_terminalViewReady) return;
|
||||
_terminalViewReady = true;
|
||||
_flushOutputBuffer();
|
||||
}
|
||||
|
||||
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||
final int exitCode = evt['exit_code'] ?? 0;
|
||||
terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
final String message = evt['message'] ?? 'Unknown error';
|
||||
terminal.write('\r\nTerminal error: $message\r\n');
|
||||
_writeToTerminal('\r\nTerminal error: $message\r\n');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
_markViewReadyScheduled = false;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
// Terminal cleanup is handled server-side when service closes
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -16,9 +16,25 @@ bool refreshingUser = false;
|
||||
|
||||
class UserModel {
|
||||
final RxString userName = ''.obs;
|
||||
final RxString displayName = ''.obs;
|
||||
final RxString avatar = ''.obs;
|
||||
final RxBool isAdmin = false.obs;
|
||||
final RxString networkError = ''.obs;
|
||||
bool get isLogin => userName.isNotEmpty;
|
||||
String get displayNameOrUserName =>
|
||||
displayName.value.trim().isEmpty ? userName.value : displayName.value;
|
||||
String get accountLabelWithHandle {
|
||||
final username = userName.value.trim();
|
||||
if (username.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final preferred = displayName.value.trim();
|
||||
if (preferred.isEmpty || preferred == username) {
|
||||
return username;
|
||||
}
|
||||
return '$preferred (@$username)';
|
||||
}
|
||||
|
||||
WeakReference<FFI> parent;
|
||||
|
||||
UserModel(this.parent) {
|
||||
@@ -98,7 +114,9 @@ class UserModel {
|
||||
_updateLocalUserInfo() {
|
||||
final userInfo = getLocalUserInfo();
|
||||
if (userInfo != null) {
|
||||
userName.value = userInfo['name'];
|
||||
userName.value = (userInfo['name'] ?? '').toString();
|
||||
displayName.value = (userInfo['display_name'] ?? '').toString();
|
||||
avatar.value = (userInfo['avatar'] ?? '').toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +128,14 @@ class UserModel {
|
||||
await gFFI.groupModel.reset();
|
||||
}
|
||||
userName.value = '';
|
||||
displayName.value = '';
|
||||
avatar.value = '';
|
||||
}
|
||||
|
||||
_parseAndUpdateUser(UserPayload user) {
|
||||
userName.value = user.name;
|
||||
displayName.value = user.displayName;
|
||||
avatar.value = user.avatar;
|
||||
isAdmin.value = user.isAdmin;
|
||||
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
|
||||
if (isWeb) {
|
||||
|
||||
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal file
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
/// A small helper for accumulating fractional mouse deltas and emitting integer deltas.
|
||||
///
|
||||
/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas
|
||||
/// are doubles. This accumulator preserves sub-pixel movement by carrying the
|
||||
/// fractional remainder across events.
|
||||
class RelativeMouseDelta {
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
const RelativeMouseDelta(this.x, this.y);
|
||||
}
|
||||
|
||||
/// Accumulates fractional mouse deltas and returns integer deltas when available.
|
||||
class RelativeMouseAccumulator {
|
||||
double _fracX = 0.0;
|
||||
double _fracY = 0.0;
|
||||
|
||||
/// Adds a delta and returns an integer delta when at least one axis reaches a
|
||||
/// magnitude of 1px (after truncation towards zero).
|
||||
///
|
||||
/// If [maxDelta] is > 0, the returned integer delta is clamped to
|
||||
/// [-maxDelta, maxDelta] on each axis.
|
||||
RelativeMouseDelta? add(
|
||||
double dx,
|
||||
double dy, {
|
||||
required int maxDelta,
|
||||
}) {
|
||||
// Guard against misuse: negative maxDelta would silently disable clamping.
|
||||
assert(maxDelta >= 0, 'maxDelta must be non-negative');
|
||||
|
||||
_fracX += dx;
|
||||
_fracY += dy;
|
||||
|
||||
int intX = _fracX.truncate();
|
||||
int intY = _fracY.truncate();
|
||||
|
||||
if (intX == 0 && intY == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clamp before subtracting so excess movement is preserved in the accumulator
|
||||
// rather than being permanently discarded during spikes.
|
||||
if (maxDelta > 0) {
|
||||
intX = intX.clamp(-maxDelta, maxDelta);
|
||||
intY = intY.clamp(-maxDelta, maxDelta);
|
||||
}
|
||||
|
||||
_fracX -= intX;
|
||||
_fracY -= intY;
|
||||
|
||||
return RelativeMouseDelta(intX, intY);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_fracX = 0.0;
|
||||
_fracY = 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1159,10 +1159,6 @@ class RustdeskImpl {
|
||||
return Future.value('');
|
||||
}
|
||||
|
||||
Future<String> mainGetPermanentPassword({dynamic hint}) {
|
||||
return Future.value('');
|
||||
}
|
||||
|
||||
Future<String> mainGetFingerprint({dynamic hint}) {
|
||||
return Future.value('');
|
||||
}
|
||||
@@ -1346,9 +1342,9 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("mainUpdateTemporaryPassword");
|
||||
}
|
||||
|
||||
Future<void> mainSetPermanentPassword(
|
||||
Future<bool> mainSetPermanentPasswordWithResult(
|
||||
{required String password, dynamic hint}) {
|
||||
throw UnimplementedError("mainSetPermanentPassword");
|
||||
throw UnimplementedError("mainSetPermanentPasswordWithResult");
|
||||
}
|
||||
|
||||
Future<bool> mainCheckSuperUserPermission({dynamic hint}) {
|
||||
@@ -1542,7 +1538,10 @@ class RustdeskImpl {
|
||||
|
||||
Future<void> mainAccountAuth(
|
||||
{required String op, required bool rememberMe, dynamic hint}) {
|
||||
return Future(() => js.context.callMethod('setByName', [
|
||||
// Safari only allows auth popups while handling the original user gesture.
|
||||
// Use Future.sync so the JS call runs synchronously (pre-opening the OIDC
|
||||
// window) while any interop error still surfaces as a Future error.
|
||||
return Future.sync(() => js.context.callMethod('setByName', [
|
||||
'account_auth',
|
||||
jsonEncode({'op': op, 'remember': rememberMe})
|
||||
]));
|
||||
@@ -1730,7 +1729,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
||||
return '[]';
|
||||
}
|
||||
|
||||
String mainSupportedInputSource({dynamic hint}) {
|
||||
@@ -2020,5 +2019,30 @@ class RustdeskImpl {
|
||||
return js.context.callMethod('getByName', ['audit_guid']);
|
||||
}
|
||||
|
||||
bool mainSetCursorPosition({required int x, required int y, dynamic hint}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool mainClipCursor(
|
||||
{required int left,
|
||||
required int top,
|
||||
required int right,
|
||||
required int bottom,
|
||||
required bool enable,
|
||||
dynamic hint}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
|
||||
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() {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(runner LANGUAGES CXX)
|
||||
project(runner LANGUAGES C CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
@@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Wayland protocol for keyboard shortcuts inhibit
|
||||
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
|
||||
pkg_check_modules(WAYLAND_PROTOCOLS_PKG QUIET wayland-protocols)
|
||||
pkg_check_modules(WAYLAND_SCANNER_PKG QUIET wayland-scanner)
|
||||
|
||||
if(WAYLAND_PROTOCOLS_PKG_FOUND)
|
||||
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
|
||||
endif()
|
||||
if(WAYLAND_SCANNER_PKG_FOUND)
|
||||
pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner)
|
||||
endif()
|
||||
|
||||
if(WAYLAND_CLIENT_FOUND AND WAYLAND_PROTOCOLS_DIR AND WAYLAND_SCANNER)
|
||||
set(KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL
|
||||
"${WAYLAND_PROTOCOLS_DIR}/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml")
|
||||
|
||||
if(EXISTS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL})
|
||||
set(WAYLAND_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/wayland-protocols")
|
||||
file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR})
|
||||
|
||||
# Generate client header
|
||||
add_custom_command(
|
||||
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
COMMAND ${WAYLAND_SCANNER} client-header
|
||||
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
# Generate protocol code
|
||||
add_custom_command(
|
||||
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
COMMAND ${WAYLAND_SCANNER} private-code
|
||||
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
set(WAYLAND_PROTOCOL_SOURCES
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
)
|
||||
|
||||
set(HAS_KEYBOARD_SHORTCUTS_INHIBIT TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME above,
|
||||
@@ -63,9 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"wayland_shortcuts_inhibit.cc"
|
||||
"bump_mouse.cc"
|
||||
"bump_mouse_x11.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
${WAYLAND_PROTOCOL_SOURCES}
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
@@ -78,6 +129,13 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
|
||||
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
|
||||
|
||||
# Wayland support for keyboard shortcuts inhibit
|
||||
if(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
target_include_directories(${BINARY_NAME} PRIVATE ${WAYLAND_GENERATED_DIR})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
|
||||
endif()
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
#include "wayland_shortcuts_inhibit.h"
|
||||
#endif
|
||||
|
||||
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
@@ -24,6 +29,80 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
|
||||
|
||||
extern bool gIsConnectionManager;
|
||||
|
||||
// --- Side mouse button support (back/forward) ---
|
||||
// Flutter's Linux embedder doesn't deliver X11 button 8/9 events to Dart.
|
||||
// We intercept them via GDK and forward through a dedicated platform channel.
|
||||
|
||||
static const char* kSideButtonChannelName = "org.rustdesk.rustdesk/side_buttons";
|
||||
|
||||
static gboolean on_side_button_event(GtkWidget* widget, GdkEventButton* event, gpointer user_data) {
|
||||
if (event->button != 8 && event->button != 9) {
|
||||
return FALSE;
|
||||
}
|
||||
// Ignore GDK_2BUTTON_PRESS / GDK_3BUTTON_PRESS (double/triple-click synthetic
|
||||
// events) - only handle real press and release.
|
||||
if (event->type != GDK_BUTTON_PRESS && event->type != GDK_BUTTON_RELEASE) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
FlMethodChannel* channel = FL_METHOD_CHANNEL(user_data);
|
||||
if (channel == NULL) return FALSE;
|
||||
|
||||
g_autoptr(FlValue) args = fl_value_new_map();
|
||||
fl_value_set_string_take(args, "button",
|
||||
fl_value_new_string(event->button == 8 ? "back" : "forward"));
|
||||
fl_value_set_string_take(args, "type",
|
||||
fl_value_new_string(event->type == GDK_BUTTON_PRESS ? "down" : "up"));
|
||||
|
||||
fl_method_channel_invoke_method(channel, "onSideMouseButton", args,
|
||||
NULL, NULL, NULL);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static FlMethodChannel* side_buttons_create_channel(FlEngine* engine) {
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
return fl_method_channel_new(
|
||||
fl_engine_get_binary_messenger(engine),
|
||||
kSideButtonChannelName,
|
||||
FL_METHOD_CODEC(codec));
|
||||
}
|
||||
|
||||
static void side_buttons_channel_destroy(gpointer data) {
|
||||
g_object_unref(data);
|
||||
}
|
||||
|
||||
static void side_buttons_init_for_window(GtkWindow* window, FlMethodChannel* channel) {
|
||||
// Guard against double-initialization (would leave dangling signal user_data).
|
||||
if (g_object_get_data(G_OBJECT(window), "side-buttons-channel") != NULL) return;
|
||||
|
||||
gtk_widget_add_events(GTK_WIDGET(window),
|
||||
GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK);
|
||||
// Store channel on the window so it stays alive and is freed with the window.
|
||||
g_object_set_data_full(G_OBJECT(window), "side-buttons-channel",
|
||||
g_object_ref(channel), side_buttons_channel_destroy);
|
||||
g_signal_connect(window, "button-press-event",
|
||||
G_CALLBACK(on_side_button_event), channel);
|
||||
g_signal_connect(window, "button-release-event",
|
||||
G_CALLBACK(on_side_button_event), channel);
|
||||
}
|
||||
|
||||
static void on_subwindow_created(FlPluginRegistry* registry) {
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
wayland_shortcuts_inhibit_init_for_subwindow(registry);
|
||||
#endif
|
||||
// Set up side button forwarding for sub-windows.
|
||||
if (registry == NULL || !FL_IS_VIEW(registry)) return;
|
||||
FlView* view = FL_VIEW(registry);
|
||||
GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view));
|
||||
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
|
||||
FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view));
|
||||
if (channel == NULL) return;
|
||||
side_buttons_init_for_window(GTK_WINDOW(toplevel), channel);
|
||||
g_object_unref(channel); // window now owns a ref via g_object_set_data_full
|
||||
}
|
||||
}
|
||||
|
||||
GtkWidget *find_gl_area(GtkWidget *widget);
|
||||
|
||||
// Implements GApplication::activate.
|
||||
@@ -91,6 +170,13 @@ static void my_application_activate(GApplication* application) {
|
||||
gtk_widget_show(GTK_WIDGET(window));
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
|
||||
// Register callback for sub-windows created by desktop_multi_window plugin.
|
||||
// Handles both Wayland shortcuts inhibition (guarded inside) and side button
|
||||
// forwarding. Safe to call on X11-only builds - the plugin just stores the
|
||||
// callback pointer regardless of windowing system.
|
||||
desktop_multi_window_plugin_set_window_created_callback(
|
||||
(WindowCreatedCallback)on_subwindow_created);
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
@@ -104,6 +190,11 @@ static void my_application_activate(GApplication* application) {
|
||||
self,
|
||||
nullptr);
|
||||
|
||||
// Forward side mouse button events (back/forward) to Dart on the main window.
|
||||
FlMethodChannel* side_channel = side_buttons_create_channel(fl_view_get_engine(view));
|
||||
side_buttons_init_for_window(window, side_channel);
|
||||
g_object_unref(side_channel);
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
|
||||
244
flutter/linux/wayland_shortcuts_inhibit.cc
Normal file
244
flutter/linux/wayland_shortcuts_inhibit.cc
Normal file
@@ -0,0 +1,244 @@
|
||||
// Wayland keyboard shortcuts inhibit implementation
|
||||
// Uses the zwp_keyboard_shortcuts_inhibit_manager_v1 protocol to request
|
||||
// the compositor to disable system shortcuts for specific windows.
|
||||
|
||||
#include "wayland_shortcuts_inhibit.h"
|
||||
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
#include <cstring>
|
||||
#include <gdk/gdkwayland.h>
|
||||
#include <wayland-client.h>
|
||||
#include "keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
|
||||
// Data structure to hold inhibitor state for each window
|
||||
typedef struct {
|
||||
struct zwp_keyboard_shortcuts_inhibit_manager_v1* manager;
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* inhibitor;
|
||||
} ShortcutsInhibitData;
|
||||
|
||||
// Cleanup function for ShortcutsInhibitData
|
||||
static void shortcuts_inhibit_data_free(gpointer data) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
|
||||
if (inhibit_data->inhibitor != NULL) {
|
||||
zwp_keyboard_shortcuts_inhibitor_v1_destroy(inhibit_data->inhibitor);
|
||||
}
|
||||
if (inhibit_data->manager != NULL) {
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_destroy(inhibit_data->manager);
|
||||
}
|
||||
g_free(inhibit_data);
|
||||
}
|
||||
|
||||
// Wayland registry handler to find the shortcuts inhibit manager
|
||||
static void registry_handle_global(void* data, struct wl_registry* registry,
|
||||
uint32_t name, const char* interface,
|
||||
uint32_t /*version*/) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
|
||||
if (strcmp(interface,
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_interface.name) == 0) {
|
||||
inhibit_data->manager =
|
||||
static_cast<zwp_keyboard_shortcuts_inhibit_manager_v1*>(wl_registry_bind(
|
||||
registry, name, &zwp_keyboard_shortcuts_inhibit_manager_v1_interface,
|
||||
1));
|
||||
}
|
||||
}
|
||||
|
||||
static void registry_handle_global_remove(void* /*data*/, struct wl_registry* /*registry*/,
|
||||
uint32_t /*name*/) {
|
||||
// Not needed for this use case
|
||||
}
|
||||
|
||||
static const struct wl_registry_listener registry_listener = {
|
||||
registry_handle_global,
|
||||
registry_handle_global_remove,
|
||||
};
|
||||
|
||||
// Inhibitor event handlers
|
||||
static void inhibitor_active(void* /*data*/,
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
|
||||
// Inhibitor is now active, shortcuts are being captured
|
||||
}
|
||||
|
||||
static void inhibitor_inactive(void* /*data*/,
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
|
||||
// Inhibitor is now inactive, shortcuts restored to compositor
|
||||
}
|
||||
|
||||
static const struct zwp_keyboard_shortcuts_inhibitor_v1_listener inhibitor_listener = {
|
||||
inhibitor_active,
|
||||
inhibitor_inactive,
|
||||
};
|
||||
|
||||
// Forward declaration
|
||||
static void uninhibit_keyboard_shortcuts(GtkWindow* window);
|
||||
|
||||
// Inhibit keyboard shortcuts on Wayland for a specific window
|
||||
static void inhibit_keyboard_shortcuts(GtkWindow* window) {
|
||||
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
|
||||
if (!GDK_IS_WAYLAND_DISPLAY(display)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already inhibited for this window
|
||||
if (g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data") != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
ShortcutsInhibitData* inhibit_data = g_new0(ShortcutsInhibitData, 1);
|
||||
|
||||
struct wl_display* wl_display = gdk_wayland_display_get_wl_display(display);
|
||||
if (wl_display == NULL) {
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_registry* registry = wl_display_get_registry(wl_display);
|
||||
if (registry == NULL) {
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
wl_registry_add_listener(registry, ®istry_listener, inhibit_data);
|
||||
wl_display_roundtrip(wl_display);
|
||||
|
||||
if (inhibit_data->manager == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
GdkWindow* gdk_window = gtk_widget_get_window(GTK_WIDGET(window));
|
||||
if (gdk_window == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_surface* surface = gdk_wayland_window_get_wl_surface(gdk_window);
|
||||
if (surface == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
GdkSeat* gdk_seat = gdk_display_get_default_seat(display);
|
||||
if (gdk_seat == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_seat* seat = gdk_wayland_seat_get_wl_seat(gdk_seat);
|
||||
if (seat == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
inhibit_data->inhibitor =
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_inhibit_shortcuts(
|
||||
inhibit_data->manager, surface, seat);
|
||||
|
||||
if (inhibit_data->inhibitor == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add listener to monitor active/inactive state
|
||||
zwp_keyboard_shortcuts_inhibitor_v1_add_listener(
|
||||
inhibit_data->inhibitor, &inhibitor_listener, window);
|
||||
|
||||
wl_display_roundtrip(wl_display);
|
||||
wl_registry_destroy(registry);
|
||||
|
||||
// Associate the inhibit data with the window for cleanup on destroy
|
||||
g_object_set_data_full(G_OBJECT(window), "shortcuts-inhibit-data",
|
||||
inhibit_data, shortcuts_inhibit_data_free);
|
||||
}
|
||||
|
||||
// Remove keyboard shortcuts inhibitor from a window
|
||||
static void uninhibit_keyboard_shortcuts(GtkWindow* window) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(
|
||||
g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data"));
|
||||
|
||||
if (inhibit_data == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This will trigger shortcuts_inhibit_data_free via g_object_set_data
|
||||
g_object_set_data(G_OBJECT(window), "shortcuts-inhibit-data", NULL);
|
||||
}
|
||||
|
||||
// Focus event handlers for dynamic inhibitor management
|
||||
static gboolean on_window_focus_in(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
return FALSE; // Continue event propagation
|
||||
}
|
||||
|
||||
static gboolean on_window_focus_out(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
uninhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
return FALSE; // Continue event propagation
|
||||
}
|
||||
|
||||
// Key for marking window as having focus handlers connected
|
||||
static const char* const kFocusHandlersConnectedKey = "shortcuts-inhibit-focus-handlers-connected";
|
||||
// Key for marking window as having a pending realize handler
|
||||
static const char* const kRealizeHandlerConnectedKey = "shortcuts-inhibit-realize-handler-connected";
|
||||
|
||||
// Callback when window is realized (mapped to screen)
|
||||
// Sets up focus-based inhibitor management
|
||||
static void on_window_realize(GtkWidget* widget, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
// Check if focus handlers are already connected to avoid duplicates
|
||||
if (g_object_get_data(G_OBJECT(widget), kFocusHandlersConnectedKey) != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect focus events for dynamic inhibitor management
|
||||
g_signal_connect(widget, "focus-in-event",
|
||||
G_CALLBACK(on_window_focus_in), NULL);
|
||||
g_signal_connect(widget, "focus-out-event",
|
||||
G_CALLBACK(on_window_focus_out), NULL);
|
||||
|
||||
// Mark as connected to prevent duplicate connections
|
||||
g_object_set_data(G_OBJECT(widget), kFocusHandlersConnectedKey, GINT_TO_POINTER(1));
|
||||
|
||||
// If window already has focus, create inhibitor now
|
||||
if (gtk_window_has_toplevel_focus(GTK_WINDOW(widget))) {
|
||||
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API: Initialize shortcuts inhibit for a sub-window
|
||||
void wayland_shortcuts_inhibit_init_for_subwindow(void* view) {
|
||||
GtkWidget* widget = GTK_WIDGET(view);
|
||||
GtkWidget* toplevel = gtk_widget_get_toplevel(widget);
|
||||
|
||||
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
|
||||
// Check if already initialized to avoid duplicate realize handlers
|
||||
if (g_object_get_data(G_OBJECT(toplevel), kFocusHandlersConnectedKey) != NULL ||
|
||||
g_object_get_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey) != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gtk_widget_get_realized(toplevel)) {
|
||||
// Window is already realized, set up focus handlers now
|
||||
on_window_realize(toplevel, NULL);
|
||||
} else {
|
||||
// Mark realize handler as connected to prevent duplicate connections
|
||||
// if called again before window is realized
|
||||
g_object_set_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey, GINT_TO_POINTER(1));
|
||||
// Wait for window to be realized
|
||||
g_signal_connect(toplevel, "realize",
|
||||
G_CALLBACK(on_window_realize), NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
22
flutter/linux/wayland_shortcuts_inhibit.h
Normal file
22
flutter/linux/wayland_shortcuts_inhibit.h
Normal file
@@ -0,0 +1,22 @@
|
||||
// Wayland keyboard shortcuts inhibit support
|
||||
// This module provides functionality to inhibit system keyboard shortcuts
|
||||
// on Wayland compositors, allowing remote desktop windows to capture all
|
||||
// key events including Super, Alt+Tab, etc.
|
||||
|
||||
#ifndef WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
#define WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
// Initialize shortcuts inhibit for a sub-window created by desktop_multi_window plugin.
|
||||
// This sets up focus-based inhibitor management: inhibitor is created when
|
||||
// the window gains focus and destroyed when it loses focus.
|
||||
//
|
||||
// @param view The FlView of the sub-window
|
||||
void wayland_shortcuts_inhibit_init_for_subwindow(void* view);
|
||||
|
||||
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
#endif // WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
@@ -19,6 +19,22 @@ import window_manager
|
||||
import window_size
|
||||
import texture_rgba_renderer
|
||||
|
||||
// Global state for relative mouse mode
|
||||
// All properties and methods must be accessed on the main thread since they
|
||||
// interact with NSEvent monitors, CoreGraphics APIs, and Flutter channels.
|
||||
// Note: We avoid @MainActor to maintain macOS 10.14 compatibility.
|
||||
class RelativeMouseState {
|
||||
static let shared = RelativeMouseState()
|
||||
|
||||
var enabled = false
|
||||
var eventMonitor: Any?
|
||||
var deltaChannel: FlutterMethodChannel?
|
||||
var accumulatedDeltaX: CGFloat = 0
|
||||
var accumulatedDeltaY: CGFloat = 0
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
rustdesk_core_main();
|
||||
@@ -64,6 +80,104 @@ class MainFlutterWindow: NSWindow {
|
||||
window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua)
|
||||
}
|
||||
|
||||
private func enableNativeRelativeMouseMode(channel: FlutterMethodChannel) -> Bool {
|
||||
assert(Thread.isMainThread, "enableNativeRelativeMouseMode must be called on the main thread")
|
||||
let state = RelativeMouseState.shared
|
||||
if state.enabled {
|
||||
// Already enabled: update the channel so this caller receives deltas.
|
||||
state.deltaChannel = channel
|
||||
return true
|
||||
}
|
||||
|
||||
// Dissociate mouse from cursor position - this locks the cursor in place
|
||||
// Do this FIRST before setting any state
|
||||
let result = CGAssociateMouseAndMouseCursorPosition(0)
|
||||
if result != CGError.success {
|
||||
NSLog("[RustDesk] Failed to dissociate mouse from cursor position: %d", result.rawValue)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only set state after CG call succeeds
|
||||
state.deltaChannel = channel
|
||||
state.accumulatedDeltaX = 0
|
||||
state.accumulatedDeltaY = 0
|
||||
|
||||
// Add local event monitor to capture mouse delta.
|
||||
// Note: Local event monitors are always called on the main thread,
|
||||
// so accessing main-thread-only state is safe here.
|
||||
state.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak state] event in
|
||||
guard let state = state else { return event }
|
||||
// Guard against race: mode may be disabled between weak capture and this check.
|
||||
guard state.enabled else { return event }
|
||||
let deltaX = event.deltaX
|
||||
let deltaY = event.deltaY
|
||||
|
||||
if deltaX != 0 || deltaY != 0 {
|
||||
// Accumulate delta (main thread only - NSEvent local monitors always run on main thread)
|
||||
state.accumulatedDeltaX += deltaX
|
||||
state.accumulatedDeltaY += deltaY
|
||||
|
||||
// Only send if we have integer movement
|
||||
let intX = Int(state.accumulatedDeltaX)
|
||||
let intY = Int(state.accumulatedDeltaY)
|
||||
|
||||
if intX != 0 || intY != 0 {
|
||||
state.accumulatedDeltaX -= CGFloat(intX)
|
||||
state.accumulatedDeltaY -= CGFloat(intY)
|
||||
|
||||
// Send delta to Flutter (already on main thread)
|
||||
state.deltaChannel?.invokeMethod("onMouseDelta", arguments: ["dx": intX, "dy": intY])
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// Check if monitor was created successfully
|
||||
if state.eventMonitor == nil {
|
||||
NSLog("[RustDesk] Failed to create event monitor for relative mouse mode")
|
||||
// Re-associate mouse since we failed
|
||||
CGAssociateMouseAndMouseCursorPosition(1)
|
||||
state.deltaChannel = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Set enabled LAST after everything succeeds
|
||||
state.enabled = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func disableNativeRelativeMouseMode() {
|
||||
assert(Thread.isMainThread, "disableNativeRelativeMouseMode must be called on the main thread")
|
||||
let state = RelativeMouseState.shared
|
||||
if !state.enabled { return }
|
||||
|
||||
state.enabled = false
|
||||
|
||||
// Remove event monitor
|
||||
if let monitor = state.eventMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
state.eventMonitor = nil
|
||||
}
|
||||
|
||||
state.deltaChannel = nil
|
||||
state.accumulatedDeltaX = 0
|
||||
state.accumulatedDeltaY = 0
|
||||
|
||||
// Re-associate mouse with cursor position (non-blocking with async retry)
|
||||
let result = CGAssociateMouseAndMouseCursorPosition(1)
|
||||
if result != CGError.success {
|
||||
NSLog("[RustDesk] Failed to re-associate mouse with cursor position: %d, scheduling retry...", result.rawValue)
|
||||
// Non-blocking retry after 50ms
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
let retryResult = CGAssociateMouseAndMouseCursorPosition(1)
|
||||
if retryResult != CGError.success {
|
||||
NSLog("[RustDesk] Retry failed to re-associate mouse: %d. Cursor may remain locked.", retryResult.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
|
||||
channel.setMethodCallHandler({
|
||||
@@ -96,7 +210,9 @@ class MainFlutterWindow: NSWindow {
|
||||
}
|
||||
case "requestRecordAudio":
|
||||
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
|
||||
result(granted)
|
||||
DispatchQueue.main.async {
|
||||
result(granted)
|
||||
}
|
||||
})
|
||||
break
|
||||
case "bumpMouse":
|
||||
@@ -145,11 +261,22 @@ class MainFlutterWindow: NSWindow {
|
||||
// This function's main action is to toggle whether the mouse cursor is
|
||||
// associated with the mouse position, but setting it to true when it's
|
||||
// already true has the side-effect of cancelling this motion suppression.
|
||||
CGAssociateMouseAndMouseCursorPosition(1 /* true */)
|
||||
//
|
||||
// However, we must NOT call this when relative mouse mode is active,
|
||||
// as it would break the pointer lock established by enableNativeRelativeMouseMode.
|
||||
if !RelativeMouseState.shared.enabled {
|
||||
CGAssociateMouseAndMouseCursorPosition(1 /* true */)
|
||||
}
|
||||
|
||||
result(true)
|
||||
|
||||
break
|
||||
case "enableNativeRelativeMouseMode":
|
||||
let success = self.enableNativeRelativeMouseMode(channel: channel)
|
||||
result(success)
|
||||
|
||||
case "disableNativeRelativeMouseMode":
|
||||
self.disableNativeRelativeMouseMode()
|
||||
result(true)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
|
||||
|
||||
#
|
||||
# Fix OpenSSL build with Android NDK clang on 32-bit architectures
|
||||
#
|
||||
|
||||
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
|
||||
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
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.4+62
|
||||
version: 1.4.7+65
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -113,8 +113,8 @@ dependencies:
|
||||
|
||||
dev_dependencies:
|
||||
icons_launcher: ^2.0.4
|
||||
#flutter_test:
|
||||
#sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
build_runner: ^2.4.6
|
||||
freezed: ^2.4.2
|
||||
flutter_lints: ^2.0.2
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/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
|
||||
~/.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
|
||||
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
|
||||
cargo build --features flutter
|
||||
cargo build --locked --features flutter
|
||||
flutter run $@
|
||||
|
||||
125
flutter/test/input_modifier_utils_test.dart
Normal file
125
flutter/test/input_modifier_utils_test.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('shouldReleaseStaleMobileShift', () {
|
||||
test('does not release when cached shift is already false', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: false,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('releases one-shot mobile shift after a text key', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not release manually toggled shift without tracked key down',
|
||||
() {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
hasTrackedShiftKeyDown: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not release when shift is still physically pressed', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: true,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not release on non-mobile platforms', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: false,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('releases on enter key', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.enter,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('releases on arrow key', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not release on modifier events', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.shiftLeft,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not release on shiftRight modifier events', () {
|
||||
expect(
|
||||
shouldReleaseStaleMobileShift(
|
||||
isMobile: true,
|
||||
cachedShiftPressed: true,
|
||||
actualShiftPressed: false,
|
||||
logicalKey: LogicalKeyboardKey.shiftRight,
|
||||
hasTrackedShiftKeyDown: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user